Skip to content

Commit 2705469

Browse files
sanil-23claude
andauthored
feat(sdk): self-healing Signal session reset + idle daemon watchdog + bypass mode (#257)
* feat(sdk): self-healing Signal session reset + daemon bypass mode Add resetSession across the SDK (EncryptionContext -> TinyPlaceClient -> Agent) to drop a peer's persisted Double Ratchet session so the next send re-runs X3DH from a fresh pre-key bundle. Agent.resetSession resolves the recipient the same way sendMessage does, so the correct cryptoId-keyed session is cleared for a @handle/base64/cryptoId address. Wrap the daemon's outbound send with reset+retry: on a session-class error (relay 400 / ratchet / decrypt / prekey / contact), reset the session and retry once so daemon replies self-heal after a crash/restart mid-handshake. Also pass --dangerously-skip-permissions to the claude provider so daemon workers run unattended in bypass mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(daemon): idle (no-event) task watchdog instead of a hard cap Convert the daemon's per-task timeout from a fixed wall-clock kill into an idle watchdog: the child CLI is reaped only after --task-timeout-ms with NO new provider event. `armIdle` re-arms on every parsed event, so a long-but- active run (streaming tool calls / output) keeps going while a genuinely wedged one is killed ~timeout after it goes silent. Default stays 600000ms. Also update the claude headless-argv test for the --dangerously-skip- permissions flag added with bypass mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(daemon): address review — opt-in bypass, narrow session classifier, propagate reset - Security (P1): `--dangerously-skip-permissions` is now opt-in behind a `--dangerously-skip-permissions` daemon flag (threaded through DaemonRuntime → RunTaskOptions → buildRunArgs), default OFF. A daemon auto-accepts contacts and runs remote task text, so bypassing approval must be an explicit operator choice, not the default. - Narrow isSessionError to explicit stale-ratchet/decrypt/prekey/session faults; stop treating a not-yet-contact 400 (or a bare HTTP status) as recoverable — resetting won't accept the contact and the failed retry would leave an undelivered X3DH session, making the next send undecryptable. - Do not swallow a failed resetSession: propagate it instead of retrying over an uncleared ratchet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a324b5 commit 2705469

7 files changed

Lines changed: 108 additions & 10 deletions

File tree

sdk/typescript/src/agent/agent.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import {
4242
publishKeys,
4343
readMessages,
44+
resolveRecipientKey,
4445
sendMessage,
4546
} from "./messaging.js";
4647
import type { PublishKeysResult, ReadMessage, SendMessageResult } from "./messaging.js";
@@ -289,6 +290,17 @@ export class Agent {
289290
return sendMessage(this.client, this.signer, recipient, text);
290291
}
291292

293+
/**
294+
* Clear the persisted Signal session for `recipient` (a @handle, cryptoId, or
295+
* base64 key — resolved the same way `sendMessage` resolves it) so the next send
296+
* re-runs X3DH from the peer's pre-key bundle. Use to recover from a poisoned/
297+
* desynced ratchet the relay rejects, then retry the send.
298+
*/
299+
async resetSession(recipient: string): Promise<void> {
300+
const to = await resolveRecipientKey(this.client, recipient);
301+
await this.client.resetSession(to);
302+
}
303+
292304
/** Read + decrypt + acknowledge the inbox. */
293305
readMessages(options?: { limit?: number }): Promise<Array<ReadMessage>> {
294306
return readMessages(this.client, this.signer, options);

sdk/typescript/src/cli/daemon.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import type { Flags } from "./types.js";
3131
import type { HarnessProvider } from "../types/harness.js";
3232

3333
const DEFAULT_CONCURRENCY = 2;
34+
// Idle (no-event) watchdog, not a hard cap: a task is killed only after this many
35+
// ms with NO new event from the provider CLI; each event re-arms it. A long-but-
36+
// active run survives, a wedged one is reaped ~10 min after it goes silent.
3437
const DEFAULT_TASK_TIMEOUT_MS = 600_000;
3538
const DEFAULT_POLL_MS = 2_000;
3639

@@ -132,6 +135,10 @@ export async function runDaemon(
132135
);
133136
}
134137
const model = stringFlag(flags, "model");
138+
// Opt-in: run claude workers with --dangerously-skip-permissions. OFF unless the
139+
// operator passes the flag, because the daemon auto-accepts contacts and executes
140+
// remote task text — unattended approval bypass must be an explicit choice.
141+
const skipPermissions = boolFlag(flags, "dangerously-skip-permissions");
135142
const opencodeAgent = stringFlag(flags, "opencode-agent");
136143
const handle = stringFlag(flags, "handle");
137144
const displayName = stringFlag(flags, "name");
@@ -172,8 +179,23 @@ export async function runDaemon(
172179
maxPending,
173180
...(statusThrottleMs !== undefined ? { statusThrottleMs } : {}),
174181
...(model ? { model } : {}),
182+
...(skipPermissions ? { skipPermissions: true } : {}),
175183
...(opencodeAgent ? { agent: opencodeAgent } : {}),
176-
send: (to, body) => lock(() => agent.sendMessage(to, body)).then(() => {}),
184+
send: (to, body) =>
185+
lock(async () => {
186+
try {
187+
await agent.sendMessage(to, body);
188+
} catch (error) {
189+
if (!isSessionError(error)) throw error;
190+
// Poisoned/desynced Signal ratchet — the relay rejects the ciphertext.
191+
// Drop the session so the retry re-runs X3DH from a fresh pre-key bundle.
192+
// Do NOT swallow a reset failure: retrying over an uncleared ratchet just
193+
// fails again and masks the cause, so let it propagate.
194+
log(`session error sending to ${to}, resetting: ${describe(error)}`);
195+
await agent.resetSession(to);
196+
await agent.sendMessage(to, body);
197+
}
198+
}),
177199
log,
178200
});
179201

@@ -264,3 +286,16 @@ async function accepterTick(
264286
function describe(error: unknown): string {
265287
return error instanceof Error ? error.message : String(error);
266288
}
289+
290+
/**
291+
* A send failure that dropping the Signal session can actually fix: an explicit
292+
* stale-ratchet / decrypt / prekey / session fault. Deliberately NOT matched:
293+
* a not-yet-contact rejection (resetting won't accept the contact, and the retry
294+
* would leave an undelivered X3DH session that makes the next send undecryptable)
295+
* or a bare HTTP status, which says nothing about the ratchet.
296+
*/
297+
function isSessionError(error: unknown): boolean {
298+
const e = error as { code?: string; message?: string };
299+
const text = `${e?.code ?? ""} ${e?.message ?? ""}`.toLowerCase();
300+
return /ratchet|decrypt|prekey|pre-key|signal session|no session/.test(text);
301+
}

sdk/typescript/src/cli/daemon/providers.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ export interface RunTaskOptions {
118118
agent?: string;
119119
/** Extra args appended before the prompt (advanced / permissions). */
120120
extraArgs?: ReadonlyArray<string>;
121+
/** Opt-in bypass of claude's permission prompts. OFF by default. */
122+
skipPermissions?: boolean;
121123
signal?: AbortSignal;
122124
/** Fired for each parsed semantic event — drives periodic status frames. */
123125
onEvent?: (event: HarnessSemanticEvent) => void;
@@ -141,6 +143,11 @@ export function buildRunArgs(options: {
141143
model?: string;
142144
agent?: string;
143145
extraArgs?: ReadonlyArray<string>;
146+
/** Opt-in: bypass the claude permission prompts (`--dangerously-skip-
147+
* permissions`). OFF by default — a daemon auto-accepts contacts and runs
148+
* remote DM task text, so skipping approval must be an explicit operator
149+
* choice, never the default. */
150+
skipPermissions?: boolean;
144151
}): Array<string> {
145152
const extra = options.extraArgs ?? [];
146153
// A prompt beginning with "-" would be parsed as a CLI flag by the provider
@@ -158,6 +165,7 @@ export function buildRunArgs(options: {
158165
"--output-format",
159166
"stream-json",
160167
"--verbose",
168+
...(options.skipPermissions ? ["--dangerously-skip-permissions"] : []),
161169
...extra,
162170
prompt,
163171
];
@@ -252,6 +260,7 @@ function runProviderAttempt(options: RunTaskOptions): Promise<RunTaskResult> {
252260
...(options.model ? { model: options.model } : {}),
253261
...(options.agent ? { agent: options.agent } : {}),
254262
...(options.extraArgs ? { extraArgs: options.extraArgs } : {}),
263+
...(options.skipPermissions ? { skipPermissions: true } : {}),
255264
});
256265
const bin = providerBin(options.provider, options.env);
257266
const child = spawn(bin, args, { cwd: options.cwd, env: options.env });
@@ -278,22 +287,33 @@ function runProviderAttempt(options: RunTaskOptions): Promise<RunTaskResult> {
278287
});
279288
}
280289

281-
const timer = setTimeout(() => {
282-
finishError(
283-
new Error(
284-
`${options.provider} task timed out after ${options.timeoutMs}ms`,
285-
),
286-
);
287-
child.kill("SIGKILL");
288-
}, options.timeoutMs);
290+
// Idle watchdog, not a hard cap: kill the child only after `timeoutMs` with NO
291+
// new event. `armIdle` re-arms on every parsed event (see consumeLine), so a
292+
// long-but-active task (streaming tool calls / output) keeps running while a
293+
// genuinely wedged one is reaped. Armed once at start to cover a child that
294+
// never emits anything.
295+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
296+
function armIdle(): void {
297+
if (settled) return;
298+
if (idleTimer) clearTimeout(idleTimer);
299+
idleTimer = setTimeout(() => {
300+
finishError(
301+
new Error(
302+
`${options.provider} task idle for ${options.timeoutMs}ms (no events)`,
303+
),
304+
);
305+
child.kill("SIGKILL");
306+
}, options.timeoutMs);
307+
}
308+
armIdle();
289309

290310
const onAbort = (): void => {
291311
child.kill("SIGTERM");
292312
};
293313
options.signal?.addEventListener("abort", onAbort, { once: true });
294314

295315
function cleanup(): void {
296-
clearTimeout(timer);
316+
if (idleTimer) clearTimeout(idleTimer);
297317
options.signal?.removeEventListener("abort", onAbort);
298318
}
299319

@@ -316,6 +336,7 @@ function runProviderAttempt(options: RunTaskOptions): Promise<RunTaskResult> {
316336
}
317337
for (const event of mapEventsFromLine(raw, line)) {
318338
events += 1;
339+
armIdle(); // progress — the task is alive; push the no-event deadline out.
319340
options.onEvent?.(event);
320341
if (event.event.kind === "agent_message") {
321342
messages.push(event.event.payload.text);
418 Bytes
Binary file not shown.

sdk/typescript/src/client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ export class TinyPlaceClient {
251251
await this.encryptionContext.publishKeyBundle(options?.preKeyCount);
252252
}
253253

254+
/**
255+
* Drop the persisted Signal session for `cryptoId` (a resolved base58 address)
256+
* so the next send bootstraps a fresh X3DH session. Recovery path for a poisoned
257+
* ratchet the relay rejects. No-op when encryption is not configured.
258+
*/
259+
async resetSession(cryptoId: string): Promise<void> {
260+
await this.encryptionContext?.resetSession(cryptoId);
261+
}
262+
254263
healthz(): Promise<unknown> {
255264
return this.http.get<unknown>("/healthz");
256265
}

sdk/typescript/src/messaging/encryption.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ export class EncryptionContext implements MessageCipher {
124124
return session.decrypt(envelope.from, senderX25519, envelope);
125125
}
126126

127+
/**
128+
* Drop the persisted Double Ratchet session for `address` (an already-resolved
129+
* base58 cryptoId). The next `encryptEnvelope` finds no session, re-fetches the
130+
* peer's pre-key bundle, and bootstraps a fresh X3DH session — the recovery path
131+
* for a poisoned/desynced ratchet that makes the relay reject sends. Pre-keys and
132+
* identity are untouched; only this peer's session is cleared.
133+
*/
134+
async resetSession(address: string): Promise<void> {
135+
await this.store.removeSession(address);
136+
}
137+
127138
/** Lazily build the session — the store's identity key load is async. */
128139
private async getSession(): Promise<SignalSession> {
129140
if (!this.session) {

sdk/typescript/tests/daemon.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,23 @@ describe("provider detection", () => {
120120
});
121121

122122
it("builds provider-specific headless argv", () => {
123+
// Default: claude runs with its normal permission prompts (no bypass).
123124
expect(buildRunArgs({ provider: "claude", prompt: "hi" })).toEqual([
124125
"-p",
125126
"--output-format",
126127
"stream-json",
127128
"--verbose",
128129
"hi",
129130
]);
131+
// Opt-in bypass only when explicitly requested.
132+
expect(buildRunArgs({ provider: "claude", prompt: "hi", skipPermissions: true })).toEqual([
133+
"-p",
134+
"--output-format",
135+
"stream-json",
136+
"--verbose",
137+
"--dangerously-skip-permissions",
138+
"hi",
139+
]);
130140
expect(buildRunArgs({ provider: "codex", prompt: "hi", model: "gpt-5" })).toEqual([
131141
"exec",
132142
"--json",

0 commit comments

Comments
 (0)