Skip to content

Commit 429d29e

Browse files
committed
fix(agents,gateway): three subagent announce delivery failures in loopback token-auth setups
Rebased onto current main. Three narrow fixes for subagent completion announces failing to deliver over loopback gateway-client + token auth: - Fix 1 (gateway/call.ts): add an explicit `requireDeviceIdentity` opt-in so subagent completion calls retain device identity for operator-scope checks, instead of inferring it from the presence of scopes (which also fired for ordinary direct-local shared-token calls). Narrows the guard per review. - Fix 2 (embedded-agent-runner): after the sessions-yield abort-settle soft timeout, keep waiting (bounded by a hard cap) for the session file lock to release so the next turn does not hit a stale lock — without blocking forever if the settle itself stalls. - Fix 3 (embedded-agent-runner): also strip the sessions_yield context marker during abort cleanup so a subsequent announce re-run does not re-yield and get rejected as "did not produce a visible reply". Fixes #77807. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 9826acd commit 429d29e

4 files changed

Lines changed: 82 additions & 1 deletion

File tree

src/agents/embedded-agent-runner/run/attempt.sessions-yield.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ const SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE = "openclaw.sessions_yield_interrupt"
1111
const SESSIONS_YIELD_CONTEXT_CUSTOM_TYPE = "openclaw.sessions_yield";
1212

1313
const SESSIONS_YIELD_ABORT_SETTLE_TIMEOUT_MS = resolveEmbeddedAbortSettleTimeoutMs();
14+
// Hard cap for the post-timeout settle wait. After the soft timeout we keep
15+
// waiting for the session file lock to release so the next turn does not hit a
16+
// stale lock, but must never block the next turn forever if the settle itself
17+
// stalls (e.g. a hung fs operation). Derived from the soft timeout so the
18+
// test-fast override applies to both.
19+
const SESSIONS_YIELD_ABORT_SETTLE_HARD_TIMEOUT_MS = SESSIONS_YIELD_ABORT_SETTLE_TIMEOUT_MS * 5;
1420

1521
// Persist a hidden context reminder so the next turn knows why the runner stopped.
1622
function buildSessionsYieldContextMessage(message: string): string {
@@ -47,6 +53,25 @@ export async function waitForSessionsYieldAbortSettle(params: {
4753
log.warn(
4854
`sessions_yield abort settle timed out: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${SESSIONS_YIELD_ABORT_SETTLE_TIMEOUT_MS}`,
4955
);
56+
// Continue waiting (bounded) for the settle to complete so the session file
57+
// lock is released before the next turn starts. Without this the lock stays
58+
// held and the next turn fails with "file lock stale", causing model
59+
// fallback and visible error messages in the delivery channel.
60+
let hardTimeout: NodeJS.Timeout | undefined;
61+
const settled = await Promise.race([
62+
params.settlePromise.then(() => true).catch(() => true),
63+
new Promise<boolean>((resolve) => {
64+
hardTimeout = setTimeout(() => resolve(false), SESSIONS_YIELD_ABORT_SETTLE_HARD_TIMEOUT_MS);
65+
}),
66+
]);
67+
if (hardTimeout) {
68+
clearTimeout(hardTimeout);
69+
}
70+
if (!settled) {
71+
log.warn(
72+
`sessions_yield abort settle hard-timeout: runId=${params.runId} sessionId=${params.sessionId} hardTimeoutMs=${SESSIONS_YIELD_ABORT_SETTLE_HARD_TIMEOUT_MS} — proceeding without confirmed lock release`,
73+
);
74+
}
5075
}
5176
}
5277

@@ -193,6 +218,19 @@ export function stripSessionsYieldArtifacts(activeSession: {
193218
strippedMessages.pop();
194219
continue;
195220
}
221+
// Also strip the sessions_yield context marker. When a new incoming message
222+
// aborts an active sessions_yield, this marker remains in the session. If a
223+
// subagent completion announce then re-runs the agent to deliver the result,
224+
// the agent sees the context message, responds via sessions_yield again, and
225+
// the announce system rejects it as "did not produce a visible reply".
226+
if (
227+
last?.role === "custom" &&
228+
"customType" in last &&
229+
last.customType === SESSIONS_YIELD_CONTEXT_CUSTOM_TYPE
230+
) {
231+
strippedMessages.pop();
232+
continue;
233+
}
196234
break;
197235
}
198236
if (strippedMessages.length !== activeSession.messages.length) {
@@ -238,7 +276,9 @@ export function stripSessionsYieldArtifacts(activeSession: {
238276
const isYieldInterruptMessage =
239277
entry.type === "custom_message" &&
240278
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE;
241-
return isYieldAbortAssistant || isYieldInterruptMessage;
279+
const isYieldContextMessage =
280+
entry.type === "custom_message" && entry.customType === SESSIONS_YIELD_CONTEXT_CUSTOM_TYPE;
281+
return isYieldAbortAssistant || isYieldInterruptMessage || isYieldContextMessage;
242282
},
243283
{
244284
preserveTrailing: (entry) =>

src/agents/subagent-spawn.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ async function callSubagentGateway(
227227
params.scopes ?? (leastPrivilegeScopes.includes(ADMIN_SCOPE) ? [ADMIN_SCOPE] : undefined);
228228
const request = {
229229
...params,
230+
// Subagent calls negotiate explicit operator scopes against the paired
231+
// device token, so the gateway needs the device identity attached even on
232+
// loopback backend connections. Without this the completion announce fails
233+
// with "missing scope: operator.write" (#77807).
234+
requireDeviceIdentity: true,
230235
...(scopes != null ? { scopes } : {}),
231236
};
232237
if (

src/gateway/call.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,22 @@ describe("callGateway url resolution", () => {
10231023
expect(lastClientOptions?.deviceIdentity).toBeNull();
10241024
});
10251025

1026+
it("keeps device identity for loopback backend scoped calls that opt in via requireDeviceIdentity", async () => {
1027+
setLocalLoopbackGatewayConfig();
1028+
1029+
await callGateway({
1030+
method: "agent",
1031+
scopes: ["operator.write"],
1032+
token: "explicit-token",
1033+
requireDeviceIdentity: true,
1034+
});
1035+
1036+
expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT);
1037+
expect(lastClientOptions?.mode).toBe(GATEWAY_CLIENT_MODES.BACKEND);
1038+
expect(lastClientOptions?.scopes).toEqual(["operator.write"]);
1039+
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
1040+
});
1041+
10261042
it("labels default backend calls with the requested method", async () => {
10271043
setLocalLoopbackGatewayConfig();
10281044

src/gateway/call.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ type CallGatewayBaseOptions = {
9191
requiredStoredDeviceAuthScopes?: OperatorScope[];
9292
requireLocalBackendSharedAuth?: boolean;
9393
deviceIdentity?: DeviceIdentity | null;
94+
/**
95+
* Forces device identity to be attached even for BACKEND + GATEWAY_CLIENT
96+
* loopback shared-token calls (which normally omit it). Set by internal
97+
* callers that negotiate explicit operator scopes against the paired device
98+
* token — e.g. subagent lifecycle/announce calls — so the gateway can verify
99+
* those scopes. Without it, such calls fail with "missing scope: ...".
100+
* See #77807. Does not affect direct-local shared-token calls that don't
101+
* opt in.
102+
*/
103+
requireDeviceIdentity?: boolean;
94104
instanceId?: string;
95105
minProtocol?: number;
96106
maxProtocol?: number;
@@ -493,6 +503,16 @@ function shouldOmitDeviceIdentityForGatewayCall(params: {
493503
}): boolean {
494504
const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;
495505
const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI;
506+
// Callers that must verify explicit operator scopes against the paired
507+
// device token (e.g. subagent completion announce) opt in via
508+
// `requireDeviceIdentity`. Without device identity, those loopback backend
509+
// calls fail with "missing scope: operator.write" even when the device has
510+
// full scopes. Fixes #77807. This is intentionally narrow: it does not fire
511+
// for ordinary scoped backend calls (which keep the direct-local
512+
// shared-token contract of omitting device identity).
513+
if (params.opts.requireDeviceIdentity === true) {
514+
return false;
515+
}
496516
// Inactive ambient credentials must not turn an auth-none CLI call device-less.
497517
// Omit identity only when the Gateway will actually authenticate the supplied secret.
498518
const hasSharedSecretAuth =

0 commit comments

Comments
 (0)