Skip to content

fix: own the access config record, and reconcile policy with cws-comm instead of pushing over it - #7

Merged
athanbase merged 2 commits into
mainfrom
fix/access-alias-and-policy-reconcile
Aug 28, 2026
Merged

fix: own the access config record, and reconcile policy with cws-comm instead of pushing over it#7
athanbase merged 2 commits into
mainfrom
fix/access-alias-and-policy-reconcile

Conversation

@athanbase

@athanbase athanbase commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

What

Two independent fixes to the agent's access-policy handling, one commit each.

Commit 1 — fix: stop sharing openclaw's config snapshot as the access record

buildOrgConfig stored acct.access, the live channels.openmax.access object inside openclaw's runtime config snapshot, as orgConfig.access. applyConfigEvent then mutated that object in place, which also moved the diff base openclaw's writer uses (applyMergePatch(sourceSnapshot, createMergePatch(runtimeSnapshot, cfg))): the patch came out empty and the write persisted nothing. The plugin now owns a deep copy from the moment it builds the org config (buildAccessSnapshot), and persistAccess shapes the written object through configWithAccess. Live effect is unchanged — decideInbound re-reads orgConfig.access on every call, so in-place mutation is still immediate. persistOwner already built a fresh object and is untouched.

Commit 2 — feat: reconcile agent policy with cws-comm instead of pushing over it

syncConfigToComm PUT the whole local policy unconditionally, from two call sites (event tail and the 5-minute tick), and nothing in the repo ever read GET /agents/{memberId}/policy. Since cws-comm drops agent.config.* events for an offline agent with no replay (internal/transport/ws/agent_config_relay.go: an offline connection "simply receives nothing"), local state falls behind whenever the agent is down — and the next full PUT then overwrites the owner's real settings with the stale ones. It is replaced by a pull-first reconcile:

  1. no self member_id yet → return
  2. GET .../policy fails → return, never PUT
  3. updated_at absent/0 and the snapshot carries no groups/allowlist → no stored policy row server-side, so the open the settings page shows is GetFullPolicy's synthesized default rather than an owner decision → seed ours, nothing to erase
  4. updated_at differs from our marker → server wins: install it live and persist it, no PUT
  5. server unchanged since the marker and the local fingerprint moved → PUT
  6. equal → nothing, and no disk write

The event-driven trigger is now debounced ~3s. The settings page saves one user-visible change as several sequential writes (set-group-scope, then group-allowlist add), so reporting at the tail of the first event raced the owner's own save with a payload whose allowlist was still empty — the erasure would have come back through the event path. The marker is per-bridge and in memory, so a restart deliberately re-reads the server as authoritative.

Three defects in buildReportedPolicy / the report's error handling went with it:

  • mode: 'silent' is dropped from both groups[] and group_allowlist. The server enum is smart|mention and one out-of-enum mode rejects the entire report, so a single silenced group used to cost every other group's settings. serverPolicyToAccess also preserves local silent entries — the server can never be authoritative about a state it cannot store.
  • an empty allowFrom is normalized to ['*']. Locally undefined / [] / ['*'] all mean "anyone may trigger me" (decideInbound only restricts a non-empty list without '*'), while the server reads an empty allow_from as "nobody". allowFrom || ['*'] did not catch this: [] is truthy in JS.
  • 404 no longer means "endpoint unavailable" by default. cws-comm also 404s from verifyAgentGroupMember when the report names a group the agent has left, and remappedConnectError passes the downstream connectErr.Message() through as detail, so a group-scoped rejection names the conversation while a routing 404 does not. A 4xx naming a reported conversation drops that group and retries once; anything else is logged with its status and left for the next cycle instead of being swallowed. A 404 here also cannot mean a missing route, because the GET on the same route family just succeeded.

Sentinel defaults were re-checked line by line against node_modules/@openmaxai/openmax-agent-sdk/src/protocol/access-policy.js at 1.0.3: dmPolicy || 'owner', dmAllowFrom || [], groupPolicy || 'allowlist', mode || 'mention', and allowFrom's three-state equivalence. group_scope is always sent explicitly, because the server defaults an absent one to open while the local default is allowlist.

Also in commit 2: the SDK moves from ^1.0.0 to ^1.0.3, and the periodic tick runs the owner sync and the reconcile in sequence rather than in parallel (both persistOwner and persistAccess read-modify-write the whole config file, so overlapping rounds could drop one of the two fields). The lockfile's stale "version": "0.1.0" is corrected to 1.1.0 by the same npm install.

Why commit 1 stands alone. It touches only the ownership of one object plus its guard test, needs no server behavior, and reverting it restores the previous (broken but self-consistent) state without affecting the reconcile. Commit 2 depends on it: the adopt branch is only durable because the write path actually produces a diff.

Why

Observed timeline on a live agent:

time event
12:00:53 owner adds a group to the allowlist on the settings page
the event arrives and takes effect in memory — the journal shows deliver … ok=true, the agent really answered in that group
the persist is a no-op: config-audit reports identical byte counts before and after, the only difference being meta.lastTouchedAt
20:05:25 restart reads access.groups back from disk as {}
12:10:58 the periodic tick PUTs the stale (empty) policy
12:15:58 second report — the server's group_allowlist is now null and the per-group rows are deleted

Commit 1 stops the silent no-op at 12:00:53; commit 2 stops 12:10:58/12:15:58 from being able to erase server state at all, and turns that same tick into the recovery path for whatever the agent missed while it was down.

Deployment note. Merging does not restore the lost data. ~/.openclaw/openclaw.json still has an empty access.groups, so after deploying, the owner has to add the allowlist entry once more on the settings page (it will persist this time), or it can be written into the file by hand followed by a restart. On the first reconcile after that, whichever side has the newer state wins per the table above.

Test

  • Tested locally — npm test: 42 tests, 42 pass, 0 fail (15 pre-existing + 27 new).
  • The commit-1 guard test was verified to be a real observer: reverting structuredClone inside buildAccessSnapshot turns it red on the load-bearing assertion (openclaw's snapshot came back carrying groups: { 'g-new': … } where {} was expected), not merely on a reference-identity check. The 15 tests that existed before stayed green with and without the fix, which is why the bug had no observer.
  • New reconcile coverage: seed issues exactly one PUT with an exact body; updated_at absent but group rows present issues none; a newer server policy adopts and persists with zero PUTs; a failed GET issues zero PUTs and holds the marker back; a non-object response issues zero PUTs; steady state issues zero PUTs and zero writes twice in a row; a 404 naming a group drops it and retries once (asserted on both payloads); a 404 naming no reported group is a logged failure rather than a silent skip; a second naming rejection gives up instead of looping; mode: 'silent' never reaches the payload; allowFrom: [] becomes ['*']; orphan server rows are ignored under scope=allowlist but kept under open.
  • tsc --strict --noEmit is clean on helpers.ts and test/helpers.test.ts (only index.ts's expected openclaw/plugin-sdk / untyped-SDK resolution errors remain, as the host is not a dependency).

Not covered, and deliberately so: index.ts cannot be imported under node --test (the OpenClaw host is not installed), so the reconcile's I/O sequencing is tested through injected callbacks in reconcileAgentPolicy and index.ts holds only the binding. Nothing here has been exercised against a live cws-comm.

Review checklist

  • No secrets or credentials in code
  • Changes match the PR title
  • Breaking changes documented (if any) — no API change; the behavior change is that the agent no longer overwrites server policy, and needs GET /agents/{memberId}/policy (present in cws-core and cws-comm today, registered alongside the PUT).

Follow-ups, not in this PR

  • persistOwner and persistAccess still read-modify-write the whole config file with no mutex, so an owner_changed event landing inside a reconcile write can still drop a field. Sequencing the periodic tick narrows the window but does not close it.
  • No reconcile runs at bridge start, so after a restart local state stays stale until the first 5-minute tick. Erasure is impossible either way now, but a boot-time round would heal faster.
  • decideInbound has no branch for mode === 'silent': it falls past the mention gate and returns handle: true, so a hand-configured silent group currently behaves like smart. SDK-side, untouched here.

athanbase and others added 2 commits August 27, 2026 21:14
buildOrgConfig stored `acct.access` — the live `channels.openmax.access`
object inside openclaw's runtime config snapshot — as orgConfig.access.
applyConfigEvent then mutated that object in place, which also moved the
diff base openclaw's writer uses (`applyMergePatch(sourceSnapshot,
createMergePatch(runtimeSnapshot, cfg))`): the patch came out empty and
the write persisted nothing.

The failure mode is invisible without a guard: an owner's allowlist edit
took effect live and messages were delivered, while the config file was
rewritten byte-identical apart from `meta.lastTouchedAt` — and the group
was gone after the next restart.

The plugin now owns its copy of the record from the moment it builds the
org config (buildAccessSnapshot), and persistAccess shapes the written
object through configWithAccess so neither side shares structure with the
writer's diff base. Live effect is unchanged: decideInbound re-reads
orgConfig.access on every call, so in-place mutation is still immediate.
persistOwner already built a fresh object and is untouched.

The new test pins both halves — the snapshot is not mutated, and the
object handed to writeConfigFile carries the new group. Reverting the
copy inside buildAccessSnapshot turns it red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The agent used to PUT its whole local policy to
/agents/{memberId}/reported-policy unconditionally — at the tail of every
agent.config.* event and again on the 5-minute tick — and never read
/agents/{memberId}/policy. Combined with cws-comm dropping config events
for an offline agent without any replay (agent_config_relay.go: an
offline connection "simply receives nothing"), local state falls behind
whenever the agent is down, and the next full PUT overwrites the owner's
real settings with the stale ones: a populated group_allowlist becomes
null and its per-group rows are deleted.

Reconcile now reads first and only writes when it knows what it would
overwrite:

  1. no self member_id yet          → return
  2. GET .../policy fails           → return, never PUT
  3. updated_at 0 and no groups     → no stored policy server-side (the
                                      page's "open" is a synthesized
                                      default, not an owner decision), so
                                      seed ours; nothing to erase
  4. updated_at newer than our mark → server wins: install it live and
                                      persist it, do not PUT
  5. server unchanged, local moved  → PUT
  6. equal                          → nothing, and no disk write

The event-driven trigger is debounced ~3s. The settings page saves one
change as several sequential writes (set-group-scope, then group-allowlist
add), so reporting at the tail of the first event raced the owner's own
save with a payload whose allowlist was still empty — the erasure would
have come back through the event path. The marker is per-bridge and in
memory, so a restart re-reads the server as authoritative.

Three reported-policy defects went with it:

  - mode=silent is now left out of both the rows and the allowlist. The
    server enum is smart|mention and one bad mode rejects the ENTIRE
    report, so a single silenced group used to cost every other group's
    settings. serverPolicyToAccess also preserves local silent entries,
    since the server can never be authoritative about a state it cannot
    store.
  - an empty allowFrom is normalized to ['*']. Locally undefined / [] /
    ['*'] all mean "anyone may trigger me" (decideInbound only restricts a
    non-empty list without '*'), while the server reads an empty
    allow_from as "nobody" — the opposite. `allowFrom || ['*']` did not
    catch this: [] is truthy.
  - a 404 is no longer assumed to mean "endpoint unavailable". cws-comm
    also 404s from verifyAgentGroupMember when the report names a group
    the agent has left, and the BFF passes the downstream message through
    as detail. A 4xx that names a reported conversation drops that group
    and retries once; anything else is logged with its status and left for
    the next cycle instead of being swallowed.

Sentinel defaults are checked against the SDK gate
(src/protocol/access-policy.js in @openmaxai/openmax-agent-sdk 1.0.3, the
version this bumps to): dmPolicy || 'owner', dmAllowFrom || [],
groupPolicy || 'allowlist', mode || 'mention', allowFrom's three-way
equivalence. group_scope is always sent explicitly because the server
defaults an absent one to 'open', which is not the local default.

The periodic tick now runs the owner sync and the reconcile in sequence:
persistOwner and persistAccess each read-modify-write the whole config
file, so overlapping rounds could drop one of the two fields.

26 new tests cover the decision table, the payload projection, and the
I/O sequencing — including that a failed read issues zero PUTs and that a
steady state writes nothing at all.

The lockfile's stale "version": "0.1.0" is corrected to 1.1.0 by the same
npm install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@athanbase

Copy link
Copy Markdown
Collaborator Author

CI note: the Gitleaks Secret Scan failure is an install-step flake, not a finding. The job died in Install Gitleaks before scanning anything:

curl: (22) The requested URL returned error: 404
gzip: stdin: unexpected end of file

The step derives its version from the unauthenticated api.github.com/repos/gitleaks/gitleaks/releases/latest call; when that request is rate-limited on a shared runner, GITLEAKS_VERSION comes back empty and the download URL degrades to .../download/v/gitleaks__linux_x64.tar.gz. The real asset (gitleaks_8.30.1_linux_x64.tar.gz) exists and the URL pattern in the workflow is correct, and this same job passed on every earlier PR — nothing in this diff touches it.

Ran the identical command locally against this branch as a stand-in: gitleaks detect --source . --verbose over all 36 commits, ~229 KB, no leaks found. Semgrep and the Quality Gate (42/42 tests) both passed in CI.

Please re-run the failed job — my token cannot. Pinning GITLEAKS_VERSION in sast.yml would remove the flake for good, but that is a CI change and out of scope for this PR.

@zylos0t zylos0t left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLEAN @ 2fe222b. Exact-head review passed: npm ci, npm test 42/42, diff-check, and SDK surface check. No unresolved findings. Residual: no live CWS/OpenClaw integration run; repository has no separate TypeScript build gate beyond its test path.

@athanbase
athanbase merged commit df5d6bf into main Aug 28, 2026
4 of 5 checks passed
@athanbase
athanbase deleted the fix/access-alias-and-policy-reconcile branch August 28, 2026 06:27
@athanbase athanbase mentioned this pull request Aug 28, 2026
athanbase added a commit that referenced this pull request Aug 28, 2026
Version bump for the access-config ownership fix and the pull-first policy
reconcile merged in #7. Follows the v1.1.0 release precedent (package.json +
README + docs/onboarding install refs).

Also corrects the README status line's SDK reference: #7 moved the dependency
to ^1.0.3, so the end-to-end verification claim now names the version it was
actually made against.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants