fix: own the access config record, and reconcile policy with cws-comm instead of pushing over it - #7
Conversation
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>
|
CI note: the Gitleaks Secret Scan failure is an install-step flake, not a finding. The job died in The step derives its version from the unauthenticated Ran the identical command locally against this branch as a stand-in: Please re-run the failed job — my token cannot. Pinning |
zylos0t
left a comment
There was a problem hiding this comment.
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.
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.
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 recordbuildOrgConfigstoredacct.access, the livechannels.openmax.accessobject inside openclaw's runtime config snapshot, asorgConfig.access.applyConfigEventthen 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), andpersistAccessshapes the written object throughconfigWithAccess. Live effect is unchanged —decideInboundre-readsorgConfig.accesson every call, so in-place mutation is still immediate.persistOwneralready built a fresh object and is untouched.Commit 2 —
feat: reconcile agent policy with cws-comm instead of pushing over itsyncConfigToCommPUT the whole local policy unconditionally, from two call sites (event tail and the 5-minute tick), and nothing in the repo ever readGET /agents/{memberId}/policy. Since cws-comm dropsagent.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:member_idyet → returnGET .../policyfails → return, never PUTupdated_atabsent/0 and the snapshot carries no groups/allowlist → no stored policy row server-side, so theopenthe settings page shows isGetFullPolicy's synthesized default rather than an owner decision → seed ours, nothing to eraseupdated_atdiffers from our marker → server wins: install it live and persist it, no PUTThe event-driven trigger is now debounced ~3s. The settings page saves one user-visible change as several sequential writes (
set-group-scope, thengroup-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 bothgroups[]andgroup_allowlist. The server enum issmart|mentionand one out-of-enum mode rejects the entire report, so a single silenced group used to cost every other group's settings.serverPolicyToAccessalso preserves local silent entries — the server can never be authoritative about a state it cannot store.allowFromis normalized to['*']. Locallyundefined/[]/['*']all mean "anyone may trigger me" (decideInboundonly restricts a non-empty list without'*'), while the server reads an emptyallow_fromas "nobody".allowFrom || ['*']did not catch this:[]is truthy in JS.verifyAgentGroupMemberwhen the report names a group the agent has left, andremappedConnectErrorpasses the downstreamconnectErr.Message()through asdetail, 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.jsat 1.0.3:dmPolicy || 'owner',dmAllowFrom || [],groupPolicy || 'allowlist',mode || 'mention', andallowFrom's three-state equivalence.group_scopeis always sent explicitly, because the server defaults an absent one toopenwhile the local default isallowlist.Also in commit 2: the SDK moves from
^1.0.0to^1.0.3, and the periodic tick runs the owner sync and the reconcile in sequence rather than in parallel (bothpersistOwnerandpersistAccessread-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 to1.1.0by the samenpm 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:
deliver … ok=true, the agent really answered in that groupconfig-auditreports identical byte counts before and after, the only difference beingmeta.lastTouchedAtaccess.groupsback from disk as{}group_allowlistis nownulland the per-group rows are deletedCommit 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.jsonstill has an emptyaccess.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
npm test: 42 tests, 42 pass, 0 fail (15 pre-existing + 27 new).structuredCloneinsidebuildAccessSnapshotturns it red on the load-bearing assertion (openclaw's snapshot came back carryinggroups: { '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.updated_atabsent 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 underscope=allowlistbut kept underopen.tsc --strict --noEmitis clean onhelpers.tsandtest/helpers.test.ts(onlyindex.ts's expectedopenclaw/plugin-sdk/ untyped-SDK resolution errors remain, as the host is not a dependency).Not covered, and deliberately so:
index.tscannot be imported undernode --test(the OpenClaw host is not installed), so the reconcile's I/O sequencing is tested through injected callbacks inreconcileAgentPolicyandindex.tsholds only the binding. Nothing here has been exercised against a live cws-comm.Review checklist
GET /agents/{memberId}/policy(present in cws-core and cws-comm today, registered alongside the PUT).Follow-ups, not in this PR
persistOwnerandpersistAccessstill read-modify-write the whole config file with no mutex, so anowner_changedevent landing inside a reconcile write can still drop a field. Sequencing the periodic tick narrows the window but does not close it.decideInboundhas no branch formode === 'silent': it falls past thementiongate and returnshandle: true, so a hand-configured silent group currently behaves likesmart. SDK-side, untouched here.