openmausbot 0.1.73 → 0.1.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-BYbK7ber.js → index-BTlyRwLl.js} +1 -1
- package/dist/assets/index-CeMbvsgx.js +305 -0
- package/dist/assets/index-DH7Zax6L.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +9 -0
- package/dist-server/index.js +1402 -1048
- package/dist-server/mcp-gate.js +58 -16
- package/dist-server/openmausbot.js +543 -271
- package/dist-server/pair-cli.js +543 -271
- package/dist-server/permission-proxy.js +119 -14
- package/dist-server/server/auto-approve.js +44 -205
- package/dist-server/server/channel-queue.js +20 -2
- package/dist-server/server/config.js +36 -1
- package/dist-server/server/drivers/acp/core.js +58 -4
- package/dist-server/server/drivers/acp/grok.js +15 -2
- package/dist-server/server/drivers/antigravity-acp.js +5 -4
- package/dist-server/server/drivers/antigravity.js +9 -4
- package/dist-server/server/drivers/claude.js +113 -13
- package/dist-server/server/drivers/codex.js +37 -15
- package/dist-server/server/drivers/retry.js +4 -0
- package/dist-server/server/engine-install.js +9 -5
- package/dist-server/server/index.js +265 -233
- package/dist-server/server/message-db.js +70 -3
- package/dist-server/server/permission-proxy.js +90 -15
- package/dist-server/server/procs.js +82 -23
- package/dist-server/server/routines.js +67 -8
- package/dist-server/server/setup-mode.js +6 -7
- package/dist-server/server/steer-queue.js +27 -7
- package/dist-server/server/store.js +19 -0
- package/dist-server/server/team-manifest.js +1 -1
- package/dist-server/server/thread-events.js +10 -1
- package/dist-server/server/webhooks.js +4 -1
- package/dist-server/server/workspace-backup.js +24 -9
- package/dist-server/server/workspace.js +13 -0
- package/dist-server/shared/approval-mode.js +11 -10
- package/dist-server/shared/ask-question.js +172 -0
- package/dist-server/shared/provider-safety.js +6 -0
- package/dist-server/vps-container-mcp.js +9 -0
- package/package.json +1 -1
- package/dist/assets/index-BLJ7byG3.js +0 -296
- package/dist/assets/index-De2TRLxu.css +0 -1
- package/dist-server/server/auto-review.js +0 -79
|
@@ -101,6 +101,28 @@ function decodeAcpConfig(defaultCli) {
|
|
|
101
101
|
* ACP JSON-RPC-over-stdio driver. Harness differences (argv, auth, catalog)
|
|
102
102
|
* live in `support`; this is the shared handshake and turn runtime.
|
|
103
103
|
*/
|
|
104
|
+
/** What "the same operation" means for a remembered session allow: the
|
|
105
|
+
* tool call's shape with keys sorted, so two identical requests key alike
|
|
106
|
+
* however the agent ordered its JSON. null when nothing identifies it. */
|
|
107
|
+
function sessionOperationKey(toolCall) {
|
|
108
|
+
const rawInput = toolCall?.rawInput;
|
|
109
|
+
const command = typeof rawInput?.command === "string" ? rawInput.command : undefined;
|
|
110
|
+
const hasInput = rawInput && typeof rawInput === "object" && Object.keys(rawInput).length > 0;
|
|
111
|
+
if (!command && !hasInput)
|
|
112
|
+
return null;
|
|
113
|
+
const stable = (value) => Array.isArray(value)
|
|
114
|
+
? value.map(stable)
|
|
115
|
+
: value && typeof value === "object"
|
|
116
|
+
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]))
|
|
117
|
+
: value;
|
|
118
|
+
return JSON.stringify(stable({
|
|
119
|
+
kind: toolCall?.kind,
|
|
120
|
+
title: toolCall?.title,
|
|
121
|
+
command,
|
|
122
|
+
input: rawInput,
|
|
123
|
+
locations: toolCall?.locations,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
104
126
|
export function createAcpDriver(support) {
|
|
105
127
|
const DRIVER_KIND = support.driverKind;
|
|
106
128
|
const SOURCE = support.nativeSource;
|
|
@@ -155,6 +177,13 @@ export function createAcpDriver(support) {
|
|
|
155
177
|
await refreshModels();
|
|
156
178
|
const listeners = new Set();
|
|
157
179
|
const active = new Map();
|
|
180
|
+
// "Always allow this session", remembered by the driver when the agent
|
|
181
|
+
// offered no `allow_always` of its own: the exact operations (kind,
|
|
182
|
+
// title, command, input, locations) a person allowed for the session,
|
|
183
|
+
// per thread. A repeat is answered the way they did, once, for as long
|
|
184
|
+
// as the native session lasts. A generic title with no input identifies
|
|
185
|
+
// nothing and is never remembered.
|
|
186
|
+
const sessionAllows = new Map();
|
|
158
187
|
const emit = (event) => {
|
|
159
188
|
for (const listener of listeners)
|
|
160
189
|
listener(event);
|
|
@@ -455,6 +484,7 @@ export function createAcpDriver(support) {
|
|
|
455
484
|
const optionFor = (want) => options.find((o) => o.kind === `${want}_once` && typeof o.optionId === "string")?.optionId
|
|
456
485
|
?? options.find((o) => String(o.kind ?? "").startsWith(want) && typeof o.optionId === "string")?.optionId
|
|
457
486
|
?? null;
|
|
487
|
+
const optionAlways = options.find((o) => o.kind === "allow_always" && typeof o.optionId === "string")?.optionId ?? null;
|
|
458
488
|
const cancelled = { outcome: { outcome: "cancelled" } };
|
|
459
489
|
const missing = (want) => emit({
|
|
460
490
|
...base(threadId, turnId),
|
|
@@ -474,14 +504,23 @@ export function createAcpDriver(support) {
|
|
|
474
504
|
});
|
|
475
505
|
}
|
|
476
506
|
const kind = String(toolCall.kind ?? "");
|
|
507
|
+
// an earlier "Always allow this session" on this exact operation
|
|
508
|
+
const operationKey = isQuestion || controlsHost ? null : sessionOperationKey(toolCall);
|
|
509
|
+
if (operationKey && sessionAllows.get(threadId)?.has(operationKey)) {
|
|
510
|
+
const allow = optionFor("allow");
|
|
511
|
+
if (allow) {
|
|
512
|
+
return send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "selected", optionId: allow } } });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
477
515
|
const tool = kind === "execute" ? "shell" : kind === "edit" ? "edit" : kind || "tool";
|
|
478
516
|
const summary = String(toolCall.rawInput?.command ?? toolCall.title ?? tool).slice(0, 200);
|
|
479
517
|
const requestId = newId();
|
|
480
|
-
const finish = (behavior, source = "user", message) => {
|
|
518
|
+
const finish = (behavior, source = "user", message, always) => {
|
|
481
519
|
if (!asks.delete(requestId))
|
|
482
520
|
return;
|
|
483
521
|
clearTimeout(timer);
|
|
484
522
|
const want = behavior === "allow" ? "allow" : "reject";
|
|
523
|
+
const forSession = want === "allow" && always === true && !isQuestion && !controlsHost;
|
|
485
524
|
const named = isQuestion && behavior === "answer"
|
|
486
525
|
? options.filter((option) => option.optionId === message || option.name?.trim() === message)
|
|
487
526
|
: [];
|
|
@@ -489,7 +528,16 @@ export function createAcpDriver(support) {
|
|
|
489
528
|
? null
|
|
490
529
|
: isQuestion
|
|
491
530
|
? named.length === 1 && typeof named[0].optionId === "string" ? named[0].optionId : null
|
|
492
|
-
:
|
|
531
|
+
: forSession
|
|
532
|
+
? optionAlways ?? optionFor("allow")
|
|
533
|
+
: optionFor(want);
|
|
534
|
+
// the agent keeps its own allow_always; when it offered none, the
|
|
535
|
+
// driver keeps the operation for the session instead
|
|
536
|
+
if (forSession && !optionAlways && optionId && operationKey) {
|
|
537
|
+
const remembered = sessionAllows.get(threadId) ?? new Set();
|
|
538
|
+
remembered.add(operationKey);
|
|
539
|
+
sessionAllows.set(threadId, remembered);
|
|
540
|
+
}
|
|
493
541
|
if (behavior !== "cancel" && !optionId)
|
|
494
542
|
missing(isQuestion ? "matching answer" : want);
|
|
495
543
|
send({
|
|
@@ -523,6 +571,8 @@ export function createAcpDriver(support) {
|
|
|
523
571
|
? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
|
|
524
572
|
: undefined,
|
|
525
573
|
approvalScope: controlsHost ? "local-computer" : undefined,
|
|
574
|
+
// the driver can honor a session-wide allow either way
|
|
575
|
+
allowSession: !isQuestion && !controlsHost ? true : undefined,
|
|
526
576
|
});
|
|
527
577
|
};
|
|
528
578
|
const handleNotification = (msg) => {
|
|
@@ -695,11 +745,15 @@ export function createAcpDriver(support) {
|
|
|
695
745
|
}
|
|
696
746
|
}
|
|
697
747
|
const images = turn.images ?? [];
|
|
698
|
-
const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true
|
|
748
|
+
const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true ||
|
|
749
|
+
support.acceptsUnadvertisedImages?.(init) === true;
|
|
699
750
|
if (images.length && support.images === true && !runtimeAcceptsImages) {
|
|
700
751
|
throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
|
|
701
752
|
}
|
|
702
753
|
const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
|
|
754
|
+
// a fresh native session forgets what the previous one allowed
|
|
755
|
+
if (!cursor)
|
|
756
|
+
sessionAllows.delete(threadId);
|
|
703
757
|
let sessionResult = null;
|
|
704
758
|
if (cursor) {
|
|
705
759
|
try {
|
|
@@ -878,7 +932,7 @@ export function createAcpDriver(support) {
|
|
|
878
932
|
const finish = turn?.asks.get(requestId);
|
|
879
933
|
if (!finish)
|
|
880
934
|
return "unavailable"; // settled, timed out, or turn gone
|
|
881
|
-
finish(decision.behavior, "user", decision.message);
|
|
935
|
+
finish(decision.behavior, "user", decision.message, decision.always === true);
|
|
882
936
|
return decision.behavior === "allow"
|
|
883
937
|
? "allowed-once"
|
|
884
938
|
: decision.behavior === "answer"
|
|
@@ -175,10 +175,21 @@ export function ensureGrokInjectSlug(modelId, env = process.env) {
|
|
|
175
175
|
writeFileSync(path, next);
|
|
176
176
|
return slug;
|
|
177
177
|
}
|
|
178
|
+
/** Grok 1.0.25 consumes native image blocks but advertises image:false.
|
|
179
|
+
* Verified with the real CLI and a loopback model: scripts/verify-grok-images.ts.
|
|
180
|
+
* Keep unknown/older runtimes on the normal capability negotiation path. */
|
|
181
|
+
export function grokAcceptsUnadvertisedImages(init) {
|
|
182
|
+
const meta = init?._meta;
|
|
183
|
+
if (meta?.grokShell !== true || typeof meta.agentVersion !== "string")
|
|
184
|
+
return false;
|
|
185
|
+
const version = /^1\.0\.(\d+)$/.exec(meta.agentVersion);
|
|
186
|
+
return Boolean(version && Number(version[1]) >= 25);
|
|
187
|
+
}
|
|
178
188
|
const support = {
|
|
179
189
|
driverKind: "grokAgent",
|
|
180
190
|
displayName: "Grok",
|
|
181
|
-
images:
|
|
191
|
+
images: true,
|
|
192
|
+
acceptsUnadvertisedImages: grokAcceptsUnadvertisedImages,
|
|
182
193
|
models: STATIC_GROK_MODELS,
|
|
183
194
|
resolveModels: (env) => mergeLocalInject(readGrokModelCatalog(env), env),
|
|
184
195
|
// Grok's accepted levels vary by model and the CLI validates lazily — a
|
|
@@ -214,7 +225,9 @@ const support = {
|
|
|
214
225
|
// crates/codegen/xai-grok-pager-bin/src/main.rs:1259-1273.
|
|
215
226
|
spawnArgs: (config, turn) => [
|
|
216
227
|
"--permission-mode",
|
|
217
|
-
config.fullAuto
|
|
228
|
+
config.fullAuto
|
|
229
|
+
? "bypassPermissions"
|
|
230
|
+
: turn.approvalMode === "auto" ? "auto" : turn.approvalMode === "edits" ? "acceptEdits" : "default",
|
|
218
231
|
"agent",
|
|
219
232
|
...(turn.model ? ["-m", turn.model] : []),
|
|
220
233
|
// long form on purpose: `--effort` is documented as an alias, and an
|
|
@@ -148,6 +148,7 @@ export class AntigravityAcpClient {
|
|
|
148
148
|
startupDiagnosticBytes = 0;
|
|
149
149
|
nativeStartupHint;
|
|
150
150
|
closed = false;
|
|
151
|
+
stopping;
|
|
151
152
|
onAuthorizationUrl;
|
|
152
153
|
/** Settles once the runtime process is gone. On Windows a running
|
|
153
154
|
* executable pins its file and its directory, so an installer must not
|
|
@@ -291,10 +292,10 @@ export class AntigravityAcpClient {
|
|
|
291
292
|
return;
|
|
292
293
|
this.closed = true;
|
|
293
294
|
this.failAll(new Error("Antigravity ACP was closed."));
|
|
294
|
-
killCliTree(this.child);
|
|
295
|
+
this.stopping = killCliTree(this.child);
|
|
295
296
|
}
|
|
296
|
-
/**
|
|
297
|
-
async closeAndWait(timeoutMs =
|
|
297
|
+
/** Allow the shared 5s TERM grace and 1s force-stop verification to finish. */
|
|
298
|
+
async closeAndWait(timeoutMs = 7_000) {
|
|
298
299
|
this.close();
|
|
299
300
|
let timer;
|
|
300
301
|
const timedOut = new Promise((resolve) => {
|
|
@@ -302,7 +303,7 @@ export class AntigravityAcpClient {
|
|
|
302
303
|
timer.unref?.();
|
|
303
304
|
});
|
|
304
305
|
try {
|
|
305
|
-
return await Promise.race([this.
|
|
306
|
+
return await Promise.race([this.stopping, timedOut]);
|
|
306
307
|
}
|
|
307
308
|
finally {
|
|
308
309
|
clearTimeout(timer);
|
|
@@ -11,8 +11,13 @@ export const STATIC_ANTIGRAVITY_MODELS = {
|
|
|
11
11
|
{ id: "gemini-3.8-flash-low", label: "Gemini 3.8 Flash (Low)" },
|
|
12
12
|
],
|
|
13
13
|
};
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/** Antigravity's own session modes: `yolo` for Full access, `auto_edit` for
|
|
15
|
+
* auto-accept edits, `default` otherwise (Ask, and Auto, which it has no
|
|
16
|
+
* reviewer for). */
|
|
17
|
+
export function antigravityPermissionMode(fullAuto, approvalMode) {
|
|
18
|
+
if (fullAuto)
|
|
19
|
+
return "yolo";
|
|
20
|
+
return approvalMode === "edits" ? "auto_edit" : "default";
|
|
16
21
|
}
|
|
17
22
|
export function antigravityModelsFromSession(value) {
|
|
18
23
|
return catalogFromAntigravityConfigOptions(value, STATIC_ANTIGRAVITY_MODELS.default);
|
|
@@ -88,8 +93,8 @@ const support = {
|
|
|
88
93
|
return { state: "unavailable", reason: error instanceof Error ? error.message : String(error) };
|
|
89
94
|
}
|
|
90
95
|
},
|
|
91
|
-
configureSession: async ({ request, sessionId, config }) => {
|
|
92
|
-
const wanted = antigravityPermissionMode(config.fullAuto);
|
|
96
|
+
configureSession: async ({ request, sessionId, config, turn }) => {
|
|
97
|
+
const wanted = antigravityPermissionMode(config.fullAuto, turn.approvalMode);
|
|
93
98
|
const result = await request("session/set_config_option", {
|
|
94
99
|
sessionId,
|
|
95
100
|
configId: "mode",
|
|
@@ -26,6 +26,7 @@ import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS }
|
|
|
26
26
|
import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
|
|
27
27
|
import { appendNative } from "./native.js";
|
|
28
28
|
import { SPAWNED_PROXIES } from "../proxy-paths.js";
|
|
29
|
+
import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, questionChoices, } from "../../shared/ask-question.js";
|
|
29
30
|
/** Whether `claude` has been signed in.
|
|
30
31
|
*
|
|
31
32
|
* Credential storage is deliberately not inspected here. Claude Code uses the
|
|
@@ -150,6 +151,30 @@ function claudeEnvironment(model, source = process.env, configDir, instanceEnvir
|
|
|
150
151
|
function inheritsUserConfig(env) {
|
|
151
152
|
return env.OMB_CLAUDE_INHERIT_USER_CONFIG === "1";
|
|
152
153
|
}
|
|
154
|
+
/** Retain the selected CLI account's authentication without importing its
|
|
155
|
+
* hooks, permissions, MCP servers or personal instructions. Explicit OMB
|
|
156
|
+
* connections/local endpoints own their entire routing + credential pair. */
|
|
157
|
+
export function readClaudeAuthSettings(env, instanceEnvironment = {}) {
|
|
158
|
+
if (CLAUDE_ACCOUNT_ENV_KEYS.some((key) => instanceEnvironment[key]))
|
|
159
|
+
return {};
|
|
160
|
+
try {
|
|
161
|
+
const settings = JSON.parse(readFileSync(join(resolveClaudeConfigDir(undefined, env), "settings.json"), "utf8"));
|
|
162
|
+
const authEnv = {};
|
|
163
|
+
for (const key of CLAUDE_ACCOUNT_ENV_KEYS) {
|
|
164
|
+
if (!key.endsWith("_FILE_DESCRIPTOR") && typeof settings?.env?.[key] === "string") {
|
|
165
|
+
authEnv[key] = settings.env[key];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
...(Object.keys(authEnv).length ? { env: authEnv } : {}),
|
|
170
|
+
...(typeof settings?.apiKeyHelper === "string" && settings.apiKeyHelper.trim()
|
|
171
|
+
? { apiKeyHelper: settings.apiKeyHelper } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
153
178
|
/** MCP servers the bot's own project declares in `<cwd>/.mcp.json`.
|
|
154
179
|
*
|
|
155
180
|
* The CLI would find this file itself, but the harness launches it with
|
|
@@ -370,8 +395,17 @@ function systemEndedReply(kind) {
|
|
|
370
395
|
? { behavior: "answer", message: "OpenMausBot: the turn is ending — wrap up." }
|
|
371
396
|
: { behavior: "deny", message: "OpenMausBot: the turn ended" };
|
|
372
397
|
}
|
|
398
|
+
/** The structured questions behind an ask, when it is one. Claude's own
|
|
399
|
+
* AskUserQuestion carries them; everything else answers null and keeps the
|
|
400
|
+
* plain summary/choices card. */
|
|
401
|
+
function askQuestions(ask) {
|
|
402
|
+
return ask.tool === ASK_USER_QUESTION_TOOL ? parseAskQuestions(ask.input) : null;
|
|
403
|
+
}
|
|
373
404
|
/** One human-readable line for an ask — what the card subtitle shows. */
|
|
374
405
|
function askSummary(ask) {
|
|
406
|
+
const questions = askQuestions(ask);
|
|
407
|
+
if (questions)
|
|
408
|
+
return askQuestionSummary(questions).slice(0, 300);
|
|
375
409
|
return askInputSummary(ask.input) ?? ask.tool ?? "tool";
|
|
376
410
|
}
|
|
377
411
|
export function permissionSocketPath(threadId) {
|
|
@@ -482,12 +516,18 @@ export async function createPermissionBroker(opts) {
|
|
|
482
516
|
continue;
|
|
483
517
|
}
|
|
484
518
|
const ask = { id: askId, kind, tool: msg.tool ?? "tool", input: msg.input ?? {}, at: Date.now() };
|
|
485
|
-
const finish = (behavior, message, source) => {
|
|
519
|
+
const finish = (behavior, message, source, always) => {
|
|
486
520
|
if (!pending.delete(askId))
|
|
487
521
|
return;
|
|
488
522
|
clearTimeout(timer);
|
|
489
523
|
try {
|
|
490
|
-
|
|
524
|
+
// `always` rides to the proxy, which hands the CLI's own suggested
|
|
525
|
+
// permission rules back as updatedPermissions: Claude remembers
|
|
526
|
+
// the allow for the session, the harness remembers nothing.
|
|
527
|
+
// `source` travels with the answer too: a proxy that cannot tell
|
|
528
|
+
// the human's words from a timeout note would file the timeout
|
|
529
|
+
// note as the human's words.
|
|
530
|
+
conn.write(JSON.stringify({ t: "answer", id: askId, behavior, message, source, ...(always ? { always: true } : {}) }) + "\n");
|
|
491
531
|
}
|
|
492
532
|
catch { }
|
|
493
533
|
opts.onResolve({ ...ask, behavior, source });
|
|
@@ -570,13 +610,13 @@ export async function createPermissionBroker(opts) {
|
|
|
570
610
|
}
|
|
571
611
|
};
|
|
572
612
|
return {
|
|
573
|
-
answer(askId, behavior, message) {
|
|
613
|
+
answer(askId, behavior, message, always) {
|
|
574
614
|
const p = pending.get(askId);
|
|
575
615
|
if (!p)
|
|
576
616
|
return false;
|
|
577
617
|
if (p.ask.kind === "question" ? behavior !== "answer" : behavior === "answer")
|
|
578
618
|
return false;
|
|
579
|
-
p.finish(behavior, message, "user");
|
|
619
|
+
p.finish(behavior, message, "user", always && behavior === "allow");
|
|
580
620
|
return true;
|
|
581
621
|
},
|
|
582
622
|
pause() {
|
|
@@ -752,6 +792,12 @@ export const ClaudeDriver = {
|
|
|
752
792
|
? configuredIdleMinimum
|
|
753
793
|
: 10_000;
|
|
754
794
|
const SESSION_IDLE_MS = Math.max(sessionIdleMinimum, Number(process.env.OMB_CLAUDE_SESSION_IDLE_MS) || 10 * 60_000);
|
|
795
|
+
const stopSession = (session) => {
|
|
796
|
+
void killCliTree(session.child).then((stopped) => {
|
|
797
|
+
if (stopped)
|
|
798
|
+
void session.finishClose?.();
|
|
799
|
+
});
|
|
800
|
+
};
|
|
755
801
|
const closeSession = (threadId, why) => {
|
|
756
802
|
const s = sessions.get(threadId);
|
|
757
803
|
if (!s || s.closing)
|
|
@@ -772,8 +818,7 @@ export const ClaudeDriver = {
|
|
|
772
818
|
}
|
|
773
819
|
catch { }
|
|
774
820
|
const kill = setTimeout(() => {
|
|
775
|
-
|
|
776
|
-
killCliTree(s.child);
|
|
821
|
+
stopSession(s);
|
|
777
822
|
}, 5_000);
|
|
778
823
|
kill.unref?.();
|
|
779
824
|
};
|
|
@@ -833,7 +878,8 @@ export const ClaudeDriver = {
|
|
|
833
878
|
const permissionMode = turn.approvalMode === undefined
|
|
834
879
|
? config.permissionMode
|
|
835
880
|
: turn.approvalMode === "full" ? "bypassPermissions"
|
|
836
|
-
: turn.approvalMode === "auto" ? "auto"
|
|
881
|
+
: turn.approvalMode === "auto" ? "auto"
|
|
882
|
+
: turn.approvalMode === "edits" ? "acceptEdits" : "default";
|
|
837
883
|
const controlsHost = turn.integrations?.localComputer?.scope === "local-computer";
|
|
838
884
|
if (controlsHost && permissionMode === "bypassPermissions" && turn.approvalMode !== "full") {
|
|
839
885
|
throw new Error("local computer control requires the interactive approval broker");
|
|
@@ -1019,6 +1065,12 @@ export const ClaudeDriver = {
|
|
|
1019
1065
|
args.push("--allowedTools", allowed.join(","));
|
|
1020
1066
|
}
|
|
1021
1067
|
const env = environment(turnModel);
|
|
1068
|
+
const authSettings = isolated && !injected.injected
|
|
1069
|
+
? readClaudeAuthSettings(env, input.environment) : {};
|
|
1070
|
+
const authSettingsPath = mcpConfigPath && Object.keys(authSettings).length
|
|
1071
|
+
? join(dirname(mcpConfigPath), "auth-settings.json") : null;
|
|
1072
|
+
if (authSettingsPath)
|
|
1073
|
+
args.push("--settings", authSettingsPath);
|
|
1022
1074
|
// Our approvals and browser credentials expire at the user-turn
|
|
1023
1075
|
// boundary. Native background workers cannot outlive that boundary;
|
|
1024
1076
|
// parallel bot work must use the harness's durable delegate_bot path.
|
|
@@ -1026,7 +1078,7 @@ export const ClaudeDriver = {
|
|
|
1026
1078
|
const cwd = turn.cwd ?? homedir();
|
|
1027
1079
|
// Everything that shapes the process, minus session/turn-specific temp
|
|
1028
1080
|
// paths. Their contents are represented directly in the key instead.
|
|
1029
|
-
const privateFileFlags = new Set(["--mcp-config"]);
|
|
1081
|
+
const privateFileFlags = new Set(["--mcp-config", "--settings"]);
|
|
1030
1082
|
const keyArgs = args.filter((a, i) => !privateFileFlags.has(a) && !privateFileFlags.has(args[i - 1] ?? ""));
|
|
1031
1083
|
const argsKey = JSON.stringify({
|
|
1032
1084
|
args: keyArgs,
|
|
@@ -1038,6 +1090,11 @@ export const ClaudeDriver = {
|
|
|
1038
1090
|
model: injected.model ?? null,
|
|
1039
1091
|
base: env.ANTHROPIC_BASE_URL ?? null,
|
|
1040
1092
|
configDir: env.CLAUDE_CONFIG_DIR ?? null,
|
|
1093
|
+
// Rotating an account's key/helper must not reuse the old process.
|
|
1094
|
+
auth: createHash("sha256").update(JSON.stringify({
|
|
1095
|
+
settings: authSettings,
|
|
1096
|
+
env: Object.fromEntries(CLAUDE_ACCOUNT_ENV_KEYS.map((key) => [key, env[key]])),
|
|
1097
|
+
})).digest("hex"),
|
|
1041
1098
|
});
|
|
1042
1099
|
// Reuse the live process when it is idle, unchanged, and is the session
|
|
1043
1100
|
// the harness wants resumed. Anything else: close it and spawn fresh
|
|
@@ -1049,7 +1106,7 @@ export const ClaudeDriver = {
|
|
|
1049
1106
|
live.turn = { turnId, settled: false, sawStreamDelta: false };
|
|
1050
1107
|
active.set(threadId, { stop: () => {
|
|
1051
1108
|
closeSession(threadId, "interrupted");
|
|
1052
|
-
|
|
1109
|
+
stopSession(live);
|
|
1053
1110
|
}, turnId, broker: live.broker });
|
|
1054
1111
|
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
1055
1112
|
const volatile = turn.systemVolatile ?? "";
|
|
@@ -1121,6 +1178,14 @@ export const ClaudeDriver = {
|
|
|
1121
1178
|
onAsk: (ask) => {
|
|
1122
1179
|
const eventTurnId = sessions.get(threadId)?.turn?.turnId ?? turnId;
|
|
1123
1180
|
askTools.set(ask.id, typeof ask.tool === "string" ? ask.tool : undefined);
|
|
1181
|
+
// Auto was requested: say whether the CLI's reviewer is actually
|
|
1182
|
+
// running, from init, so the harness can tell a classifier's
|
|
1183
|
+
// verdict from a Manual session asking about everything.
|
|
1184
|
+
const nativeMode = sessions.get(threadId)?.nativePermissionMode ?? null;
|
|
1185
|
+
const nativeReview = permissionMode === "auto" && nativeMode !== null
|
|
1186
|
+
? nativeMode === "auto" ? "active" : "inactive"
|
|
1187
|
+
: undefined;
|
|
1188
|
+
const questions = askQuestions(ask);
|
|
1124
1189
|
emit({
|
|
1125
1190
|
...base(threadId, eventTurnId),
|
|
1126
1191
|
type: "request.opened",
|
|
@@ -1128,10 +1193,19 @@ export const ClaudeDriver = {
|
|
|
1128
1193
|
requestType: ask.kind,
|
|
1129
1194
|
tool: ask.tool,
|
|
1130
1195
|
summary: askSummary(ask),
|
|
1196
|
+
nativeReview,
|
|
1197
|
+
// the proxy hands Claude its own suggested rules on `always`;
|
|
1198
|
+
// host control stays one action at a time
|
|
1199
|
+
allowSession: ask.kind === "permission" && !(controlsHost && typeof ask.tool === "string" && ask.tool.startsWith("mcp__computer")) ? true : undefined,
|
|
1131
1200
|
approvalScope: typeof ask.tool === "string" && controlsHost && ask.tool.startsWith("mcp__computer")
|
|
1132
1201
|
? "local-computer"
|
|
1133
1202
|
: undefined,
|
|
1134
|
-
|
|
1203
|
+
questions: questions ?? undefined,
|
|
1204
|
+
// A structured ask still offers flat labels, for the phone
|
|
1205
|
+
// companions and any client that predates the question card.
|
|
1206
|
+
choices: questions
|
|
1207
|
+
? questionChoices(questions)
|
|
1208
|
+
: Array.isArray(ask.input?.choices) ? ask.input.choices.slice(0, 5) : undefined,
|
|
1135
1209
|
});
|
|
1136
1210
|
},
|
|
1137
1211
|
onResolve: (resolved) => {
|
|
@@ -1160,6 +1234,9 @@ export const ClaudeDriver = {
|
|
|
1160
1234
|
if (mcpConfigPath) {
|
|
1161
1235
|
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
|
|
1162
1236
|
}
|
|
1237
|
+
if (authSettingsPath) {
|
|
1238
|
+
writeFileSync(authSettingsPath, JSON.stringify(authSettings), { mode: 0o600 });
|
|
1239
|
+
}
|
|
1163
1240
|
if (sessionId)
|
|
1164
1241
|
args.push("--resume", sessionId);
|
|
1165
1242
|
else
|
|
@@ -1190,6 +1267,7 @@ export const ClaudeDriver = {
|
|
|
1190
1267
|
volatile: turn.systemVolatile ?? "",
|
|
1191
1268
|
sessionId: sessionId ?? newSessionId,
|
|
1192
1269
|
sawInit: false,
|
|
1270
|
+
nativePermissionMode: null,
|
|
1193
1271
|
turn: { turnId, settled: false, sawStreamDelta: false },
|
|
1194
1272
|
idleTimer: null,
|
|
1195
1273
|
closing: false,
|
|
@@ -1231,6 +1309,8 @@ export const ClaudeDriver = {
|
|
|
1231
1309
|
};
|
|
1232
1310
|
const currentTurnId = () => session.turn?.turnId ?? turnId;
|
|
1233
1311
|
const handleLine = (line) => {
|
|
1312
|
+
if (session.closing)
|
|
1313
|
+
return;
|
|
1234
1314
|
let o;
|
|
1235
1315
|
try {
|
|
1236
1316
|
o = JSON.parse(line);
|
|
@@ -1243,6 +1323,7 @@ export const ClaudeDriver = {
|
|
|
1243
1323
|
case "system":
|
|
1244
1324
|
if (o.subtype === "init") {
|
|
1245
1325
|
session.sawInit = true;
|
|
1326
|
+
session.nativePermissionMode = typeof o.permissionMode === "string" ? o.permissionMode : null;
|
|
1246
1327
|
if (typeof o.session_id === "string")
|
|
1247
1328
|
session.sessionId = o.session_id;
|
|
1248
1329
|
emit({ ...base(threadId, currentTurnId()), type: "session.started", sessionId: o.session_id, model: o.model });
|
|
@@ -1371,7 +1452,22 @@ export const ClaudeDriver = {
|
|
|
1371
1452
|
emit({ ...base(threadId, currentTurnId()), type: "runtime.error", ...describeSpawnFailure(e, config.cli) });
|
|
1372
1453
|
settle(false, "spawn_error");
|
|
1373
1454
|
});
|
|
1374
|
-
|
|
1455
|
+
let closeFinalized = false;
|
|
1456
|
+
const finalizeClose = async (code) => {
|
|
1457
|
+
if (closeFinalized)
|
|
1458
|
+
return;
|
|
1459
|
+
// The root can close while its MCP helpers are still running. Join
|
|
1460
|
+
// an in-flight stop (or reap its remaining group) before releasing
|
|
1461
|
+
// the turn so a replacement cannot overlap the old helpers.
|
|
1462
|
+
if (!(await killCliTree(child, 0))) {
|
|
1463
|
+
session.broker?.close();
|
|
1464
|
+
session.broker = undefined;
|
|
1465
|
+
emit({ ...base(threadId, currentTurnId()), type: "runtime.error", message: "Claude could not be confirmed stopped; its helper processes may still be running." });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
if (closeFinalized)
|
|
1469
|
+
return;
|
|
1470
|
+
closeFinalized = true;
|
|
1375
1471
|
// a turn still running when the process died is a failed turn; a
|
|
1376
1472
|
// process that exited between turns (idle close, contract change)
|
|
1377
1473
|
// is just a session ending
|
|
@@ -1536,6 +1632,10 @@ export const ClaudeDriver = {
|
|
|
1536
1632
|
removePrivateTempDir(session.systemPromptPath);
|
|
1537
1633
|
if (sessions.get(threadId) === session)
|
|
1538
1634
|
sessions.delete(threadId);
|
|
1635
|
+
};
|
|
1636
|
+
child.on("close", (code) => {
|
|
1637
|
+
session.finishClose = () => finalizeClose(code);
|
|
1638
|
+
void session.finishClose();
|
|
1539
1639
|
});
|
|
1540
1640
|
const stop = () => {
|
|
1541
1641
|
// taskkill is asynchronous on Windows. Retire steering and approvals
|
|
@@ -1543,7 +1643,7 @@ export const ClaudeDriver = {
|
|
|
1543
1643
|
closeSession(threadId, "interrupted");
|
|
1544
1644
|
retry.cancelled = true;
|
|
1545
1645
|
retryAbort.abort();
|
|
1546
|
-
|
|
1646
|
+
stopSession(session);
|
|
1547
1647
|
};
|
|
1548
1648
|
active.set(threadId, { stop, turnId, broker });
|
|
1549
1649
|
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
@@ -1683,7 +1783,7 @@ export const ClaudeDriver = {
|
|
|
1683
1783
|
if (!broker)
|
|
1684
1784
|
return "unavailable";
|
|
1685
1785
|
const behavior = decision.behavior === "answer" ? "answer" : decision.behavior;
|
|
1686
|
-
if (!broker.answer(requestId, behavior, decision.message))
|
|
1786
|
+
if (!broker.answer(requestId, behavior, decision.message, decision.always))
|
|
1687
1787
|
return "unavailable";
|
|
1688
1788
|
return behavior === "allow" ? "allowed-once" : behavior === "answer" ? "answered" : "rejected";
|
|
1689
1789
|
},
|
|
@@ -181,6 +181,9 @@ function mcpAppApprovalForm(params) {
|
|
|
181
181
|
/** Codex persists these values on its native thread. Keep them explicit on
|
|
182
182
|
* start, resume, and every turn so switching modes cannot leave a more
|
|
183
183
|
* permissive sandbox/reviewer stuck to the next request. */
|
|
184
|
+
/** Ask and Edits both run Codex's workspace-write sandbox with the person as
|
|
185
|
+
* reviewer: Codex has no narrower "edits only" mode, so the selector never
|
|
186
|
+
* offers Edits for it (supportsApprovalMode) and a stray value asks. */
|
|
184
187
|
function namedApprovalParams(mode) {
|
|
185
188
|
if (mode === "full") {
|
|
186
189
|
return {
|
|
@@ -521,6 +524,7 @@ export const CodexDriver = {
|
|
|
521
524
|
const earlyNotifications = [];
|
|
522
525
|
const state = {
|
|
523
526
|
settled: false,
|
|
527
|
+
lastError: "",
|
|
524
528
|
lastText: "",
|
|
525
529
|
sawStreamDelta: false,
|
|
526
530
|
// codex reports token usage as a running THREAD total; the harness
|
|
@@ -582,10 +586,18 @@ export const CodexDriver = {
|
|
|
582
586
|
send({ jsonrpc: "2.0", id, method, params });
|
|
583
587
|
});
|
|
584
588
|
let stopping;
|
|
585
|
-
const terminate = () => stopping ??= killCliTree(child)
|
|
586
|
-
|
|
589
|
+
const terminate = () => stopping ??= killCliTree(child).then((stopped) => {
|
|
590
|
+
if (!stopped)
|
|
591
|
+
stopping = undefined;
|
|
592
|
+
return stopped;
|
|
593
|
+
});
|
|
594
|
+
let completeStoppedTurn;
|
|
595
|
+
const stop = async () => {
|
|
587
596
|
stopRequested = true;
|
|
588
|
-
|
|
597
|
+
const stopped = await terminate();
|
|
598
|
+
if (stopped)
|
|
599
|
+
completeStoppedTurn?.();
|
|
600
|
+
return stopped;
|
|
589
601
|
};
|
|
590
602
|
const settle = async (ok, stopReason) => {
|
|
591
603
|
if (state.settled)
|
|
@@ -602,15 +614,9 @@ export const CodexDriver = {
|
|
|
602
614
|
active.delete(threadId);
|
|
603
615
|
emit({ ...base(threadId, turnId), type: "turn.completed", ok, stopReason, cost: null, ...(state.usage ? { usage: state.usage } : {}) });
|
|
604
616
|
};
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
else {
|
|
617
|
+
completeStoppedTurn = complete;
|
|
618
|
+
if (!(await stop())) {
|
|
609
619
|
emit({ ...base(threadId, turnId), type: "runtime.error", message: "codex did not shut down after termination was requested" });
|
|
610
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
611
|
-
complete();
|
|
612
|
-
else
|
|
613
|
-
child.once("close", complete);
|
|
614
620
|
}
|
|
615
621
|
};
|
|
616
622
|
// server→client approval request → canonical request.opened
|
|
@@ -873,7 +879,15 @@ export const CodexDriver = {
|
|
|
873
879
|
}
|
|
874
880
|
case "turn/completed": {
|
|
875
881
|
const t = p.turn ?? {};
|
|
876
|
-
|
|
882
|
+
const message = typeof t.error?.message === "string" ? t.error.message.slice(0, 400) : "";
|
|
883
|
+
if (t.status !== "completed" && message && message !== state.lastError) {
|
|
884
|
+
state.lastError = message;
|
|
885
|
+
emit({ ...base(threadId, turnId), type: "runtime.error", message,
|
|
886
|
+
...(classifyError({ text: message }).reason === "auth" ? { setup: true } : {}),
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
void settle(t.status === "completed", t.status === "completed" ? null :
|
|
890
|
+
(classifyError({ text: message || state.lastError }).reason === "provider_safety" ? "provider_safety" : (message || t.status || "failed")));
|
|
877
891
|
break;
|
|
878
892
|
}
|
|
879
893
|
case "error":
|
|
@@ -881,8 +895,10 @@ export const CodexDriver = {
|
|
|
881
895
|
// {error:{message}} — surface either (agentcal armor)
|
|
882
896
|
{
|
|
883
897
|
const message = p.message ?? p.error?.message;
|
|
884
|
-
if (message)
|
|
885
|
-
|
|
898
|
+
if (message) {
|
|
899
|
+
state.lastError = String(message).slice(0, 400);
|
|
900
|
+
emit({ ...base(threadId, turnId), type: "runtime.error", message: state.lastError });
|
|
901
|
+
}
|
|
886
902
|
}
|
|
887
903
|
break;
|
|
888
904
|
}
|
|
@@ -943,6 +959,12 @@ export const CodexDriver = {
|
|
|
943
959
|
child.on("close", (code) => {
|
|
944
960
|
if (abandoned)
|
|
945
961
|
return;
|
|
962
|
+
if (state.settled) {
|
|
963
|
+
// Root exit alone cannot release a turn after an uncertain stop.
|
|
964
|
+
// Recheck its group; an explicit later Stop can also retry this.
|
|
965
|
+
void stop();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
946
968
|
if (!state.settled) {
|
|
947
969
|
emit({
|
|
948
970
|
...base(threadId, turnId),
|
|
@@ -1134,7 +1156,7 @@ export const CodexDriver = {
|
|
|
1134
1156
|
message,
|
|
1135
1157
|
...(needsAuth ? { setup: true } : {}),
|
|
1136
1158
|
});
|
|
1137
|
-
await settle(false, needsAuth ? "auth_required" : "rpc_error");
|
|
1159
|
+
await settle(false, needsAuth ? "auth_required" : verdict.reason === "provider_safety" ? "provider_safety" : "rpc_error");
|
|
1138
1160
|
}
|
|
1139
1161
|
}
|
|
1140
1162
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isProviderSafetyBlock } from "../../shared/provider-safety.js";
|
|
1
2
|
export const RETRY_MAX_ATTEMPTS = 3;
|
|
2
3
|
/** Backoff schedule before attempt N (N is 1-based over retries): 1s / 3s / 8s. */
|
|
3
4
|
export const BACKOFF_BASE_MS = [1_000, 3_000, 8_000];
|
|
@@ -42,6 +43,9 @@ const messageOf = (err) => {
|
|
|
42
43
|
*/
|
|
43
44
|
export function classifyError(err) {
|
|
44
45
|
const text = messageOf(err);
|
|
46
|
+
// A surrounding HTTP 5xx/429 must not replay a provider safety block.
|
|
47
|
+
if (isProviderSafetyBlock(text))
|
|
48
|
+
return { transient: false, reason: "provider_safety" };
|
|
45
49
|
if (err && "exitCode" in err) {
|
|
46
50
|
const { exitCode: code } = err;
|
|
47
51
|
if (code !== null && code < 0)
|