@mono-agent/agent-app 0.8.0 → 0.9.0
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/README.md +71 -22
- package/dist/account-home.d.ts +6 -0
- package/dist/account-home.d.ts.map +1 -0
- package/dist/account-home.js +9 -0
- package/dist/account-home.js.map +1 -0
- package/dist/app-config.d.ts +3 -1
- package/dist/app-config.d.ts.map +1 -1
- package/dist/app-config.js +8 -5
- package/dist/app-config.js.map +1 -1
- package/dist/app.d.ts +25 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +237 -16
- package/dist/app.js.map +1 -1
- package/dist/background-environment.d.ts +16 -0
- package/dist/background-environment.d.ts.map +1 -0
- package/dist/background-environment.js +68 -0
- package/dist/background-environment.js.map +1 -0
- package/dist/background-runtime.d.ts +62 -0
- package/dist/background-runtime.d.ts.map +1 -0
- package/dist/background-runtime.js +1115 -0
- package/dist/background-runtime.js.map +1 -0
- package/dist/background-snapshot-key.d.ts +13 -0
- package/dist/background-snapshot-key.d.ts.map +1 -0
- package/dist/background-snapshot-key.js +194 -0
- package/dist/background-snapshot-key.js.map +1 -0
- package/dist/background-snapshot.d.ts +96 -0
- package/dist/background-snapshot.d.ts.map +1 -0
- package/dist/background-snapshot.js +450 -0
- package/dist/background-snapshot.js.map +1 -0
- package/dist/background-worker-lease.d.ts +39 -0
- package/dist/background-worker-lease.d.ts.map +1 -0
- package/dist/background-worker-lease.js +356 -0
- package/dist/background-worker-lease.js.map +1 -0
- package/dist/background.d.ts +74 -2
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js +815 -73
- package/dist/background.js.map +1 -1
- package/dist/channel-plugins.d.ts +2 -0
- package/dist/channel-plugins.d.ts.map +1 -1
- package/dist/channel-plugins.js +8 -0
- package/dist/channel-plugins.js.map +1 -1
- package/dist/channels.d.ts +10 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +71 -3
- package/dist/channels.js.map +1 -1
- package/dist/cli.d.ts +12 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +341 -63
- package/dist/cli.js.map +1 -1
- package/dist/config-reference.d.ts.map +1 -1
- package/dist/config-reference.js +253 -0
- package/dist/config-reference.js.map +1 -1
- package/dist/configuration-proposal-tool.d.ts +7 -0
- package/dist/configuration-proposal-tool.d.ts.map +1 -1
- package/dist/configuration-proposal-tool.js +55 -6
- package/dist/configuration-proposal-tool.js.map +1 -1
- package/dist/configured-agent.d.ts +4 -0
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +15 -0
- package/dist/configured-agent.js.map +1 -1
- package/dist/continuation-command.d.ts +15 -0
- package/dist/continuation-command.d.ts.map +1 -0
- package/dist/continuation-command.js +170 -0
- package/dist/continuation-command.js.map +1 -0
- package/dist/continuation-config.d.ts +25 -0
- package/dist/continuation-config.d.ts.map +1 -0
- package/dist/continuation-config.js +236 -0
- package/dist/continuation-config.js.map +1 -0
- package/dist/continuation-service.d.ts +99 -0
- package/dist/continuation-service.d.ts.map +1 -0
- package/dist/continuation-service.js +1313 -0
- package/dist/continuation-service.js.map +1 -0
- package/dist/continuation-store.d.ts +104 -0
- package/dist/continuation-store.d.ts.map +1 -0
- package/dist/continuation-store.js +688 -0
- package/dist/continuation-store.js.map +1 -0
- package/dist/continuations.d.ts +167 -0
- package/dist/continuations.d.ts.map +1 -0
- package/dist/continuations.js +71 -0
- package/dist/continuations.js.map +1 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +426 -27
- package/dist/doctor.js.map +1 -1
- package/dist/first-run-managed-memory.d.ts.map +1 -1
- package/dist/first-run-managed-memory.js +66 -3
- package/dist/first-run-managed-memory.js.map +1 -1
- package/dist/first-run-readiness.d.ts.map +1 -1
- package/dist/first-run-readiness.js +2 -31
- package/dist/first-run-readiness.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +7 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +7 -2
- package/dist/init.js.map +1 -1
- package/dist/launchd.d.ts +16 -6
- package/dist/launchd.d.ts.map +1 -1
- package/dist/launchd.js +30 -15
- package/dist/launchd.js.map +1 -1
- package/dist/local-configuration.d.ts +61 -18
- package/dist/local-configuration.d.ts.map +1 -1
- package/dist/local-configuration.js +530 -145
- package/dist/local-configuration.js.map +1 -1
- package/dist/managed-runtime-packages.d.ts +9 -0
- package/dist/managed-runtime-packages.d.ts.map +1 -0
- package/dist/managed-runtime-packages.js +56 -0
- package/dist/managed-runtime-packages.js.map +1 -0
- package/dist/memory-embedding-service.d.ts +33 -0
- package/dist/memory-embedding-service.d.ts.map +1 -0
- package/dist/memory-embedding-service.js +207 -0
- package/dist/memory-embedding-service.js.map +1 -0
- package/dist/memory-recall.d.ts +5 -4
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +21 -8
- package/dist/memory-recall.js.map +1 -1
- package/dist/modules/catalog.d.ts.map +1 -1
- package/dist/modules/catalog.js +91 -9
- package/dist/modules/catalog.js.map +1 -1
- package/dist/notify-runtime.d.ts +26 -0
- package/dist/notify-runtime.d.ts.map +1 -0
- package/dist/notify-runtime.js +27 -0
- package/dist/notify-runtime.js.map +1 -0
- package/dist/notify-tool.d.ts +51 -0
- package/dist/notify-tool.d.ts.map +1 -0
- package/dist/notify-tool.js +182 -0
- package/dist/notify-tool.js.map +1 -0
- package/dist/proactive-notify.d.ts +2 -0
- package/dist/proactive-notify.d.ts.map +1 -1
- package/dist/proactive-notify.js +1 -0
- package/dist/proactive-notify.js.map +1 -1
- package/dist/process-incarnation.d.ts +23 -0
- package/dist/process-incarnation.d.ts.map +1 -0
- package/dist/process-incarnation.js +208 -0
- package/dist/process-incarnation.js.map +1 -0
- package/dist/project-skills.d.ts +1 -1
- package/dist/project-skills.js +6 -6
- package/dist/recipes/base.d.ts +15 -0
- package/dist/recipes/base.d.ts.map +1 -0
- package/dist/recipes/base.js +51 -0
- package/dist/recipes/base.js.map +1 -0
- package/dist/recipes/catalog.d.ts +4 -0
- package/dist/recipes/catalog.d.ts.map +1 -0
- package/dist/recipes/catalog.js +525 -0
- package/dist/recipes/catalog.js.map +1 -0
- package/dist/recipes/index.d.ts +11 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +14 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/types.d.ts +70 -0
- package/dist/recipes/types.d.ts.map +1 -0
- package/dist/recipes/types.js +15 -0
- package/dist/recipes/types.js.map +1 -0
- package/dist/sandbox-manager.d.ts +17 -0
- package/dist/sandbox-manager.d.ts.map +1 -1
- package/dist/sandbox-manager.js +673 -127
- package/dist/sandbox-manager.js.map +1 -1
- package/dist/setup.d.ts +29 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +97 -0
- package/dist/setup.js.map +1 -0
- package/dist/supermemory-plugin.d.ts +5 -3
- package/dist/supermemory-plugin.d.ts.map +1 -1
- package/dist/supermemory-plugin.js +11 -5
- package/dist/supermemory-plugin.js.map +1 -1
- package/dist/tui-command.d.ts +43 -2
- package/dist/tui-command.d.ts.map +1 -1
- package/dist/tui-command.js +261 -27
- package/dist/tui-command.js.map +1 -1
- package/dist/wizard/answers.d.ts +4 -4
- package/dist/wizard/answers.d.ts.map +1 -1
- package/dist/wizard/answers.js +4 -21
- package/dist/wizard/answers.js.map +1 -1
- package/dist/wizard/run.d.ts.map +1 -1
- package/dist/wizard/run.js +162 -5
- package/dist/wizard/run.js.map +1 -1
- package/package.json +15 -15
- package/schema/mono-agent.config.schema.json +206 -3
- package/skills/mono-agent-composer/SKILL.md +4 -4
- package/skills/mono-agent-composer/references/config-blueprint.md +6 -6
- package/skills/mono-agent-composer/references/discovery-questions.md +25 -10
- package/skills/mono-agent-composer/references/feature-coverage.md +7 -7
- package/skills/mono-agent-composer/references/package-map.md +1 -1
- package/skills/mono-agent-composer/references/validation.md +2 -2
|
@@ -1,104 +1,280 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, } from "node:fs";
|
|
3
|
-
import { chmod, lstat, mkdir,
|
|
3
|
+
import { chmod, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { CONFIG_ENV_KEYS, readMonoAgentConfigJson, } from "@mono-agent/config";
|
|
7
7
|
import { createToolPolicy } from "@mono-agent/agent-harness";
|
|
8
8
|
import { loadAppCoreConfig } from "./app-config.js";
|
|
9
|
+
import { ADAPTER_SEND_TOOLS_MCP_SERVER_NAME } from "./adapter-send-tools.js";
|
|
10
|
+
import { captureBackgroundSnapshot, } from "./background-snapshot.js";
|
|
9
11
|
import { createConfiguredAgentResponder } from "./configured-agent.js";
|
|
10
|
-
import { CONFIGURATION_PROPOSAL_MCP_SERVER_NAME, CONFIGURATION_PROPOSAL_TOOL_NAME, configurationProposalMcpServerSpec, } from "./configuration-proposal-tool.js";
|
|
12
|
+
import { CONFIGURATION_PROPOSAL_MCP_SERVER_NAME, CONFIGURATION_PROPOSAL_TOOL_NAME, configurationProposalMcpServerSpec, containsUnsafeConfigurationReviewControl, } from "./configuration-proposal-tool.js";
|
|
11
13
|
import { validateMonoAgentFolder } from "./doctor.js";
|
|
12
|
-
|
|
14
|
+
import { ADAPTER_SEND_TOOL_NAMES, canonicalToolName } from "./modules/known-tools.js";
|
|
15
|
+
import { RUN_HISTORY_MCP_SERVER_NAME, RUN_HISTORY_TOOL_NAME } from "./run-history.js";
|
|
16
|
+
import { configuredRuntimeFallbackModels } from "./runtime-routes.js";
|
|
17
|
+
export const LOCAL_CONFIGURATION_PROMPT = "Begin temporary post-wizard configuration mode. This is not an ordinary chat. Read the mono-agent-configure skill. Your first assistant message must explain that this short conversation is for discussing the agent's Role (the configured identity document → ## Role; normally IDENTITY.md → ## Role), behavior, memory, skills, tools, or channels; secrets must never be entered; nothing changes without a separate host-owned approval; the operator can reply done or no changes to finish without edits; and ordinary chat starts after the operator's reply and any approval or rejection. Mention that /quit closes only the console and never sends a background stop request; the agent remains running unless a later restart or recovery explicitly reports failure. Then ask one concise question: how would the operator like to configure you further? Do not repeat the setup wizard. After the operator's single reply, either record one constrained proposal with ProposeAgentConfiguration or explain that no changes are needed. Never claim a proposal was applied: the local host validates it and asks for separate approval.";
|
|
18
|
+
export const LOCAL_CONFIGURATION_OPERATOR_PROMPT = "Continue the temporary post-wizard configuration exchange after the opening invitation. The invitation was already shown in a separate provider conversation: do not repeat it and do not ask the opening question again. Read the mono-agent-configure skill, act on the operator's one reply, and either record one constrained proposal with ProposeAgentConfiguration or clearly state that no changes are proposed so the host can hand off to ordinary chat. Never ask for secrets or claim a proposal was applied.";
|
|
13
19
|
const CONFIGURATION_READ_ONLY_TOOLS = [
|
|
14
20
|
"ReadSkill",
|
|
15
21
|
"MemoryRecall",
|
|
16
22
|
CONFIGURATION_PROPOSAL_TOOL_NAME,
|
|
17
23
|
`mcp__${CONFIGURATION_PROPOSAL_MCP_SERVER_NAME}__${CONFIGURATION_PROPOSAL_TOOL_NAME}`,
|
|
18
24
|
];
|
|
19
|
-
/** Build a current-folder responder
|
|
25
|
+
/** Build a current-folder responder for ordinary embedded chat only. */
|
|
20
26
|
export async function createLocalConfigurationSession(options) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
cancel: (conversationId, reason) => active.cancel?.(conversationId, reason),
|
|
35
|
-
deliverVerbatim: async (conversationId, text) => await active.deliverVerbatim?.(conversationId, text),
|
|
27
|
+
if (options.configure === true) {
|
|
28
|
+
throw new Error("Temporary configuration must attach to the authoritative background agent; use `mono-agent tui --configure` without `--local`.");
|
|
29
|
+
}
|
|
30
|
+
const authenticated = await authenticatedLocalConfig(options.cwd, options.configPath);
|
|
31
|
+
const secureOptions = { ...options, ...authenticated };
|
|
32
|
+
const config = await loadAppCoreConfig(secureOptions);
|
|
33
|
+
const responder = await createConfiguredAgentResponder({ config });
|
|
34
|
+
return {
|
|
35
|
+
responder,
|
|
36
|
+
title: config.agent?.name ?? "Mono Agent",
|
|
37
|
+
async dispose() {
|
|
38
|
+
await responder.dispose?.();
|
|
39
|
+
},
|
|
36
40
|
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Create the local host controller for a remote configuration conversation.
|
|
44
|
+
* The daemon only proposes; this process validates, confirms, writes, restarts,
|
|
45
|
+
* proves readiness, and rolls back on failure.
|
|
46
|
+
*/
|
|
47
|
+
export async function createRemoteConfigurationSession(options) {
|
|
48
|
+
// `runTui` reconstructs the managed worker's dotenv-plus-operational
|
|
49
|
+
// environment once, before discovery and launchd/source verification. Reuse
|
|
50
|
+
// that exact snapshot here: a second dotenv read would open a TOCTOU window
|
|
51
|
+
// and could make proposal validation/restart authority disagree with the
|
|
52
|
+
// already-proven background worker.
|
|
53
|
+
const manager = await LocalConfigurationManager.create(options);
|
|
37
54
|
return {
|
|
38
|
-
responder: proxy,
|
|
39
|
-
title: manager.currentConfig.agent?.name ?? "Mono Agent",
|
|
40
55
|
configuration: {
|
|
41
|
-
|
|
56
|
+
get sessionId() {
|
|
57
|
+
return manager.sessionId;
|
|
58
|
+
},
|
|
59
|
+
conversationId: `tui-configuration-${randomUUID()}`,
|
|
60
|
+
roleLocation: manager.roleLocation,
|
|
61
|
+
initialPrompt: LOCAL_CONFIGURATION_PROMPT,
|
|
42
62
|
prompt: LOCAL_CONFIGURATION_PROMPT,
|
|
63
|
+
operatorPrompt: LOCAL_CONFIGURATION_OPERATOR_PROMPT,
|
|
43
64
|
takeProposal: async () => {
|
|
44
65
|
let proposal;
|
|
45
66
|
try {
|
|
46
67
|
proposal = await manager.takeProposal();
|
|
47
68
|
}
|
|
48
69
|
catch (error) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
const rotationWarning = await rotateAttemptSafely(manager, options.beforeRotateAttempt);
|
|
71
|
+
if (rotationWarning !== undefined) {
|
|
72
|
+
throw new Error(`${reasonOf(error)} ${rotationWarning}`);
|
|
73
|
+
}
|
|
53
74
|
throw error;
|
|
54
75
|
}
|
|
55
76
|
if (proposal === undefined) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// fresh request-scoped server and removes the tool before ordinary
|
|
60
|
-
// turns. The stable proxy keeps the TUI connection unchanged.
|
|
61
|
-
await replaceActiveResponder();
|
|
77
|
+
const rotationWarning = await rotateAttemptSafely(manager, options.beforeRotateAttempt);
|
|
78
|
+
if (rotationWarning !== undefined)
|
|
79
|
+
throw new Error(rotationWarning);
|
|
62
80
|
}
|
|
63
81
|
return proposal;
|
|
64
82
|
},
|
|
65
83
|
approve: async (id) => {
|
|
66
|
-
|
|
67
|
-
let replacement;
|
|
68
|
-
try {
|
|
69
|
-
replacement = await manager.buildResponder();
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
await applied.rollback();
|
|
73
|
-
throw new Error(`The approved files were restored because the replacement responder could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
-
}
|
|
75
|
-
await activateResponder(replacement);
|
|
76
|
-
return {
|
|
77
|
-
message: `Applied configuration change ${applied.changeId}. A fresh provider conversation is now active. Rollback evidence: ${applied.rollbackDir}`,
|
|
78
|
-
};
|
|
84
|
+
return await settleConfigurationAttempt(manager, () => approveAndRestart(manager, id, options.restartBackground), options.beforeRotateAttempt);
|
|
79
85
|
},
|
|
80
86
|
reject: async (id) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
return await settleConfigurationAttempt(manager, () => manager.reject(id), options.beforeRotateAttempt);
|
|
88
|
+
},
|
|
89
|
+
abandon: async () => {
|
|
90
|
+
const rotationWarning = await rotateAttemptSafely(manager, options.beforeRotateAttempt);
|
|
91
|
+
if (rotationWarning !== undefined)
|
|
92
|
+
throw new Error(rotationWarning);
|
|
84
93
|
},
|
|
85
94
|
},
|
|
86
95
|
async dispose() {
|
|
87
|
-
await active.dispose?.();
|
|
88
96
|
await manager.dispose();
|
|
89
97
|
},
|
|
90
98
|
};
|
|
91
99
|
}
|
|
100
|
+
async function settleConfigurationAttempt(manager, task, beforeRotateAttempt) {
|
|
101
|
+
let result;
|
|
102
|
+
let taskError;
|
|
103
|
+
try {
|
|
104
|
+
result = await task();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
taskError = error;
|
|
108
|
+
}
|
|
109
|
+
const rotationWarning = await rotateAttemptSafely(manager, beforeRotateAttempt);
|
|
110
|
+
if (taskError !== undefined) {
|
|
111
|
+
if (rotationWarning !== undefined) {
|
|
112
|
+
throw new Error(`${reasonOf(taskError)} ${rotationWarning}`);
|
|
113
|
+
}
|
|
114
|
+
throw taskError;
|
|
115
|
+
}
|
|
116
|
+
if (result === undefined) {
|
|
117
|
+
throw new Error("Configuration attempt finished without a host result.");
|
|
118
|
+
}
|
|
119
|
+
return rotationWarning === undefined
|
|
120
|
+
? result
|
|
121
|
+
: { ...result, message: `${result.message} ${rotationWarning}` };
|
|
122
|
+
}
|
|
123
|
+
async function rotateAttemptSafely(manager, beforeRotateAttempt) {
|
|
124
|
+
try {
|
|
125
|
+
await beforeRotateAttempt?.();
|
|
126
|
+
await manager.rotateAttempt();
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
await manager.disableConfigurationAttempts(error);
|
|
131
|
+
return (`Warning: the completed configuration capability could not be rotated (${reasonOf(error)}). ` +
|
|
132
|
+
"Configuration re-entry is disabled in this console; close it and run `mono-agent tui --configure` again.");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function reasonOf(error) {
|
|
136
|
+
return error instanceof Error ? error.message : String(error);
|
|
137
|
+
}
|
|
138
|
+
async function approveAndRestart(manager, id, restartBackground) {
|
|
139
|
+
const applied = await manager.apply(id);
|
|
140
|
+
const attempted = await safeRestart(restartBackground, applied.snapshot);
|
|
141
|
+
if (attempted.ok) {
|
|
142
|
+
return {
|
|
143
|
+
kind: "applied",
|
|
144
|
+
connection: attempted.connection,
|
|
145
|
+
message: `Configuration applied and the background agent restarted successfully. Ordinary chat is now active. ` +
|
|
146
|
+
`Rollback evidence: ${applied.rollbackDir}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const restoredSnapshot = await applied.rollback();
|
|
151
|
+
const recovered = await safeRestart(restartBackground, restoredSnapshot);
|
|
152
|
+
if (recovered.ok) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "rolled_back",
|
|
155
|
+
connection: recovered.connection,
|
|
156
|
+
message: `The new configuration could not start, so the approved files were restored and the previous background agent was restarted. ` +
|
|
157
|
+
`Ordinary chat is now active. ${attempted.message} Rollback evidence: ${applied.rollbackDir}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
kind: "error",
|
|
162
|
+
message: `The new configuration could not start. The approved files were restored, but the previous background agent also failed to restart. ` +
|
|
163
|
+
`${attempted.message} Recovery attempt: ${recovered.message} Manual recovery is required. ` +
|
|
164
|
+
`Rollback evidence: ${applied.rollbackDir}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
kind: "error",
|
|
170
|
+
message: `The new configuration could not start, and automatic file rollback also failed: ` +
|
|
171
|
+
`${error instanceof Error ? error.message : String(error)} ${attempted.message} ` +
|
|
172
|
+
`Manual recovery is required. Rollback evidence: ${applied.rollbackDir}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function safeRestart(restart, expectedSnapshot) {
|
|
177
|
+
try {
|
|
178
|
+
return await restart(expectedSnapshot);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
message: `Background restart failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Install proposal-only authority on explicitly capability-marked TUI requests. */
|
|
188
|
+
export function createLocalConfigurationRuntimeExtension(options) {
|
|
189
|
+
return async (input) => {
|
|
190
|
+
const request = configurationRequestFromMetadata(input.request.metadata);
|
|
191
|
+
if (request === undefined)
|
|
192
|
+
return { runtimeOptions: {} };
|
|
193
|
+
const authenticated = await authenticatedLocalConfig(options.cwd, options.configPath);
|
|
194
|
+
const sessionDir = await resolveOwnedDirectoryInside(authenticated.cwd, join(authenticated.cwd, ".mono-agent", "configuration-proposals", `session-${request.sessionId}`), "Configuration proposal session");
|
|
195
|
+
const configReadPath = options.configReadPath ?? authenticated.configPath;
|
|
196
|
+
const config = await loadAppCoreConfig({
|
|
197
|
+
cwd: authenticated.cwd,
|
|
198
|
+
configPath: configReadPath,
|
|
199
|
+
env: options.env,
|
|
200
|
+
});
|
|
201
|
+
const route = configurationRuntimeRoute(config);
|
|
202
|
+
const allRoutesUseDirectCodex = route.models.every((model) => model.sdk === "codex");
|
|
203
|
+
const runtimeOptions = {
|
|
204
|
+
permissionMode: "plan",
|
|
205
|
+
...(route.modelOverride === undefined ? {} : { model: route.modelOverride }),
|
|
206
|
+
};
|
|
207
|
+
if (request.phase === "invitation") {
|
|
208
|
+
return {
|
|
209
|
+
toolPolicyOverride: createToolPolicy({
|
|
210
|
+
allowedTools: allRoutesUseDirectCodex ? ["*"] : ["ReadSkill", "MemoryRecall"],
|
|
211
|
+
disallowedTools: [],
|
|
212
|
+
mcpServers: {},
|
|
213
|
+
}),
|
|
214
|
+
runtimeOptions,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const snapshot = await readMonoAgentConfigJson(configReadPath);
|
|
218
|
+
const sinkPath = join(sessionDir, `proposal-${safeFilePart(input.runId)}.json`);
|
|
219
|
+
const proposalServer = configurationProposalMcpServerSpec({
|
|
220
|
+
sinkPath,
|
|
221
|
+
baseVersion: snapshot.version,
|
|
222
|
+
}, authenticated.cwd);
|
|
223
|
+
return {
|
|
224
|
+
toolPolicyOverride: createToolPolicy({
|
|
225
|
+
allowedTools: allRoutesUseDirectCodex ? ["*"] : CONFIGURATION_READ_ONLY_TOOLS,
|
|
226
|
+
disallowedTools: [],
|
|
227
|
+
mcpServers: {
|
|
228
|
+
[CONFIGURATION_PROPOSAL_MCP_SERVER_NAME]: proposalServer,
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
runtimeOptions,
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function configurationRuntimeRoute(config) {
|
|
236
|
+
const fallbacks = configuredRuntimeFallbackModels(config.runtime);
|
|
237
|
+
const directOpenCodeFallbacks = fallbacks.filter((model) => model.sdk === "opencode");
|
|
238
|
+
if (config.runtime.model.sdk !== "opencode") {
|
|
239
|
+
if (directOpenCodeFallbacks.length > 0) {
|
|
240
|
+
throw new Error("Temporary configuration is unavailable while the fallback chain contains direct OpenCode. " +
|
|
241
|
+
"Its provider-owned tool loop cannot receive the host-owned proposal MCP capability, so a failover could silently lose the proposal. " +
|
|
242
|
+
"Use a Pi route such as pi:opencode-go:<model>, or remove the direct opencode:* fallback before running /configure.");
|
|
243
|
+
}
|
|
244
|
+
return { models: [config.runtime.model, ...fallbacks] };
|
|
245
|
+
}
|
|
246
|
+
const supported = fallbacks.find((model) => model.sdk !== "opencode");
|
|
247
|
+
if (supported === undefined || directOpenCodeFallbacks.length > 0) {
|
|
248
|
+
throw new Error("Temporary configuration cannot use direct OpenCode's provider-owned tool loop because it cannot receive the host-owned proposal MCP capability. " +
|
|
249
|
+
"Add a proposal-capable fallback such as pi:opencode-go:<model>, or switch the primary runtime before running /configure.");
|
|
250
|
+
}
|
|
251
|
+
const remaining = fallbacks.filter((model) => model !== supported);
|
|
252
|
+
return {
|
|
253
|
+
modelOverride: supported,
|
|
254
|
+
models: [supported, ...remaining],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
92
257
|
export class LocalConfigurationManager {
|
|
93
258
|
options;
|
|
94
|
-
|
|
259
|
+
proposalRoot;
|
|
95
260
|
currentConfig;
|
|
96
|
-
|
|
261
|
+
roleLocation;
|
|
97
262
|
prepared = new Map();
|
|
98
|
-
|
|
263
|
+
cleanupPaths = new Set();
|
|
264
|
+
currentSessionId;
|
|
265
|
+
currentSessionDir;
|
|
266
|
+
disabledReason;
|
|
267
|
+
constructor(options, proposalRoot, sessionDir, sessionId, currentConfig) {
|
|
99
268
|
this.options = options;
|
|
100
|
-
this.
|
|
269
|
+
this.proposalRoot = proposalRoot;
|
|
270
|
+
this.currentSessionId = sessionId;
|
|
271
|
+
this.currentSessionDir = sessionDir;
|
|
272
|
+
this.cleanupPaths.add(sessionDir);
|
|
101
273
|
this.currentConfig = currentConfig;
|
|
274
|
+
this.roleLocation = formatRoleLocation(this.options.cwd, currentConfig.context.identityPath);
|
|
275
|
+
}
|
|
276
|
+
get sessionId() {
|
|
277
|
+
return this.currentSessionId;
|
|
102
278
|
}
|
|
103
279
|
static async create(options) {
|
|
104
280
|
const authenticated = await authenticatedLocalConfig(options.cwd, options.configPath);
|
|
@@ -108,72 +284,114 @@ export class LocalConfigurationManager {
|
|
|
108
284
|
configPath: authenticated.configPath,
|
|
109
285
|
};
|
|
110
286
|
const parent = await ensureOwnedDirectoryInside(secureOptions.cwd, join(secureOptions.cwd, ".mono-agent", "configuration-proposals"), "Configuration proposal directory");
|
|
111
|
-
const
|
|
287
|
+
const sessionId = randomUUID();
|
|
288
|
+
const sessionDir = join(parent, `session-${sessionId}`);
|
|
289
|
+
await mkdir(sessionDir, { mode: 0o700 });
|
|
112
290
|
await chmod(sessionDir, 0o700);
|
|
113
291
|
const currentConfig = await loadAppCoreConfig(secureOptions);
|
|
114
|
-
return new LocalConfigurationManager(secureOptions, sessionDir, currentConfig);
|
|
115
|
-
}
|
|
116
|
-
async buildResponder() {
|
|
117
|
-
await resolveOwnedRegularFileInside(this.options.cwd, this.options.configPath, "Config file");
|
|
118
|
-
const config = await loadAppCoreConfig(this.options);
|
|
119
|
-
return await createConfiguredAgentResponder({
|
|
120
|
-
config,
|
|
121
|
-
runtimeOptionsForRequest: async (input) => await this.runtimeExtension(input),
|
|
122
|
-
});
|
|
292
|
+
return new LocalConfigurationManager(secureOptions, parent, sessionDir, sessionId, currentConfig);
|
|
123
293
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
294
|
+
/** Revoke the completed attempt's opaque filesystem capability before issuing the next one. */
|
|
295
|
+
async rotateAttempt() {
|
|
296
|
+
this.assertAttemptsEnabled();
|
|
297
|
+
const previousDir = this.currentSessionDir;
|
|
298
|
+
const revokedPath = join(this.proposalRoot, `.revoked-${randomUUID()}`);
|
|
299
|
+
const sessionId = randomUUID();
|
|
300
|
+
const sessionDir = join(this.proposalRoot, `session-${sessionId}`);
|
|
301
|
+
try {
|
|
302
|
+
const revoked = await resolveOwnedDirectoryInside(this.options.cwd, previousDir, "Configuration proposal session");
|
|
303
|
+
// Rename first: removing the exact `session-<capability>` path revokes
|
|
304
|
+
// late model calls atomically even if recursive cleanup is interrupted.
|
|
305
|
+
await rename(revoked, revokedPath);
|
|
306
|
+
this.cleanupPaths.delete(previousDir);
|
|
307
|
+
this.cleanupPaths.add(revokedPath);
|
|
308
|
+
this.prepared.clear();
|
|
309
|
+
await mkdir(sessionDir, { mode: 0o700 });
|
|
310
|
+
this.cleanupPaths.add(sessionDir);
|
|
311
|
+
await chmod(sessionDir, 0o700);
|
|
312
|
+
this.currentSessionId = sessionId;
|
|
313
|
+
this.currentSessionDir = sessionDir;
|
|
314
|
+
// Proposal payloads are secret-rejected and owner-only; a cleanup failure
|
|
315
|
+
// cannot restore the old capability path, so do not invalidate the newly
|
|
316
|
+
// issued attempt if best-effort deletion loses a race with process exit.
|
|
317
|
+
try {
|
|
318
|
+
await rm(revokedPath, { recursive: true, force: true });
|
|
319
|
+
this.cleanupPaths.delete(revokedPath);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// The capability path is already atomically revoked. Keep the renamed
|
|
323
|
+
// artifact registered so dispose() can retry best-effort cleanup.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
this.cleanupPaths.add(previousDir);
|
|
328
|
+
this.cleanupPaths.add(revokedPath);
|
|
329
|
+
this.cleanupPaths.add(sessionDir);
|
|
330
|
+
await this.disableConfigurationAttempts(error);
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Poison the public capability id and best-effort remove every owned attempt path. */
|
|
335
|
+
async disableConfigurationAttempts(error) {
|
|
336
|
+
if (this.disabledReason === undefined) {
|
|
337
|
+
this.disabledReason = reasonOf(error);
|
|
338
|
+
}
|
|
339
|
+
this.prepared.clear();
|
|
340
|
+
const disabledId = randomUUID();
|
|
341
|
+
this.currentSessionId = disabledId;
|
|
342
|
+
// Deliberately do not create this path: future `/configure` metadata can no
|
|
343
|
+
// longer name any existing proposal capability even when old cleanup fails.
|
|
344
|
+
this.currentSessionDir = join(this.proposalRoot, `session-${disabledId}`);
|
|
345
|
+
await Promise.all([...this.cleanupPaths].map(async (path) => {
|
|
346
|
+
try {
|
|
347
|
+
await rm(path, { recursive: true, force: true });
|
|
348
|
+
this.cleanupPaths.delete(path);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Keep failed paths registered so dispose() gets one final retry.
|
|
352
|
+
}
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
assertAttemptsEnabled() {
|
|
356
|
+
if (this.disabledReason !== undefined) {
|
|
357
|
+
throw new Error(`Temporary configuration re-entry is disabled because the prior capability could not be rotated: ${this.disabledReason}`);
|
|
127
358
|
}
|
|
128
|
-
const secureConfig = await resolveOwnedRegularFileInside(this.options.cwd, this.options.configPath, "Config file");
|
|
129
|
-
const snapshot = await readMonoAgentConfigJson(secureConfig);
|
|
130
|
-
const sessionDir = await ensureOwnedDirectoryInside(this.options.cwd, this.sessionDir, "Configuration proposal session");
|
|
131
|
-
const sinkPath = join(sessionDir, `${safeFilePart(input.runId)}.json`);
|
|
132
|
-
this.pendingSinks.add(sinkPath);
|
|
133
|
-
const proposalServer = configurationProposalMcpServerSpec({
|
|
134
|
-
sinkPath,
|
|
135
|
-
baseVersion: snapshot.version,
|
|
136
|
-
}, this.options.cwd);
|
|
137
|
-
const directProviderNeedsNativeReadOnly = this.currentConfig.runtime.model.sdk === "codex"
|
|
138
|
-
|| this.currentConfig.runtime.model.sdk === "opencode";
|
|
139
|
-
return {
|
|
140
|
-
toolPolicyOverride: createToolPolicy({
|
|
141
|
-
// Direct Codex/OpenCode cannot project a finite tool list. Their native
|
|
142
|
-
// plan posture below denies mutating built-ins; replacing MCP policy
|
|
143
|
-
// still removes every configured external/action server.
|
|
144
|
-
allowedTools: directProviderNeedsNativeReadOnly ? ["*"] : CONFIGURATION_READ_ONLY_TOOLS,
|
|
145
|
-
disallowedTools: [],
|
|
146
|
-
mcpServers: {
|
|
147
|
-
[CONFIGURATION_PROPOSAL_MCP_SERVER_NAME]: proposalServer,
|
|
148
|
-
},
|
|
149
|
-
}),
|
|
150
|
-
runtimeOptions: {
|
|
151
|
-
// Native direct-provider bridges enforce read-only/deny-all action
|
|
152
|
-
// posture with this field; finite-list runtimes get both defenses.
|
|
153
|
-
permissionMode: "plan",
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
359
|
}
|
|
157
360
|
async takeProposal() {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
361
|
+
this.assertAttemptsEnabled();
|
|
362
|
+
const sessionDir = await resolveOwnedDirectoryInside(this.options.cwd, this.currentSessionDir, "Configuration proposal session");
|
|
363
|
+
const sinks = (await readdir(sessionDir))
|
|
364
|
+
.filter((name) => /^proposal-[A-Za-z0-9._-]+\.json$/u.test(name))
|
|
365
|
+
.sort();
|
|
366
|
+
if (sinks.length === 0)
|
|
367
|
+
return undefined;
|
|
368
|
+
if (sinks.length > 1) {
|
|
369
|
+
// Consume every well-formed sink name before failing closed. Otherwise a
|
|
370
|
+
// single malformed/duplicate turn would permanently poison later
|
|
371
|
+
// /configure attempts that reuse this owner-created capability session.
|
|
372
|
+
for (const name of sinks) {
|
|
373
|
+
const stale = await resolveOwnedRegularFileInside(this.options.cwd, join(sessionDir, name), "Configuration proposal payload");
|
|
374
|
+
await rm(stale);
|
|
375
|
+
}
|
|
376
|
+
throw new Error("The configuration conversation produced more than one proposal payload; nothing was applied.");
|
|
167
377
|
}
|
|
168
|
-
|
|
378
|
+
const sink = await resolveOwnedRegularFileInside(this.options.cwd, join(sessionDir, sinks[0]), "Configuration proposal payload");
|
|
379
|
+
const contents = await readFile(sink, "utf8");
|
|
380
|
+
await rm(sink);
|
|
381
|
+
return await this.prepareProposal(parseProposal(contents));
|
|
169
382
|
}
|
|
170
383
|
async reject(id) {
|
|
384
|
+
this.assertAttemptsEnabled();
|
|
171
385
|
if (!this.prepared.delete(id)) {
|
|
172
386
|
throw new Error(`Configuration proposal ${id} is no longer pending.`);
|
|
173
387
|
}
|
|
174
|
-
return {
|
|
388
|
+
return {
|
|
389
|
+
kind: "rejected",
|
|
390
|
+
message: `Proposal rejected; no files changed. Ordinary chat is now active.`,
|
|
391
|
+
};
|
|
175
392
|
}
|
|
176
393
|
async prepareProposal(proposal) {
|
|
394
|
+
this.assertAttemptsEnabled();
|
|
177
395
|
const prepared = await this.prepare(proposal);
|
|
178
396
|
this.prepared.set(proposal.id, prepared);
|
|
179
397
|
return prepared.card;
|
|
@@ -209,13 +427,22 @@ export class LocalConfigurationManager {
|
|
|
209
427
|
roleAfter = replaceRoleSection(roleBefore, proposal.role);
|
|
210
428
|
}
|
|
211
429
|
const details = proposal.patch.map(formatPatchOperation);
|
|
212
|
-
if (proposal.role !== undefined)
|
|
213
|
-
details.push(
|
|
430
|
+
if (proposal.role !== undefined && rolePath !== undefined) {
|
|
431
|
+
details.push(`replace ${formatRoleLocation(this.options.cwd, rolePath)}`);
|
|
432
|
+
}
|
|
214
433
|
const card = {
|
|
215
434
|
id: proposal.id,
|
|
216
435
|
title: "Agent configuration proposal",
|
|
217
436
|
rationale: proposal.rationale,
|
|
218
437
|
details,
|
|
438
|
+
...(proposal.role === undefined || rolePath === undefined
|
|
439
|
+
? {}
|
|
440
|
+
: {
|
|
441
|
+
role: {
|
|
442
|
+
location: formatRoleLocation(this.options.cwd, rolePath),
|
|
443
|
+
proposedBody: normalizedRoleBody(proposal.role),
|
|
444
|
+
},
|
|
445
|
+
}),
|
|
219
446
|
};
|
|
220
447
|
return {
|
|
221
448
|
proposal,
|
|
@@ -230,6 +457,7 @@ export class LocalConfigurationManager {
|
|
|
230
457
|
};
|
|
231
458
|
}
|
|
232
459
|
async apply(id) {
|
|
460
|
+
this.assertAttemptsEnabled();
|
|
233
461
|
const prepared = this.prepared.get(id);
|
|
234
462
|
if (prepared === undefined)
|
|
235
463
|
throw new Error(`Configuration proposal ${id} is no longer pending.`);
|
|
@@ -243,11 +471,12 @@ export class LocalConfigurationManager {
|
|
|
243
471
|
const rollbackDir = await ensureOwnedDirectoryInside(this.options.cwd, join(rollbackRoot, changeId), "Configuration change directory");
|
|
244
472
|
await writeFile(join(rollbackDir, "mono-agent.config.json.before"), prepared.configBefore, { flag: "wx", mode: 0o600 });
|
|
245
473
|
if (prepared.roleBefore !== undefined) {
|
|
246
|
-
await writeFile(join(rollbackDir, "
|
|
474
|
+
await writeFile(join(rollbackDir, "identity-document.before"), prepared.roleBefore, { flag: "wx", mode: 0o600 });
|
|
247
475
|
}
|
|
248
476
|
const configAfter = `${JSON.stringify(prepared.candidate, null, 2)}\n`;
|
|
249
477
|
let configWritten = false;
|
|
250
478
|
let roleWritten = false;
|
|
479
|
+
let appliedSnapshot;
|
|
251
480
|
try {
|
|
252
481
|
// Candidate validation and rollback-evidence staging both await I/O. A
|
|
253
482
|
// second comparison at the actual commit boundary prevents either step
|
|
@@ -262,7 +491,7 @@ export class LocalConfigurationManager {
|
|
|
262
491
|
// rename and before the second. If either changed, the guarded catch
|
|
263
492
|
// restores only files that still equal our exact committed bytes.
|
|
264
493
|
await assertExactOwnedContents(this.options.cwd, this.options.configPath, configAfter, "Committed config changed before the Role update");
|
|
265
|
-
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleBefore, "
|
|
494
|
+
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleBefore, "Configured identity document changed at the Role commit boundary");
|
|
266
495
|
await atomicReplaceExact(this.options.cwd, prepared.rolePath, prepared.roleBefore, prepared.roleAfter);
|
|
267
496
|
roleWritten = true;
|
|
268
497
|
}
|
|
@@ -284,6 +513,12 @@ export class LocalConfigurationManager {
|
|
|
284
513
|
changedPaths: prepared.proposal.patch.map((operation) => operation.path),
|
|
285
514
|
roleChanged: prepared.roleAfter !== undefined,
|
|
286
515
|
}, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
516
|
+
await this.options.beforeSnapshotCapture?.("apply");
|
|
517
|
+
appliedSnapshot = await this.captureCommittedSnapshot();
|
|
518
|
+
await assertExactOwnedContents(this.options.cwd, this.options.configPath, configAfter, "Committed config changed while its approved background snapshot was captured");
|
|
519
|
+
if (prepared.rolePath !== undefined && prepared.roleAfter !== undefined) {
|
|
520
|
+
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleAfter, "Committed Role changed while its approved background snapshot was captured");
|
|
521
|
+
}
|
|
287
522
|
}
|
|
288
523
|
catch (error) {
|
|
289
524
|
const recoveryErrors = [];
|
|
@@ -312,16 +547,20 @@ export class LocalConfigurationManager {
|
|
|
312
547
|
}
|
|
313
548
|
throw error;
|
|
314
549
|
}
|
|
550
|
+
if (appliedSnapshot === undefined) {
|
|
551
|
+
throw new Error("The approved configuration was not accompanied by an exact background snapshot.");
|
|
552
|
+
}
|
|
315
553
|
this.prepared.delete(id);
|
|
316
554
|
return {
|
|
317
555
|
changeId,
|
|
318
556
|
rollbackDir,
|
|
557
|
+
snapshot: appliedSnapshot,
|
|
319
558
|
rollback: async () => {
|
|
320
|
-
await withConfigurationTransactionLock(this.options.cwd, `${id}-rollback`, async () => {
|
|
559
|
+
return await withConfigurationTransactionLock(this.options.cwd, `${id}-rollback`, async () => {
|
|
321
560
|
await assertExactOwnedContents(this.options.cwd, this.options.configPath, configAfter, "Configuration changed before rollback");
|
|
322
561
|
let roleRestored = false;
|
|
323
562
|
if (prepared.rolePath !== undefined && prepared.roleAfter !== undefined) {
|
|
324
|
-
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleAfter, "
|
|
563
|
+
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleAfter, "Configured identity document changed before rollback");
|
|
325
564
|
await atomicReplaceExact(this.options.cwd, prepared.rolePath, prepared.roleAfter, prepared.roleBefore);
|
|
326
565
|
roleRestored = true;
|
|
327
566
|
}
|
|
@@ -347,12 +586,27 @@ export class LocalConfigurationManager {
|
|
|
347
586
|
changeId,
|
|
348
587
|
rolledBackAt: new Date().toISOString(),
|
|
349
588
|
}, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
589
|
+
await this.options.beforeSnapshotCapture?.("rollback");
|
|
590
|
+
const restoredSnapshot = await this.captureCommittedSnapshot();
|
|
591
|
+
await assertExactOwnedContents(this.options.cwd, this.options.configPath, prepared.configBefore, "Restored config changed while its rollback background snapshot was captured");
|
|
592
|
+
if (prepared.rolePath !== undefined && prepared.roleBefore !== undefined) {
|
|
593
|
+
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleBefore, "Restored Role changed while its rollback background snapshot was captured");
|
|
594
|
+
}
|
|
595
|
+
return restoredSnapshot;
|
|
350
596
|
});
|
|
351
597
|
},
|
|
352
598
|
};
|
|
353
599
|
}
|
|
600
|
+
async captureCommittedSnapshot() {
|
|
601
|
+
return await captureBackgroundSnapshot({
|
|
602
|
+
cwd: this.options.cwd,
|
|
603
|
+
configPath: this.options.configPath,
|
|
604
|
+
...(this.options.envFile === undefined ? {} : { envFile: this.options.envFile }),
|
|
605
|
+
env: this.options.env,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
354
608
|
async validateCandidate(candidate, label) {
|
|
355
|
-
const sessionDir = await ensureOwnedDirectoryInside(this.options.cwd, this.
|
|
609
|
+
const sessionDir = await ensureOwnedDirectoryInside(this.options.cwd, this.currentSessionDir, "Configuration proposal session");
|
|
356
610
|
const path = join(sessionDir, `${safeFilePart(label)}.candidate.json`);
|
|
357
611
|
await writeFile(path, `${JSON.stringify(candidate, null, 2)}\n`, { mode: 0o600 });
|
|
358
612
|
try {
|
|
@@ -374,14 +628,27 @@ export class LocalConfigurationManager {
|
|
|
374
628
|
}
|
|
375
629
|
}
|
|
376
630
|
async dispose() {
|
|
377
|
-
await
|
|
631
|
+
await Promise.all([...new Set([...this.cleanupPaths, this.currentSessionDir])]
|
|
632
|
+
.map((path) => rm(path, { recursive: true, force: true })));
|
|
633
|
+
this.cleanupPaths.clear();
|
|
378
634
|
}
|
|
379
635
|
}
|
|
380
636
|
export function isLocalConfigurationRequest(metadata) {
|
|
637
|
+
return configurationRequestFromMetadata(metadata) !== undefined;
|
|
638
|
+
}
|
|
639
|
+
function configurationRequestFromMetadata(metadata) {
|
|
381
640
|
const tui = metadata?.tui;
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
641
|
+
if (metadata?.source !== "tui" || typeof tui !== "object" || tui === null)
|
|
642
|
+
return undefined;
|
|
643
|
+
const record = tui;
|
|
644
|
+
const sessionId = record.configurationSessionId;
|
|
645
|
+
const phase = record.configurationPhase;
|
|
646
|
+
return record.configuration === true
|
|
647
|
+
&& (phase === "invitation" || phase === "operator")
|
|
648
|
+
&& typeof sessionId === "string"
|
|
649
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(sessionId)
|
|
650
|
+
? { sessionId, phase }
|
|
651
|
+
: undefined;
|
|
385
652
|
}
|
|
386
653
|
function parseProposal(contents) {
|
|
387
654
|
const parsed = JSON.parse(contents);
|
|
@@ -390,7 +657,8 @@ function parseProposal(contents) {
|
|
|
390
657
|
|| typeof parsed.baseVersion !== "string"
|
|
391
658
|
|| typeof parsed.rationale !== "string"
|
|
392
659
|
|| !Array.isArray(parsed.patch)
|
|
393
|
-
|| typeof parsed.createdAt !== "string"
|
|
660
|
+
|| typeof parsed.createdAt !== "string"
|
|
661
|
+
|| (parsed.role !== undefined && typeof parsed.role !== "string")) {
|
|
394
662
|
throw new Error("The configuration proposal payload was malformed.");
|
|
395
663
|
}
|
|
396
664
|
return parsed;
|
|
@@ -511,6 +779,12 @@ function cloneJson(value) {
|
|
|
511
779
|
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
512
780
|
}
|
|
513
781
|
function assertProposalContainsNoSecrets(proposal, env) {
|
|
782
|
+
if (containsUnsafeConfigurationReviewControl(proposal.rationale)) {
|
|
783
|
+
throw new Error("The proposal rationale contains unsafe terminal or bidi control characters. It was rejected.");
|
|
784
|
+
}
|
|
785
|
+
if (proposal.role !== undefined && containsUnsafeConfigurationReviewControl(proposal.role)) {
|
|
786
|
+
throw new Error("The proposed Role contains unsafe terminal or bidi control characters. It was rejected.");
|
|
787
|
+
}
|
|
514
788
|
const secretValues = Object.entries(env)
|
|
515
789
|
.filter(([name, value]) => /(?:api.?key|credential|password|secret|token)/iu.test(name) && (value?.length ?? 0) >= 4)
|
|
516
790
|
.map(([, value]) => value);
|
|
@@ -618,32 +892,133 @@ function escapeJsonPointerSegment(value) {
|
|
|
618
892
|
return value.replace(/~/gu, "~0").replace(/\//gu, "~1");
|
|
619
893
|
}
|
|
620
894
|
function toolAuthorityBroadened(before, after) {
|
|
621
|
-
const beforeAllowed =
|
|
622
|
-
const afterAllowed =
|
|
623
|
-
const beforeDenied =
|
|
624
|
-
const afterDenied =
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
895
|
+
const beforeAllowed = (before?.allowedTools ?? ["*"]).map(normalizeToolPattern);
|
|
896
|
+
const afterAllowed = (after?.allowedTools ?? ["*"]).map(normalizeToolPattern);
|
|
897
|
+
const beforeDenied = (before?.disallowedTools ?? []).map(normalizeToolPattern);
|
|
898
|
+
const afterDenied = (after?.disallowedTools ?? []).map(normalizeToolPattern);
|
|
899
|
+
return afterAllowed.some((allowed) => {
|
|
900
|
+
// A deny that covers the whole allow pattern leaves no effective authority.
|
|
901
|
+
if (afterDenied.some((denied) => toolPatternCovers(denied, allowed)))
|
|
902
|
+
return false;
|
|
903
|
+
// Conservatively require one prior allow pattern to cover the whole new
|
|
904
|
+
// pattern. Finite exact patterns cannot collectively cover a wildcard.
|
|
905
|
+
if (!beforeAllowed.some((priorAllowed) => toolPatternCovers(priorAllowed, allowed)))
|
|
906
|
+
return true;
|
|
907
|
+
// Deny-wins means every part of this allow pattern that was denied before
|
|
908
|
+
// must remain denied. This catches exact denies hidden by a new MCP server
|
|
909
|
+
// wildcard, as well as wildcard denies narrowed to exact names.
|
|
910
|
+
return beforeDenied.some((priorDenied) => {
|
|
911
|
+
if (afterDenied.some((denied) => toolPatternCovers(denied, priorDenied)))
|
|
912
|
+
return false;
|
|
913
|
+
const overlap = toolPatternIntersection(allowed, priorDenied);
|
|
914
|
+
return overlap !== undefined
|
|
915
|
+
&& !afterDenied.some((denied) => toolPatternCovers(denied, overlap));
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Tool policy values are exact names except for global `*` and the canonical
|
|
921
|
+
* MCP server wildcard `mcp__<server>__*`. Return whether every tool matched by
|
|
922
|
+
* `candidate` is also matched by `covering`.
|
|
923
|
+
*/
|
|
924
|
+
function toolPatternCovers(covering, candidate) {
|
|
925
|
+
if (covering === candidate || covering === "*")
|
|
926
|
+
return true;
|
|
927
|
+
const prefix = mcpServerWildcardPrefix(covering);
|
|
928
|
+
if (prefix === undefined)
|
|
929
|
+
return false;
|
|
930
|
+
const candidatePrefix = mcpServerWildcardPrefix(candidate);
|
|
931
|
+
return (candidatePrefix ?? candidate).startsWith(prefix)
|
|
932
|
+
|| appOwnedMcpWildcardCovers(prefix, candidate);
|
|
933
|
+
}
|
|
934
|
+
/** Return the narrower pattern when two supported tool patterns overlap. */
|
|
935
|
+
function toolPatternIntersection(left, right) {
|
|
936
|
+
if (toolPatternCovers(left, right))
|
|
937
|
+
return right;
|
|
938
|
+
if (toolPatternCovers(right, left))
|
|
939
|
+
return left;
|
|
940
|
+
if (left === "mcp__*" && isMcpAuthorityTool(right))
|
|
941
|
+
return right;
|
|
942
|
+
if (right === "mcp__*" && isMcpAuthorityTool(left))
|
|
943
|
+
return left;
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
function mcpServerWildcardPrefix(pattern) {
|
|
947
|
+
if (!pattern.startsWith("mcp__") || pattern === "mcp__*")
|
|
948
|
+
return undefined;
|
|
949
|
+
if (pattern.endsWith("__*"))
|
|
950
|
+
return pattern.slice(0, -1);
|
|
951
|
+
const serverName = pattern.slice("mcp__".length);
|
|
952
|
+
return serverName.length > 0 && !serverName.includes("__")
|
|
953
|
+
? `${pattern}__`
|
|
954
|
+
: undefined;
|
|
955
|
+
}
|
|
956
|
+
function normalizeToolPattern(pattern) {
|
|
957
|
+
const bare = canonicalToolName(pattern);
|
|
958
|
+
if (bare !== pattern)
|
|
959
|
+
return bare;
|
|
960
|
+
const qualified = mcpQualifiedTool(pattern);
|
|
961
|
+
if (qualified === undefined)
|
|
962
|
+
return pattern;
|
|
963
|
+
const canonical = canonicalToolName(qualified.tool);
|
|
964
|
+
return isAppOwnedMcpTool(qualified.server, canonical)
|
|
965
|
+
? canonical
|
|
966
|
+
: pattern;
|
|
967
|
+
}
|
|
968
|
+
function mcpQualifiedTool(pattern) {
|
|
969
|
+
if (!pattern.startsWith("mcp__") || mcpServerWildcardPrefix(pattern) !== undefined)
|
|
970
|
+
return undefined;
|
|
971
|
+
const body = pattern.slice("mcp__".length);
|
|
972
|
+
const separator = body.lastIndexOf("__");
|
|
973
|
+
if (separator <= 0 || separator >= body.length - 2)
|
|
974
|
+
return undefined;
|
|
975
|
+
return {
|
|
976
|
+
server: body.slice(0, separator),
|
|
977
|
+
tool: body.slice(separator + 2),
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function isAppOwnedMcpTool(server, tool) {
|
|
981
|
+
return server === ADAPTER_SEND_TOOLS_MCP_SERVER_NAME
|
|
982
|
+
? ADAPTER_SEND_TOOL_NAMES.some((name) => name === tool)
|
|
983
|
+
: server === RUN_HISTORY_MCP_SERVER_NAME && tool === RUN_HISTORY_TOOL_NAME;
|
|
984
|
+
}
|
|
985
|
+
function appOwnedMcpWildcardCovers(prefix, candidate) {
|
|
986
|
+
if (prefix === `mcp__${ADAPTER_SEND_TOOLS_MCP_SERVER_NAME}__`) {
|
|
987
|
+
return ADAPTER_SEND_TOOL_NAMES.some((name) => name === candidate);
|
|
988
|
+
}
|
|
989
|
+
return prefix === `mcp__${RUN_HISTORY_MCP_SERVER_NAME}__`
|
|
990
|
+
&& candidate === RUN_HISTORY_TOOL_NAME;
|
|
991
|
+
}
|
|
992
|
+
function isAppOwnedTool(candidate) {
|
|
993
|
+
return ADAPTER_SEND_TOOL_NAMES.some((name) => name === candidate)
|
|
994
|
+
|| candidate === RUN_HISTORY_TOOL_NAME;
|
|
995
|
+
}
|
|
996
|
+
function isMcpAuthorityTool(candidate) {
|
|
997
|
+
return (candidate.startsWith("mcp__") && candidate !== "mcp__*") || isAppOwnedTool(candidate);
|
|
633
998
|
}
|
|
634
999
|
function replaceRoleSection(identity, role) {
|
|
635
|
-
const body = role
|
|
636
|
-
if (
|
|
1000
|
+
const body = normalizedRoleBody(role);
|
|
1001
|
+
if (/^##\s/mu.test(body)) {
|
|
637
1002
|
throw new Error("The generated Role must be non-empty and cannot introduce another level-two Identity section.");
|
|
638
1003
|
}
|
|
639
1004
|
const match = /^## Role\s*\n([\s\S]*?)(?=\n##\s|$)/mu.exec(identity);
|
|
640
1005
|
if (match === null || match.index === undefined) {
|
|
641
|
-
throw new Error("
|
|
1006
|
+
throw new Error("The configured identity document has no canonical ## Role section to replace safely.");
|
|
642
1007
|
}
|
|
643
1008
|
const start = match.index;
|
|
644
1009
|
const end = start + match[0].length;
|
|
645
1010
|
return `${identity.slice(0, start)}## Role\n\n${body}\n${identity.slice(end).replace(/^\n*/u, "\n")}`;
|
|
646
1011
|
}
|
|
1012
|
+
function normalizedRoleBody(role) {
|
|
1013
|
+
const body = role.trim();
|
|
1014
|
+
if (body.length === 0) {
|
|
1015
|
+
throw new Error("The generated Role must be non-empty.");
|
|
1016
|
+
}
|
|
1017
|
+
if (body.length > 8_000) {
|
|
1018
|
+
throw new Error("The generated Role exceeds the 8,000-character review limit.");
|
|
1019
|
+
}
|
|
1020
|
+
return body;
|
|
1021
|
+
}
|
|
647
1022
|
function formatPatchOperation(operation) {
|
|
648
1023
|
if (operation.op === "remove")
|
|
649
1024
|
return `remove ${operation.path}`;
|
|
@@ -652,8 +1027,14 @@ function formatPatchOperation(operation) {
|
|
|
652
1027
|
if (operation.op === "test")
|
|
653
1028
|
return `verify current value at ${operation.path}`;
|
|
654
1029
|
const rendered = JSON.stringify(operation.value);
|
|
655
|
-
|
|
656
|
-
|
|
1030
|
+
return `${operation.op} ${operation.path} = ${rendered ?? ""}`;
|
|
1031
|
+
}
|
|
1032
|
+
function formatRoleLocation(cwd, identityPath) {
|
|
1033
|
+
const relativePath = relative(resolve(cwd), resolve(identityPath));
|
|
1034
|
+
const display = relativePath.length > 0 && relativePath !== ".." && !relativePath.startsWith(`..${sep}`)
|
|
1035
|
+
? relativePath
|
|
1036
|
+
: resolve(identityPath);
|
|
1037
|
+
return `${display} → ## Role`;
|
|
657
1038
|
}
|
|
658
1039
|
function pathsOverlap(left, right) {
|
|
659
1040
|
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
@@ -711,6 +1092,20 @@ async function ensureOwnedDirectoryInside(root, path, label) {
|
|
|
711
1092
|
}
|
|
712
1093
|
return current;
|
|
713
1094
|
}
|
|
1095
|
+
/** Resolve an already-created owner-only directory without creating capability ids supplied by a client. */
|
|
1096
|
+
async function resolveOwnedDirectoryInside(root, path, label) {
|
|
1097
|
+
const canonicalRoot = await realpath(resolve(root));
|
|
1098
|
+
const absolute = resolve(path);
|
|
1099
|
+
assertLexicalPathInside(canonicalRoot, absolute, label);
|
|
1100
|
+
const segments = relative(canonicalRoot, absolute).split(sep).filter((segment) => segment.length > 0);
|
|
1101
|
+
let current = canonicalRoot;
|
|
1102
|
+
await assertOwnedDirectory(current, "Current agent folder");
|
|
1103
|
+
for (const segment of segments) {
|
|
1104
|
+
current = join(current, segment);
|
|
1105
|
+
await assertOwnedDirectory(current, label);
|
|
1106
|
+
}
|
|
1107
|
+
return current;
|
|
1108
|
+
}
|
|
714
1109
|
async function assertOwnedDirectory(path, label) {
|
|
715
1110
|
assertOwnedDirectoryInfo(await lstat(path), path, label);
|
|
716
1111
|
}
|
|
@@ -751,7 +1146,7 @@ async function assertPreparedSourcesCurrent(cwd, configPath, prepared, phase) {
|
|
|
751
1146
|
const secureRole = await resolveOwnedRegularFileInside(cwd, prepared.rolePath, "Identity file");
|
|
752
1147
|
const currentRole = await readFile(secureRole, "utf8");
|
|
753
1148
|
if (sha256(currentRole) !== prepared.expectedRoleHash) {
|
|
754
|
-
throw new Error(`
|
|
1149
|
+
throw new Error(`Configured identity document changed ${phase}. Nothing was written; run /configure again.`);
|
|
755
1150
|
}
|
|
756
1151
|
}
|
|
757
1152
|
}
|
|
@@ -898,16 +1293,6 @@ function sha256(value) {
|
|
|
898
1293
|
function safeFilePart(value) {
|
|
899
1294
|
return value.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 120);
|
|
900
1295
|
}
|
|
901
|
-
async function readOptional(path) {
|
|
902
|
-
try {
|
|
903
|
-
return await readFile(path, "utf8");
|
|
904
|
-
}
|
|
905
|
-
catch (error) {
|
|
906
|
-
if (error.code === "ENOENT")
|
|
907
|
-
return undefined;
|
|
908
|
-
throw error;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
1296
|
function isRecord(value) {
|
|
912
1297
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
913
1298
|
}
|