@chibitek/chibi 0.1.168 → 0.1.170
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/{abnfDiagram-O67JEVCF-DUyzT6Xq.js → abnfDiagram-O67JEVCF-Dwjw0xC0.js} +1 -1
- package/dist/assets/{arc-B4-kO642.js → arc-B__BIHzb.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-DgKdnNL2.js → architectureDiagram-NJMV4G6O-DQy7n3Ga.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-B6oV3kDr.js → blockDiagram-BEXU5L5S-moFo2nSA.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Dp0E43jx.js → c4Diagram-YGBWAQC7-C-dF8UVl.js} +1 -1
- package/dist/assets/channel-BQM13l0i.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-Cfv1WVUo.js → chunk-2Q5K7J3B-DnF0mxAV.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-_-STY2yO.js → chunk-3FUC2YCW-Dp8Dsifv.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-C6MZm1cm.js → chunk-5VM5RSS4-D8fmZ4d8.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-C__aVIJy.js → chunk-GTNCS2PH-DNH2HZNQ.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-fSrARJuB.js → chunk-GWA4HPMP-BvL6j7pQ.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CJioJB6h.js → chunk-JWPE2WC7-Co0gUA5M.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-o6MBGCq_.js → chunk-NETBCI7D-Deu9tpCF.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-O-TFLbdc.js → chunk-WEXAMYUT-DKy8jAU5.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-DYS9CQbl.js → chunk-XXDRQBXY-DQhvP8zg.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BC4bSH09.js → classDiagram-v2-NBCMYWYE-mhv3hMMV.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-7NKuC3-C.js → cose-bilkent-JH36ORCC-CR1nOQOe.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CuWv5cOw.js → cynefin-OW5HDTMX-Ddgkhxgt.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-D_l_JrG5.js → cynefinDiagram-VND7K2PF-4KbkrNhB.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-D7eucSmr.js → dagre-6A5THRUB-DNxtjbvT.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-CADR5v1e.js → diagram-22UHCM2B-BRpvnOWX.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-DF_fBOZn.js → diagram-3UASUU5V-DsQVnKUb.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-DE5p59WH.js → diagram-ATOU4E4O-BRZ5455_.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-vthvYooA.js → diagram-CDSNMT55-CidrDKp0.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-ByMHy4S-.js → diagram-MLGK6HIB-1hkZ8SsK.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-C9hKBUnO.js → diagram-MPIPVDR6-D1dWGFqn.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-Djv6hWEl.js → ebnfDiagram-ZINNZB2B-DGjfEPY5.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CmUjf18z.js → elk-276RUBZZ-C5nvtTy7.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-DkmXQUok.js → erDiagram-OPXOYQCR-CC0QBE1A.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-6pz2poYn.js → flowDiagram-KWPJA3E3-BthbP_HO.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-Cx9Fbw75.js → ganttDiagram-FUAMR5RP-BQ2wLSEI.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-BR5MsYkP.js → gitGraphDiagram-X574FWY7-lTR8tNlq.js} +1 -1
- package/dist/assets/{index-BoF5cGZq.js → index-AWsWxgoq.js} +1 -1
- package/dist/assets/{index-Ckv52eaP.js → index-VagspC1z.js} +186 -186
- package/dist/assets/{infoDiagram-VRGFBTTK-DoF183Am.js → infoDiagram-VRGFBTTK-BgI9ZuT_.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-4T_-hM1-.js → ishikawaDiagram-OU5B5YK6-qPe6Rhk2.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-Ca0T3Ngd.js → journeyDiagram-ZHPQQLJL-CpbadZuP.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DNp_DAr9.js → kanban-definition-PNTS6WVX-D9kCb3-t.js} +1 -1
- package/dist/assets/{linear-COUoS2bF.js → linear-CdW4LT3g.js} +1 -1
- package/dist/assets/{mermaid.core-DQHowMIV.js → mermaid.core-BbPWTT56.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-CMgO1Mkh.js → mindmap-definition-NLK3R4M7-BtOaXxvb.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BCVCZbsq.js → pegDiagram-GJSIUBJH-BtcTITAK.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DxaMlX62.js → pieDiagram-5QR66LMP-Ch_--zjt.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-qUD0pkur.js → quadrantDiagram-O4NWA36T-Di8ygRal.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-Dc6rcAJB.js → railroadDiagram-XR7U4H2S-DxgesAmb.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DMTfI_lR.js → requirementDiagram-PLB6GJNP-DjH-jdCr.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-B0BWZys8.js → sankeyDiagram-IPEJSGJF-ClNLceSc.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-DZVt17mL.js → sequenceDiagram-PO4LG4MO-Cip_EmtV.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BFQztpNW.js → sizeCapture-INFHLROL-BzW2hkq8.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-GeFCVCDr.js → stateDiagram-v2-GCMORJYK-CwHu7P19.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DTqQXTvP.js → swimlanes-2SLR337P-DO9E9HUK.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-UC0GNJzN.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BoiNrkP7.js → timeline-definition-EJHVYXUP-D2_E-i9h.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-CdfsSO2g.js → usecaseDiagram-POWQR4AR-oKBQ1FAG.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-CHc_Fn1O.js → vennDiagram-UO4OBE2U-HEdQsC7L.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-Bv3KWVEF.js → wardleyDiagram-VNRHLVJA-mNpRO8rd.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-CQFknLPS.js → xychartDiagram-PMCCYNJV-uURjTK1X.js} +1 -1
- package/dist/handbook/atomic-handbook.html +1 -1
- package/dist/index.html +1 -1
- package/dist-server/container-mcp.js +1 -0
- package/dist-server/hooks/plan-hook.js +22 -0
- package/dist-server/index.js +1277 -931
- package/dist-server/local-computer.js +1 -0
- package/dist-server/openmausbot.js +384 -298
- package/dist-server/pair-cli.js +384 -298
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/atomic-engine-release.js +13 -13
- package/dist-server/server/bot-work-modes.js +212 -0
- package/dist-server/server/config.js +6 -5
- package/dist-server/server/drivers/acp/core.js +6 -4
- package/dist-server/server/drivers/acp/custom.js +51 -37
- package/dist-server/server/drivers/builtIn.js +2 -1
- package/dist-server/server/drivers/chat-mcp-tools.js +1 -0
- package/dist-server/server/drivers/claude.js +28 -25
- package/dist-server/server/drivers/codex-approval-presets.js +34 -0
- package/dist-server/server/drivers/codex.js +11 -35
- package/dist-server/server/drivers/openai-chat.js +4 -3
- package/dist-server/server/hooks/plan-hook.js +17 -0
- package/dist-server/server/index.js +73 -57
- package/dist-server/server/mcp-interactive-probe.js +60 -0
- package/dist-server/server/mcp-remote-bridge.js +13 -6
- package/dist-server/server/plan-policy.js +14 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/store.js +4 -4
- package/dist-server/shared/approval-mode.js +3 -1
- package/dist-server/shared/engine-choices.js +1 -1
- package/dist-server/vps-container-mcp.js +1 -0
- package/package.json +1 -1
- package/dist/assets/channel-BidDehbw.js +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CRQqFEdH.js +0 -8
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { namedApprovalParams } from "./codex-approval-presets.js";
|
|
1
2
|
import { codexToolSurfaceArgs } from "./codex-tool-surface.js";
|
|
2
3
|
import { codexImage } from "./codex-image.js";
|
|
3
4
|
// Official Codex app-server over newline-delimited JSON-RPC on stdio.
|
|
@@ -183,40 +184,6 @@ function additionalPermissionSummary(permissions, reason) {
|
|
|
183
184
|
const plainRecord = (value) => value && typeof value === "object" && !Array.isArray(value)
|
|
184
185
|
? value
|
|
185
186
|
: null;
|
|
186
|
-
/** Codex persists these values on its native thread. Keep them explicit on
|
|
187
|
-
* start, resume, and every turn so switching modes cannot leave a more
|
|
188
|
-
* permissive sandbox/reviewer stuck to the next request. */
|
|
189
|
-
/** Ask and Edits both run Codex's workspace-write sandbox with the person as
|
|
190
|
-
* reviewer: Codex has no narrower "edits only" mode, so the selector never
|
|
191
|
-
* offers Edits for it (supportsApprovalMode) and a stray value asks. */
|
|
192
|
-
function namedApprovalParams(mode) {
|
|
193
|
-
if (mode === "full") {
|
|
194
|
-
return {
|
|
195
|
-
thread: {
|
|
196
|
-
approvalPolicy: "never",
|
|
197
|
-
approvalsReviewer: "user",
|
|
198
|
-
sandbox: "danger-full-access",
|
|
199
|
-
},
|
|
200
|
-
turn: {
|
|
201
|
-
approvalPolicy: "never",
|
|
202
|
-
approvalsReviewer: "user",
|
|
203
|
-
sandboxPolicy: { type: "dangerFullAccess" },
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
return {
|
|
208
|
-
thread: {
|
|
209
|
-
approvalPolicy: "on-request",
|
|
210
|
-
approvalsReviewer: mode === "auto" ? "auto_review" : "user",
|
|
211
|
-
sandbox: "workspace-write",
|
|
212
|
-
},
|
|
213
|
-
turn: {
|
|
214
|
-
approvalPolicy: "on-request",
|
|
215
|
-
approvalsReviewer: mode === "auto" ? "auto_review" : "user",
|
|
216
|
-
sandboxPolicy: { type: "workspaceWrite" },
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
187
|
/** Linked repos besides the working folder become extra writable roots of
|
|
221
188
|
* Codex's workspace-write sandbox (the app-server's equivalent of the CLI's
|
|
222
189
|
* --add-dir). Full access already reaches them; read-only and a named
|
|
@@ -559,6 +526,9 @@ export const CodexDriver = {
|
|
|
559
526
|
// instance's legacy fullAuto setting. Harness turns always send an
|
|
560
527
|
// explicit mode, which takes precedence.
|
|
561
528
|
const approvalMode = turn.approvalMode ?? (config.fullAuto ? "full" : "ask");
|
|
529
|
+
// Plan has no MCP/harness tools, even when a user opted into native MCP.
|
|
530
|
+
if (approvalMode === "plan")
|
|
531
|
+
turn = { ...turn, integrations: undefined, mcpFromUserConfig: false };
|
|
562
532
|
// The person's servers this turn mounts. One that would clobber a
|
|
563
533
|
// harness-owned variable is left out, not fatal: the rest still mount.
|
|
564
534
|
const customServers = {};
|
|
@@ -627,7 +597,7 @@ export const CodexDriver = {
|
|
|
627
597
|
// The harness's own mounts above are never switched off by this.
|
|
628
598
|
if (!turn.mcpFromUserConfig) {
|
|
629
599
|
const harnessMounts = new Set(["openmausbot_connectors", "atomic_accounts", "agents", "computer", "browser", "openmausbot_phone"]);
|
|
630
|
-
appServerArgs.push(...codexUserMcpOffArgs(new Map([...declaredInCodexConfig].filter(([name]) => !harnessMounts.has(name)))));
|
|
600
|
+
appServerArgs.push(...codexUserMcpOffArgs(new Map([...declaredInCodexConfig].filter(([name]) => approvalMode === "plan" || !harnessMounts.has(name)))));
|
|
631
601
|
// Same rule for the person's plugins (a `git ls-remote` per Git
|
|
632
602
|
// marketplace at every start) and personal skills (listed in the
|
|
633
603
|
// model's context on every call). Sign-in is untouched.
|
|
@@ -656,6 +626,8 @@ export const CodexDriver = {
|
|
|
656
626
|
const prefix = "mcp_servers.openmausbot_phone";
|
|
657
627
|
appServerArgs.push("-c", `${prefix}.command=${JSON.stringify(bridge.command)}`, "-c", `${prefix}.args=${JSON.stringify(bridge.args)}`, "-c", `${prefix}.env_vars=${JSON.stringify(Object.keys(bridge.env))}`, "-c", `${prefix}.default_tools_approval_mode="auto"`);
|
|
658
628
|
}
|
|
629
|
+
if (approvalMode === "plan")
|
|
630
|
+
appServerArgs.push("-c", 'web_search="disabled"', "-c", "features.apps=false", "-c", "features.multi_agent=false");
|
|
659
631
|
const child = spawnCli(config.cli, appServerArgs, {
|
|
660
632
|
cwd: childCwd,
|
|
661
633
|
env,
|
|
@@ -925,6 +897,10 @@ export const CodexDriver = {
|
|
|
925
897
|
: isAdditionalPermission
|
|
926
898
|
? { permissions: allow ? grantedPermissions(params.permissions) : {}, scope: "turn" }
|
|
927
899
|
: { decision: allow ? (legacy ? "approved" : "accept") : legacy ? "denied" : "decline" };
|
|
900
|
+
if (approvalMode === "plan" && isPermission) {
|
|
901
|
+
send({ jsonrpc: "2.0", id: msg.id, result: permissionResult(false) });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
928
904
|
if (autoAcceptPermissions && isPermission && !serverConfirm) {
|
|
929
905
|
return send({
|
|
930
906
|
jsonrpc: "2.0",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PLAN_REFUSAL } from "../plan-policy.js";
|
|
1
2
|
import { newEventId, newId } from "../contracts.js";
|
|
2
3
|
import { redactSecretsInText } from "../redact.js";
|
|
3
4
|
import { toolDetailPreview } from "../tool-summary.js";
|
|
@@ -423,8 +424,8 @@ export function createOpenAIChatRuntime(options) {
|
|
|
423
424
|
// so it is honoured here: without it every single tool call on
|
|
424
425
|
// an OpenAI-compatible engine stops for a card, and a Chief's
|
|
425
426
|
// delegated Full access cannot help either.
|
|
426
|
-
const allowed = turn.approvalMode === "full"
|
|
427
|
-
|| await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.", tools.customTool?.(call.function.name));
|
|
427
|
+
const allowed = (turn.approvalMode !== "plan" || tools.planReadOnly?.(call.function.name) === true) && (turn.approvalMode === "full"
|
|
428
|
+
|| await approval.ask(call.function.name, inputPreview ?? "This tool has no arguments.", tools.customTool?.(call.function.name)));
|
|
428
429
|
abort.signal.throwIfAborted();
|
|
429
430
|
emit({ ...base(turn.threadId, turnId), type: "item.started", itemType: "tool", itemId: call.id,
|
|
430
431
|
title: call.function.name, ...(inputPreview ? { input: inputPreview } : {}),
|
|
@@ -444,7 +445,7 @@ export function createOpenAIChatRuntime(options) {
|
|
|
444
445
|
}
|
|
445
446
|
else {
|
|
446
447
|
denials.push(call.function.name);
|
|
447
|
-
result = { ok: false, text: "Permission denied or expired; the tool was not executed." };
|
|
448
|
+
result = { ok: false, text: turn.approvalMode === "plan" && tools.planReadOnly?.(call.function.name) !== true ? PLAN_REFUSAL : "Permission denied or expired; the tool was not executed." };
|
|
448
449
|
}
|
|
449
450
|
}
|
|
450
451
|
catch (error) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Atomic Engineering, 2026-10-31. Claude PreToolUse Plan guard.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { planToolAllowed, PLAN_REFUSAL } from "../plan-policy.js";
|
|
4
|
+
try {
|
|
5
|
+
const event = JSON.parse(readFileSync(0, "utf8"));
|
|
6
|
+
if (event.hook_event_name === "PreToolUse" && planToolAllowed(event.tool_name)) {
|
|
7
|
+
// No permission override: reads still obey Claude's normal access checks.
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
process.stderr.write(PLAN_REFUSAL);
|
|
11
|
+
process.exitCode = 2;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
process.stderr.write(PLAN_REFUSAL);
|
|
16
|
+
process.exitCode = 2;
|
|
17
|
+
}
|
|
@@ -34,7 +34,7 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
|
|
|
34
34
|
import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
|
|
35
35
|
import { escapeAttribute } from "../src/lib/composer-attachments.js";
|
|
36
36
|
import { credentialResumeOutcome, credentialIsConfigured, isCredentialTargetId, } from "../shared/credential-request.js";
|
|
37
|
-
import { approvalHeldNote, approvalHeldReason,
|
|
37
|
+
import { approvalHeldNote, approvalHeldReason, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
|
|
38
38
|
import { updateClaudeCli } from "./claude-update.js";
|
|
39
39
|
import { failedRunDetail } from "./turn-failure.js";
|
|
40
40
|
import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
|
|
@@ -99,6 +99,7 @@ import { decodeGeneratedImage } from "./generated-image.js";
|
|
|
99
99
|
import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, isRemoteMcpServer, mcpReadOnlyAutoAllow, } from "./mcp-registry.js";
|
|
100
100
|
import { mcpLaunchBlocker, mcpLaunchBlockerReason, mcpSecretStorage, mcpServerSecretView, moveParkedArgToEnv, receiveMcpSecretsSaveResult, setMcpSecretSender, storeMcpServerSecrets, } from "./mcp-secrets.js";
|
|
101
101
|
import { probeMcpServer } from "./mcp-probe.js";
|
|
102
|
+
import { createInteractiveMcpProbe } from "./mcp-interactive-probe.js";
|
|
102
103
|
import { McpToolHintStore, readOnlyToolAllowed } from "./mcp-tool-hints.js";
|
|
103
104
|
import { McpAuthStateStore } from "./mcp-auth-state.js";
|
|
104
105
|
import { McpHealthStore } from "./mcp-health.js";
|
|
@@ -251,6 +252,7 @@ import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, Pho
|
|
|
251
252
|
// Keep these two last: a route module may import any server module, and
|
|
252
253
|
// loading the table after everything above leaves module start-up order as is.
|
|
253
254
|
import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
|
|
255
|
+
import { BotWorkModes, createBotWorkModeRoutes, ownBotApprovalMode, legacyGodModeCovers, dispatchWorkMode, trustedPlanChangeForStore, invalidAllThreadScope } from "./bot-work-modes.js";
|
|
254
256
|
import { ROUTES, dispatchRoutes } from "./routes/table.js";
|
|
255
257
|
import { createThreadStopRoutes } from "./routes/thread-stop.js";
|
|
256
258
|
import { createThreadImageRoutes } from "./routes/thread-images.js";
|
|
@@ -4171,8 +4173,8 @@ function checkedTaskModelSwitch(current, raw, updateBotDefault, resetApprovalToA
|
|
|
4171
4173
|
}
|
|
4172
4174
|
for (const target of updateBotDefault ? [current, profile] : [current]) {
|
|
4173
4175
|
const mode = approvalModeFor(target);
|
|
4174
|
-
if (resetApprovalToAsk && mode === "custom" && !trusted) {
|
|
4175
|
-
return { ok: false, status: 403, error: "
|
|
4176
|
+
if (resetApprovalToAsk && (mode === "plan" || (mode === "custom" && !trusted))) {
|
|
4177
|
+
return { ok: false, status: 403, error: "Approve the plan or change Custom permissions in the app before switching models" };
|
|
4176
4178
|
}
|
|
4177
4179
|
if (!resetApprovalToAsk && modelSwitchNeedsAsk(mode, registry.cliTarget(target.modelSelection.instanceId)?.driverKind, registry.cliTarget(checked.selection.instanceId)?.driverKind)) {
|
|
4178
4180
|
return { ok: false, status: 400, error: "Confirm switching this model with Ask permissions first (resetApprovalToAsk)" };
|
|
@@ -4324,7 +4326,7 @@ const wireBot = (bot) => {
|
|
|
4324
4326
|
const visible = approvalGrant && !approvalGrant.threadOnly
|
|
4325
4327
|
? { ...rest, approvalMode: "ask", autoApprove: false }
|
|
4326
4328
|
: rest;
|
|
4327
|
-
return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
|
|
4329
|
+
return { ...visible, workMode: godModeCovers(bot) ? "full" : botWorkModes.permits(approvalModeFor(visible)) ? approvalModeFor(visible) : "ask", workModeWorkspaceOverride: godModeCovers(bot), waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
|
|
4328
4330
|
screenLive: screenLiveForBot(bot.id), avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
|
|
4329
4331
|
};
|
|
4330
4332
|
/** The correlated private response carries the requested value so Electron
|
|
@@ -4450,7 +4452,7 @@ async function botOverview(bot) {
|
|
|
4450
4452
|
autoApprove: bot.autoApprove,
|
|
4451
4453
|
approvalMode: approvalModeForTurn(bot),
|
|
4452
4454
|
// GOD MODE skips the peer check for every bot, whatever its engine
|
|
4453
|
-
approvePeerComms: bot.approvePeerComms && !
|
|
4455
|
+
approvePeerComms: bot.approvePeerComms && !godModeCovers(bot),
|
|
4454
4456
|
peers: bot.peers,
|
|
4455
4457
|
browser: bot.browser,
|
|
4456
4458
|
chiefOfStaff: bot.chiefOfStaff,
|
|
@@ -4489,36 +4491,15 @@ async function botOverview(bot) {
|
|
|
4489
4491
|
recent,
|
|
4490
4492
|
});
|
|
4491
4493
|
}
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
* uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
|
|
4497
|
-
* with one deliberate exception: a Chief of Staff with Full access makes the
|
|
4498
|
-
* threads it delegates Full too (delegatedFullAccess), so the grant the
|
|
4499
|
-
* person gave the Chief covers the work the Chief hands out. */
|
|
4494
|
+
// Enforce engine permissions on every turn, including delegated work.
|
|
4495
|
+
const botWorkModes = new BotWorkModes(join(DATA_DIR, "bot-work-modes.json"));
|
|
4496
|
+
for (const id of botWorkModes.expired(id => sessions.isLive(id)))
|
|
4497
|
+
store.setAllThreadApprovalMode(id, "ask", true);
|
|
4500
4498
|
const approvalModeForTurn = (bot, peerInitiated = false) => godModeElevates(bot) ? "full" : ownApprovalModeForTurn(bot, peerInitiated);
|
|
4501
4499
|
/** The bot's (or thread's) own level for a turn, GOD MODE aside. */
|
|
4502
|
-
const ownApprovalModeForTurn = (bot, peerInitiated = false) =>
|
|
4503
|
-
const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
|
|
4504
|
-
if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
|
|
4505
|
-
return "ask";
|
|
4506
|
-
}
|
|
4507
|
-
return mode;
|
|
4508
|
-
};
|
|
4509
|
-
/** GOD MODE covers this bot or thread: the switch is on, this server allows
|
|
4510
|
-
* it, and no approval change of this bot's own is still being confirmed
|
|
4511
|
-
* (approvalModeFor keeps that inert as Ask, and so does GOD MODE). It is
|
|
4512
|
-
* decided per request and never written to a bot record, so turning it off
|
|
4513
|
-
* returns every bot to its own level. Every engine is covered: one that
|
|
4514
|
-
* implements Full runs its turns at Full (godModeElevates); on any other the
|
|
4515
|
-
* harness answers each tool permission request the engine sends
|
|
4516
|
-
* (request.opened), so no engine keeps asking under GOD MODE. */
|
|
4500
|
+
const ownApprovalModeForTurn = (bot, peerInitiated = false) => ownBotApprovalMode(bot, registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, peerInitiated, botWorkModes.permits(approvalModeFor(bot)));
|
|
4517
4501
|
function godModeCovers(bot) {
|
|
4518
|
-
|
|
4519
|
-
return false;
|
|
4520
|
-
const grant = bot.approvalGrant;
|
|
4521
|
-
return !(grant && !(grant.threadOnly && grant.threadId !== bot.threadId));
|
|
4502
|
+
return legacyGodModeCovers(bot, godModeActive() && botWorkModes.permits("full"), botWorkModes.selected(bot.id) !== undefined);
|
|
4522
4503
|
}
|
|
4523
4504
|
/** GOD MODE runs this bot or thread at Full, whatever its own level says:
|
|
4524
4505
|
* it is covered (godModeCovers) and its engine implements Full. */
|
|
@@ -4563,7 +4544,7 @@ function peerReviewRequired(bot, threadId) {
|
|
|
4563
4544
|
/** "Ask me before contacting other bots" is skipped with Full access on the
|
|
4564
4545
|
* source conversation, and for every bot while GOD MODE is on. */
|
|
4565
4546
|
function peerApprovalWaived(botId, threadId) {
|
|
4566
|
-
return
|
|
4547
|
+
return fullAccessForSource(botId, threadId) || Boolean(store.projectBotForTask(botId, threadId) && godModeCovers(store.projectBotForTask(botId, threadId)));
|
|
4567
4548
|
}
|
|
4568
4549
|
/** Full access flows down a Chief of Staff's delegation. The person gave the
|
|
4569
4550
|
* Chief Full access so its work runs without prompts; a teammate stopping
|
|
@@ -4577,7 +4558,7 @@ function peerApprovalWaived(botId, threadId) {
|
|
|
4577
4558
|
function delegatedFullAccess(from, fromThreadId, target) {
|
|
4578
4559
|
// Chibi's GOD MODE never reaches the Private Bot, and the Private Bot's
|
|
4579
4560
|
// never reaches Chibi: each runs a linked handoff at its own level.
|
|
4580
|
-
if (isPersonalBot(from) !== isPersonalBot(target))
|
|
4561
|
+
if (isPersonalBot(from) !== isPersonalBot(target) || approvalModeFor(target) === "plan")
|
|
4581
4562
|
return false;
|
|
4582
4563
|
return delegationInheritsFullAccess({
|
|
4583
4564
|
senderIsChief: Boolean(from.chiefOfStaff),
|
|
@@ -4681,7 +4662,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
4681
4662
|
const grantBusy = (bot) => bot.approvalGrant?.threadOnly
|
|
4682
4663
|
? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy ||
|
|
4683
4664
|
Boolean(bot.approvalGrant?.allThreads && store.tasks(bot.id).some(task => threadBusy(bot.id, task.threadId)));
|
|
4684
|
-
const grantSupported = (bot, mode) => supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
|
|
4665
|
+
const grantSupported = (bot, mode) => botWorkModes.permits(mode) && supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
|
|
4685
4666
|
(!bot.approvalGrant?.allThreads || store.tasks(bot.id).every(task => supportsApprovalMode(registry.cliTarget((task.modelSelection ?? bot.modelSelection).instanceId)?.driverKind, mode)));
|
|
4686
4667
|
const clearGrant = (bot) => store.patchBot(bot.id, {
|
|
4687
4668
|
...(!bot.approvalGrant?.threadOnly ? { approvalMode: "ask", autoApprove: false } : {}),
|
|
@@ -4896,6 +4877,10 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
4896
4877
|
respond({ ok: false, error: "The approval mode is invalid" });
|
|
4897
4878
|
return true;
|
|
4898
4879
|
}
|
|
4880
|
+
if (!botWorkModes.permits(mode)) {
|
|
4881
|
+
respond({ ok: false, error: "This mode is disabled in Settings" });
|
|
4882
|
+
return true;
|
|
4883
|
+
}
|
|
4899
4884
|
const existing = store.bot(botId);
|
|
4900
4885
|
if (!existing) {
|
|
4901
4886
|
respond({ ok: false, error: "No such bot" });
|
|
@@ -4903,15 +4888,16 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
4903
4888
|
}
|
|
4904
4889
|
const currentMode = approvalModeFor(existing);
|
|
4905
4890
|
const threadId = message.threadId;
|
|
4906
|
-
if (
|
|
4907
|
-
(
|
|
4908
|
-
|
|
4909
|
-
|
|
4891
|
+
if (!trustedPlanChangeForStore(store, botId, mode, message)) {
|
|
4892
|
+
respond({ ok: false, error: "Approve the plan in the app before leaving Plan" });
|
|
4893
|
+
return true;
|
|
4894
|
+
}
|
|
4895
|
+
if (invalidAllThreadScope(mode, message)) {
|
|
4910
4896
|
respond({ ok: false, error: "Invalid all-threads approval scope" });
|
|
4911
4897
|
return true;
|
|
4912
4898
|
}
|
|
4913
4899
|
if (message.allThreads === true && mode === "ask") {
|
|
4914
|
-
const updated = store.setAllThreadApprovalMode(botId, "ask");
|
|
4900
|
+
const updated = store.setAllThreadApprovalMode(botId, "ask", message.approvePlan !== true);
|
|
4915
4901
|
void stopBotForEmergencyApprovalDowngrade(botId).then(() => respond({ ok: true, bot: wireBot(store.bot(botId) ?? updated) }), () => respond({ ok: false, error: "Approval was reset to Ask, but an active turn could not be stopped" }));
|
|
4916
4902
|
return true;
|
|
4917
4903
|
}
|
|
@@ -6048,6 +6034,10 @@ function closeSessionStreams(sessionId) {
|
|
|
6048
6034
|
}
|
|
6049
6035
|
sessions.onSessionRevoked((sessionId) => {
|
|
6050
6036
|
providerAuthSessions.revokeOwner(sessionId);
|
|
6037
|
+
for (const id of botWorkModes.revoke(sessionId)) {
|
|
6038
|
+
store.setAllThreadApprovalMode(id, "ask", true);
|
|
6039
|
+
void interruptBotWork(id);
|
|
6040
|
+
}
|
|
6051
6041
|
closeSessionStreams(sessionId);
|
|
6052
6042
|
});
|
|
6053
6043
|
/** Every frame is numbered, and the last few hundred are kept, so a client
|
|
@@ -10817,7 +10807,7 @@ async function startTurn(botId, text, opts) {
|
|
|
10817
10807
|
text: dispatchContext.turnText,
|
|
10818
10808
|
refreshSystemPrompt: true,
|
|
10819
10809
|
images: turnImages,
|
|
10820
|
-
approvalMode: approvalModeForTurn(bot, commsDepth > 0),
|
|
10810
|
+
approvalMode: dispatchWorkMode(approvalModeForTurn(bot, commsDepth > 0), registry.cliTarget(instance.instanceId)?.driverKind),
|
|
10821
10811
|
model,
|
|
10822
10812
|
effort,
|
|
10823
10813
|
variant,
|
|
@@ -12769,7 +12759,7 @@ failover) {
|
|
|
12769
12759
|
text,
|
|
12770
12760
|
refreshSystemPrompt: true,
|
|
12771
12761
|
images: turnImages,
|
|
12772
|
-
approvalMode: roomTurnApprovalMode(readyBot, orchestration),
|
|
12762
|
+
approvalMode: dispatchWorkMode(roomTurnApprovalMode(readyBot, orchestration), registry.cliTarget(instance.instanceId)?.driverKind),
|
|
12773
12763
|
system: roomSystem.text,
|
|
12774
12764
|
systemStable: roomSystem.stable,
|
|
12775
12765
|
systemVolatile: roomSystem.volatile,
|
|
@@ -13610,8 +13600,9 @@ function handoffAuthorizations(from, sourceThreadId, raw) {
|
|
|
13610
13600
|
conversationBotName: from.name,
|
|
13611
13601
|
});
|
|
13612
13602
|
}
|
|
13613
|
-
/**
|
|
13614
|
-
|
|
13603
|
+
/** Test and Reconnect share a single browser-allowed probe per connection.
|
|
13604
|
+
* Bot MCP sessions remain independent to preserve request routing. */
|
|
13605
|
+
const interactiveMcpProbe = createInteractiveMcpProbe();
|
|
13615
13606
|
/** The interview's view of this computer (GET /api/bot-interview/context):
|
|
13616
13607
|
* only tools that exist here, each with a plain status. The Personal Bot
|
|
13617
13608
|
* sees only connections marked Personal; a company bot never sees those. */
|
|
@@ -16043,6 +16034,24 @@ ROUTES.push(createMcpMarketplaceRoutes({
|
|
|
16043
16034
|
fromBot: (req) => authorizedInternalCapability(req.headers.authorization) !== null,
|
|
16044
16035
|
sessionExpired: (auth) => auth.kind === "session" && !sessions.isLive(auth.session.id),
|
|
16045
16036
|
}));
|
|
16037
|
+
ROUTES.push(createBotWorkModeRoutes({
|
|
16038
|
+
modes: botWorkModes, desktopManaged: DESKTOP_MANAGED, live: id => sessions.isLive(id),
|
|
16039
|
+
bot: id => {
|
|
16040
|
+
const bot = store.bot(id);
|
|
16041
|
+
if (!bot)
|
|
16042
|
+
return undefined;
|
|
16043
|
+
const targets = [bot, ...store.tasks(id).map(task => store.projectBotForTask(id, task.threadId)).filter(Boolean)];
|
|
16044
|
+
return { busy: targets.some(target => threadBusy(id, target.threadId)),
|
|
16045
|
+
revision: JSON.stringify(targets.map(target => [target.threadId, target.modelSelection, target.approvalMode, target.approvalGrant, store.messagesFor(target.threadId).at(-1)?.id])),
|
|
16046
|
+
drivers: targets.map(target => registry.cliTarget(target.modelSelection.instanceId)?.driverKind),
|
|
16047
|
+
current: targets.some(target => approvalModeFor(target) === "plan") ? "plan" : wireBot(bot).workMode ?? "ask" };
|
|
16048
|
+
},
|
|
16049
|
+
stop: interruptBotWork,
|
|
16050
|
+
apply: (id, mode, preservePlan) => { store.setAllThreadApprovalMode(id, mode, preservePlan); },
|
|
16051
|
+
affected: allowed => store.bots.filter(bot => [bot, ...store.tasks(bot.id).map(task => store.projectBotForTask(bot.id, task.threadId)).filter(Boolean)].some(target => ![...allowed, "auto", "custom", "plan"].includes(godModeCovers(target) ? "full" : approvalModeFor(target)))).map(bot => bot.id),
|
|
16052
|
+
changed: id => { const bot = store.bot(id); if (bot)
|
|
16053
|
+
broadcast({ kind: "bot", bot: wireBot(bot) }); },
|
|
16054
|
+
}));
|
|
16046
16055
|
// Settings → AI & accounts → Local models: Ollama's models, pulls and removals.
|
|
16047
16056
|
ROUTES.push(createLocalModelRoutes({
|
|
16048
16057
|
ollamaUrl: process.env.OMB_OLLAMA_URL || undefined,
|
|
@@ -21283,8 +21292,12 @@ const handleRequest = async (req, res) => {
|
|
|
21283
21292
|
else {
|
|
21284
21293
|
requestedApprovalMode = approvalModeFor(existingBot ?? {});
|
|
21285
21294
|
}
|
|
21295
|
+
if (!botWorkModes.permits(requestedApprovalMode))
|
|
21296
|
+
return json(res, 403, { error: "This mode is disabled in Settings" });
|
|
21286
21297
|
const currentApprovalMode = approvalModeFor(existingBot ?? {});
|
|
21287
21298
|
const approvalChangeRequested = body.approvalMode !== undefined || body.autoApprove !== undefined;
|
|
21299
|
+
if (currentApprovalMode === "plan" && requestedApprovalMode !== "plan")
|
|
21300
|
+
return json(res, 403, { error: "Approve the plan in the app before leaving Plan" });
|
|
21288
21301
|
if (existingBot?.busy && approvalChangeRequested && requestedApprovalMode !== currentApprovalMode) {
|
|
21289
21302
|
return json(res, 409, {
|
|
21290
21303
|
error: "stop this bot's turn before changing its approval level",
|
|
@@ -21299,13 +21312,13 @@ const handleRequest = async (req, res) => {
|
|
|
21299
21312
|
const targetSelection = normalizedSelection ?? existingBot?.modelSelection;
|
|
21300
21313
|
if (normalizedSelection && selectedTask) {
|
|
21301
21314
|
const mode = approvalModeFor(selectedTask);
|
|
21302
|
-
if ((mode === "full" || mode === "custom") &&
|
|
21315
|
+
if ((mode === "full" || mode === "custom" || mode === "plan") &&
|
|
21303
21316
|
(!supportsApprovalMode(registry.cliTarget(normalizedSelection.instanceId)?.driverKind, mode) ||
|
|
21304
21317
|
registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(selectedTask.modelSelection.instanceId)?.driverKind)) {
|
|
21305
21318
|
return json(res, 400, { error: "Choose Ask for the selected thread before changing providers with elevated permissions" });
|
|
21306
21319
|
}
|
|
21307
21320
|
}
|
|
21308
|
-
if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") &&
|
|
21321
|
+
if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom" || requestedApprovalMode === "plan") &&
|
|
21309
21322
|
(body.approvalMode !== undefined || normalizedSelection !== undefined) &&
|
|
21310
21323
|
(!targetSelection || !supportsApprovalMode(registry.cliTarget(targetSelection.instanceId)?.driverKind, requestedApprovalMode) ||
|
|
21311
21324
|
(existingBot && normalizedSelection && registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(existingBot.modelSelection.instanceId)?.driverKind))) {
|
|
@@ -23009,14 +23022,19 @@ const handleRequest = async (req, res) => {
|
|
|
23009
23022
|
if (!checked.ok)
|
|
23010
23023
|
return json(res, checked.status, { error: checked.error });
|
|
23011
23024
|
patch.modelSelection = checked.selection;
|
|
23025
|
+
if (approvalModeFor(current) === "plan" && !supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, "plan"))
|
|
23026
|
+
return json(res, 400, { error: "Choose Ask before switching from Plan to this engine" });
|
|
23012
23027
|
}
|
|
23013
23028
|
if (body.approvalMode !== undefined || body.autoApprove !== undefined) {
|
|
23014
23029
|
if (body.autoApprove !== undefined && typeof body.autoApprove !== "boolean")
|
|
23015
23030
|
return json(res, 400, { error: "autoApprove must be a boolean" });
|
|
23016
23031
|
const mode = body.approvalMode ?? (body.autoApprove ? "auto" : "ask");
|
|
23017
|
-
|
|
23018
|
-
|
|
23019
|
-
if (
|
|
23032
|
+
if (!botWorkModes.permits(mode))
|
|
23033
|
+
return json(res, 403, { error: "This mode is disabled in Settings" });
|
|
23034
|
+
if (approvalModeFor(current) === "plan" && mode !== "plan")
|
|
23035
|
+
return json(res, 403, { error: "Approve the plan in the app before leaving Plan" });
|
|
23036
|
+
// A settings PATCH cannot manufacture an elevated grant.
|
|
23037
|
+
if (mode !== "ask" && mode !== "auto" && mode !== "edits" && mode !== "plan")
|
|
23020
23038
|
return json(res, 403, { error: "Full and Custom access require trusted desktop confirmation" });
|
|
23021
23039
|
if (approvalModeFor(current) === "custom")
|
|
23022
23040
|
return json(res, 403, { error: "Leaving Custom approval requires confirmation in the packaged desktop app" });
|
|
@@ -23995,7 +24013,7 @@ const handleRequest = async (req, res) => {
|
|
|
23995
24013
|
const testBlocked = mcpLaunchBlocker(parsed.server);
|
|
23996
24014
|
if (testBlocked)
|
|
23997
24015
|
return json(res, 409, { error: `${mcpTest[1]} cannot start yet: ${mcpLaunchBlockerReason(testBlocked)}`, code: testBlocked });
|
|
23998
|
-
if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES) {
|
|
24016
|
+
if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES && !interactiveMcpProbe.has(bridgedMcpServer(parsed.server, true))) {
|
|
23999
24017
|
return json(res, 429, { error: "Two MCP connection tests are already running." });
|
|
24000
24018
|
}
|
|
24001
24019
|
const controller = new AbortController();
|
|
@@ -24006,11 +24024,9 @@ const handleRequest = async (req, res) => {
|
|
|
24006
24024
|
res.once("close", disconnect);
|
|
24007
24025
|
mcpProbesInFlight += 1;
|
|
24008
24026
|
try {
|
|
24009
|
-
//
|
|
24010
|
-
//
|
|
24011
|
-
const
|
|
24012
|
-
// A person's click: the one start besides Reconnect that may open a browser.
|
|
24013
|
-
const probed = await probeMcpServer(bridgedMcpServer(parsed.server, true), connecting ? MCP_SIGN_IN_TIMEOUT_MS : undefined, controller.signal);
|
|
24027
|
+
// Test may open a browser even without ?connect=1. Keep its callback
|
|
24028
|
+
// listener alive for the same MFA window as Reconnect.
|
|
24029
|
+
const probed = await interactiveMcpProbe(bridgedMcpServer(parsed.server, true), controller.signal);
|
|
24014
24030
|
if (probed.ok) {
|
|
24015
24031
|
mcpAuthState.noteOk(mcpTest[1]);
|
|
24016
24032
|
mcpHealth.noteOk(mcpTest[1]);
|
|
@@ -24049,7 +24065,7 @@ const handleRequest = async (req, res) => {
|
|
|
24049
24065
|
const reconnectBlocked = mcpLaunchBlocker(parsed.server);
|
|
24050
24066
|
if (reconnectBlocked)
|
|
24051
24067
|
return json(res, 409, { error: `${name} cannot start yet: ${mcpLaunchBlockerReason(reconnectBlocked)}`, code: reconnectBlocked });
|
|
24052
|
-
if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES) {
|
|
24068
|
+
if (mcpProbesInFlight >= MAX_CONCURRENT_MCP_PROBES && !interactiveMcpProbe.has(bridgedMcpServer(parsed.server, true))) {
|
|
24053
24069
|
return json(res, 429, { error: "Two MCP connection tests are already running." });
|
|
24054
24070
|
}
|
|
24055
24071
|
const controller = new AbortController();
|
|
@@ -24060,7 +24076,7 @@ const handleRequest = async (req, res) => {
|
|
|
24060
24076
|
res.once("close", disconnect);
|
|
24061
24077
|
mcpProbesInFlight += 1;
|
|
24062
24078
|
try {
|
|
24063
|
-
const probed = await
|
|
24079
|
+
const probed = await interactiveMcpProbe(bridgedMcpServer(parsed.server, true), controller.signal);
|
|
24064
24080
|
if (probed.ok) {
|
|
24065
24081
|
mcpAuthState.noteOk(name);
|
|
24066
24082
|
mcpHealth.noteOk(name);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// One browser-allowed probe per resolved connection. Bot MCP sessions remain
|
|
2
|
+
// independent: sharing those would misroute server-initiated requests.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { probeMcpServer } from "./mcp-probe.js";
|
|
5
|
+
import { MCP_SIGN_IN_TIMEOUT_MS } from "./mcp-remote-bridge.js";
|
|
6
|
+
const cancelled = () => ({ ok: false, error: "Connection test was cancelled." });
|
|
7
|
+
function connectionKey(spec) {
|
|
8
|
+
// Include every resolved argument and credential value, never just the
|
|
9
|
+
// display name/URL. Only a digest is retained as the map key, never logged.
|
|
10
|
+
return createHash("sha256").update(JSON.stringify(spec, (_key, value) => {
|
|
11
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
12
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
})).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
export function createInteractiveMcpProbe(probe = probeMcpServer) {
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
const run = (spec, signal) => {
|
|
20
|
+
if (signal?.aborted)
|
|
21
|
+
return Promise.resolve(cancelled());
|
|
22
|
+
const key = connectionKey(spec);
|
|
23
|
+
let entry = pending.get(key);
|
|
24
|
+
if (!entry) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
entry = { controller, result: Promise.resolve().then(() => probe(spec, MCP_SIGN_IN_TIMEOUT_MS, controller.signal)), callers: 0 };
|
|
27
|
+
pending.set(key, entry);
|
|
28
|
+
const current = entry;
|
|
29
|
+
const clear = () => { if (pending.get(key) === current)
|
|
30
|
+
pending.delete(key); };
|
|
31
|
+
void current.result.then(clear, clear);
|
|
32
|
+
}
|
|
33
|
+
const current = entry;
|
|
34
|
+
current.callers += 1;
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let settled = false;
|
|
37
|
+
const release = () => {
|
|
38
|
+
if (settled)
|
|
39
|
+
return false;
|
|
40
|
+
settled = true;
|
|
41
|
+
signal?.removeEventListener("abort", abort);
|
|
42
|
+
current.callers -= 1;
|
|
43
|
+
if (!current.callers && pending.get(key) === current) {
|
|
44
|
+
pending.delete(key);
|
|
45
|
+
current.controller.abort();
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
const abort = () => { if (release())
|
|
50
|
+
resolve(cancelled()); };
|
|
51
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
52
|
+
void current.result.then((result) => { if (release())
|
|
53
|
+
resolve(result); }, (error) => { if (release())
|
|
54
|
+
reject(error); });
|
|
55
|
+
if (signal?.aborted)
|
|
56
|
+
abort();
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
return Object.assign(run, { has: (spec) => pending.has(connectionKey(spec)) });
|
|
60
|
+
}
|
|
@@ -19,6 +19,7 @@ import { basename, dirname } from "node:path";
|
|
|
19
19
|
/** The bundled bridge's exit code when it needed a browser sign-in and was
|
|
20
20
|
* told not to open one (EX_NOPERM). */
|
|
21
21
|
export const MCP_SIGN_IN_REQUIRED_EXIT = 77;
|
|
22
|
+
export const MCP_SIGN_IN_TIMEOUT_MS = 300_000;
|
|
22
23
|
/** Set on a bridge's environment: never open a browser, exit instead. */
|
|
23
24
|
export const MCP_NO_BROWSER_ENV = "ATOMIC_MCP_NO_BROWSER";
|
|
24
25
|
/** The line the bundled bridge writes to stderr before that exit. */
|
|
@@ -63,6 +64,13 @@ export function bridgeMcpRemote(spec, options) {
|
|
|
63
64
|
const target = mcpRemoteTarget(spec);
|
|
64
65
|
if (!target)
|
|
65
66
|
return spec;
|
|
67
|
+
// A stable redirect host prevents saved OAuth clients becoming invalid when
|
|
68
|
+
// an upstream/platform default changes. Preserve an explicitly configured host.
|
|
69
|
+
const extraArgs = target.extraArgs.includes("--host")
|
|
70
|
+
? [...target.extraArgs]
|
|
71
|
+
: [...target.extraArgs, "--host", "localhost"];
|
|
72
|
+
if (!extraArgs.includes("--auth-timeout"))
|
|
73
|
+
extraArgs.push("--auth-timeout", String(MCP_SIGN_IN_TIMEOUT_MS / 1_000));
|
|
66
74
|
const noBrowser = options.allowBrowser ? {} : { [MCP_NO_BROWSER_ENV]: "1" };
|
|
67
75
|
const env = { ...spec.env };
|
|
68
76
|
// A stale flag from an older start must not outlive a person's click.
|
|
@@ -71,16 +79,15 @@ export function bridgeMcpRemote(spec, options) {
|
|
|
71
79
|
// A bundled entry keeps its own script: the desktop app already repoints
|
|
72
80
|
// it at the current Resources copy on launch (electron/chibitek-defaults.mjs).
|
|
73
81
|
if (target.bundled)
|
|
74
|
-
return { ...spec, env: { ...env, ...noBrowser } };
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// drop a server the person added.
|
|
82
|
+
return { ...spec, args: [spec.args[0], target.url, ...extraArgs], env: { ...env, ...noBrowser } };
|
|
83
|
+
// Keep the redirect stable even in a dev checkout without a built bridge.
|
|
84
|
+
// The unbundled command retains its existing browser behavior.
|
|
78
85
|
if (!options.bridge)
|
|
79
|
-
return spec;
|
|
86
|
+
return { ...spec, args: [...spec.args.slice(0, spec.args.length - target.extraArgs.length), ...extraArgs] };
|
|
80
87
|
return {
|
|
81
88
|
...spec,
|
|
82
89
|
command: options.execPath,
|
|
83
|
-
args: [options.bridge, target.url, ...
|
|
90
|
+
args: [options.bridge, target.url, ...extraArgs],
|
|
84
91
|
env: { ...env, ELECTRON_RUN_AS_NODE: "1", ...noBrowser },
|
|
85
92
|
};
|
|
86
93
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Atomic Engineering, 2026-10-31. Harness-owned Plan tool policy.
|
|
2
|
+
// Unknown tools, MCP annotations and shell command strings are never trusted.
|
|
3
|
+
export const PLAN_READ_TOOLS = ["Read", "Glob", "Grep"];
|
|
4
|
+
export const PLAN_REFUSAL = "Plan mode blocked this action. Review the plan and approve it in Atomic before making changes.";
|
|
5
|
+
export function planToolAllowed(tool) {
|
|
6
|
+
return typeof tool === "string" && PLAN_READ_TOOLS.includes(tool);
|
|
7
|
+
}
|
|
8
|
+
export function claudePlanHook(node, helper) {
|
|
9
|
+
const quote = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
|
10
|
+
const command = process.platform === "win32"
|
|
11
|
+
? '"%OMB_PLAN_NODE%" "%OMB_PLAN_HOOK%" || exit /b 2'
|
|
12
|
+
: `${quote(node)} ${quote(helper)} || exit 2`;
|
|
13
|
+
return [{ matcher: "", hooks: [{ type: "command", command, timeout: 5 }] }];
|
|
14
|
+
}
|
|
@@ -44,6 +44,7 @@ export const SPAWNED_PROXIES = {
|
|
|
44
44
|
hook: resolveProxy("hooks/omb-hook"),
|
|
45
45
|
// the RTK add-on's Claude PreToolUse hook (bypassPermissions turns only)
|
|
46
46
|
rtkHook: resolveProxy("hooks/rtk-hook"),
|
|
47
|
+
planHook: resolveProxy("hooks/plan-hook"),
|
|
47
48
|
// Loaded by the external `pi` process via `-e`, not by this server — but
|
|
48
49
|
// resolved through the same single source of truth so the packaged layout
|
|
49
50
|
// check can assert it ships.
|
|
@@ -2618,16 +2618,16 @@ export class Store {
|
|
|
2618
2618
|
return task;
|
|
2619
2619
|
}
|
|
2620
2620
|
/** One durable write: never leave only part of a bot's threads updated. */
|
|
2621
|
-
setAllThreadApprovalMode(botId, mode) {
|
|
2621
|
+
setAllThreadApprovalMode(botId, mode, preservePlan = false) {
|
|
2622
2622
|
const bot = this.bot(botId);
|
|
2623
2623
|
if (!bot)
|
|
2624
2624
|
return null;
|
|
2625
2625
|
const patch = { approvalMode: mode, autoApprove: false, alwaysAllow: [] };
|
|
2626
|
-
const tasks = (bot.tasks ?? []).map(task => ({ ...task, ...patch })
|
|
2627
|
-
const next = { ...bot, ...patch, approvalGrant: undefined, tasks };
|
|
2626
|
+
const tasks = (bot.tasks ?? []).map(task => preservePlan && approvalModeFor(this.projectBotForTask(botId, task.threadId)) === "plan" ? { ...task } : { ...task, ...patch });
|
|
2627
|
+
const next = { ...bot, ...(preservePlan && approvalModeFor(bot) === "plan" ? {} : patch), approvalGrant: undefined, tasks };
|
|
2628
2628
|
this.saveBots(this.bots.map(candidate => candidate === bot ? next : candidate));
|
|
2629
2629
|
bot.tasks?.forEach((task, index) => Object.assign(task, tasks[index]));
|
|
2630
|
-
Object.assign(bot,
|
|
2630
|
+
Object.assign(bot, { ...next, tasks: bot.tasks });
|
|
2631
2631
|
this.emit({ type: "bot", botId });
|
|
2632
2632
|
return bot;
|
|
2633
2633
|
}
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* permission mode, passed through: OpenMausBot never decides a permission
|
|
3
3
|
* itself (Full access aside, which answers residual prompts because that is
|
|
4
4
|
* what the person granted). The order is the order the selector shows. */
|
|
5
|
-
export const APPROVAL_MODES = ["ask", "edits", "auto", "full", "custom"];
|
|
5
|
+
export const APPROVAL_MODES = ["ask", "edits", "auto", "full", "custom", "plan"];
|
|
6
6
|
/** Only providers with an implemented permission mapping may expose a level.
|
|
7
7
|
* `edits` (auto-accept edits) exists where the engine has such a mode:
|
|
8
8
|
* Claude and Grok `acceptEdits`, Antigravity `auto_edit`, Qwen `auto-edit`,
|
|
9
9
|
* Gemini `auto_edit`, Mistral Vibe `accept-edits`. Codex's Ask already runs `workspace-write`, so an edits
|
|
10
10
|
* level would change nothing there. */
|
|
11
11
|
export function supportsApprovalMode(driverKind, mode) {
|
|
12
|
+
if (mode === "plan")
|
|
13
|
+
return ["atomicEngine", "codex", "claudeAgent", "grok", "openai-compat", "minimax", "mistral", "ollamaCloud"].includes(driverKind ?? "");
|
|
12
14
|
if (mode === "custom")
|
|
13
15
|
return driverKind === "codex";
|
|
14
16
|
if (mode === "edits")
|
|
@@ -127,7 +127,7 @@ export function engineBrand(engine) {
|
|
|
127
127
|
const name = engine.managed?.organizationName?.trim() || (generic(engine.displayName) ? "OpenAI-compatible endpoint" : engine.displayName.trim());
|
|
128
128
|
return { key: `endpoint:${name.toLowerCase()}`, name, group: "flatPlans" };
|
|
129
129
|
}
|
|
130
|
-
if (engine.driverKind
|
|
130
|
+
if (["customAcp", "atomicEngine"].includes(engine.driverKind)) {
|
|
131
131
|
if (engine.instanceId === ATOMIC_ENGINE_ID || /^atomic engine$/i.test(engine.displayName.trim()))
|
|
132
132
|
return { key: "atomic-engine", name: "Atomic Engine", group: "atomic" };
|
|
133
133
|
const name = generic(engine.displayName) ? "Custom agent" : engine.displayName.trim();
|