@mono-agent/agent-app 0.6.2 → 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 +259 -19
- 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/adapter-send-proxy.d.ts +18 -0
- package/dist/adapter-send-proxy.d.ts.map +1 -0
- package/dist/adapter-send-proxy.js +229 -0
- package/dist/adapter-send-proxy.js.map +1 -0
- package/dist/adapter-send-tools-main.js +62 -4
- package/dist/adapter-send-tools-main.js.map +1 -1
- package/dist/adapter-send-tools.d.ts +33 -6
- package/dist/adapter-send-tools.d.ts.map +1 -1
- package/dist/adapter-send-tools.js +485 -87
- package/dist/adapter-send-tools.js.map +1 -1
- package/dist/app-config.d.ts +3 -1
- package/dist/app-config.d.ts.map +1 -1
- package/dist/app-config.js +16 -5
- package/dist/app-config.js.map +1 -1
- package/dist/app.d.ts +27 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +761 -147
- 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/broadcast-recorder.d.ts.map +1 -1
- package/dist/broadcast-recorder.js +24 -2
- package/dist/broadcast-recorder.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 +87 -9
- package/dist/channels.js.map +1 -1
- package/dist/cli.d.ts +86 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2421 -143
- package/dist/cli.js.map +1 -1
- package/dist/config-reference.d.ts.map +1 -1
- package/dist/config-reference.js +448 -6
- package/dist/config-reference.js.map +1 -1
- package/dist/configuration-proposal-main.d.ts +3 -0
- package/dist/configuration-proposal-main.d.ts.map +1 -0
- package/dist/configuration-proposal-main.js +13 -0
- package/dist/configuration-proposal-main.js.map +1 -0
- package/dist/configuration-proposal-tool.d.ts +33 -0
- package/dist/configuration-proposal-tool.d.ts.map +1 -0
- package/dist/configuration-proposal-tool.js +188 -0
- package/dist/configuration-proposal-tool.js.map +1 -0
- package/dist/configured-agent.d.ts +16 -0
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +165 -18
- package/dist/configured-agent.js.map +1 -1
- package/dist/consumer-contract.d.ts +3 -0
- package/dist/consumer-contract.d.ts.map +1 -1
- package/dist/consumer-contract.js +44 -1
- package/dist/consumer-contract.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 +29 -5
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +1611 -116
- package/dist/doctor.js.map +1 -1
- package/dist/first-run-managed-memory.d.ts +39 -0
- package/dist/first-run-managed-memory.d.ts.map +1 -0
- package/dist/first-run-managed-memory.js +584 -0
- package/dist/first-run-managed-memory.js.map +1 -0
- package/dist/first-run-readiness.d.ts +111 -0
- package/dist/first-run-readiness.d.ts.map +1 -0
- package/dist/first-run-readiness.js +728 -0
- package/dist/first-run-readiness.js.map +1 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +95 -3
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1218 -24
- package/dist/init.js.map +1 -1
- package/dist/interaction-bridge.d.ts +25 -1
- package/dist/interaction-bridge.d.ts.map +1 -1
- package/dist/interaction-bridge.js +281 -19
- package/dist/interaction-bridge.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 +99 -0
- package/dist/local-configuration.d.ts.map +1 -0
- package/dist/local-configuration.js +1299 -0
- package/dist/local-configuration.js.map +1 -0
- 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-command.d.ts +4 -0
- package/dist/memory-command.d.ts.map +1 -1
- package/dist/memory-command.js +751 -27
- package/dist/memory-command.js.map +1 -1
- 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 +43 -14
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +103 -33
- package/dist/memory-recall.js.map +1 -1
- package/dist/memory-retrieval.d.ts +66 -0
- package/dist/memory-retrieval.d.ts.map +1 -0
- package/dist/memory-retrieval.js +332 -0
- package/dist/memory-retrieval.js.map +1 -0
- package/dist/modules/base.d.ts +6 -5
- package/dist/modules/base.d.ts.map +1 -1
- package/dist/modules/base.js +11 -7
- package/dist/modules/base.js.map +1 -1
- package/dist/modules/catalog.d.ts.map +1 -1
- package/dist/modules/catalog.js +141 -25
- package/dist/modules/catalog.js.map +1 -1
- package/dist/modules/index.d.ts +2 -2
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/known-tools.d.ts +3 -0
- package/dist/modules/known-tools.d.ts.map +1 -1
- package/dist/modules/known-tools.js +11 -1
- package/dist/modules/known-tools.js.map +1 -1
- package/dist/modules/types.d.ts +20 -6
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/modules/types.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/package-version.d.ts +3 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +14 -0
- package/dist/package-version.js.map +1 -0
- package/dist/pi-auth-store-inspection.d.ts +21 -0
- package/dist/pi-auth-store-inspection.d.ts.map +1 -0
- package/dist/pi-auth-store-inspection.js +120 -0
- package/dist/pi-auth-store-inspection.js.map +1 -0
- package/dist/pi-oauth-login-main.d.ts +3 -0
- package/dist/pi-oauth-login-main.d.ts.map +1 -0
- package/dist/pi-oauth-login-main.js +14 -0
- package/dist/pi-oauth-login-main.js.map +1 -0
- package/dist/pi-oauth-login.d.ts +22 -0
- package/dist/pi-oauth-login.d.ts.map +1 -0
- package/dist/pi-oauth-login.js +79 -0
- package/dist/pi-oauth-login.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 +40 -0
- package/dist/project-skills.d.ts.map +1 -0
- package/dist/project-skills.js +573 -0
- package/dist/project-skills.js.map +1 -0
- package/dist/provider-setup.d.ts +117 -2
- package/dist/provider-setup.d.ts.map +1 -1
- package/dist/provider-setup.js +1505 -49
- package/dist/provider-setup.js.map +1 -1
- package/dist/readiness-probe-worker.d.ts +12 -0
- package/dist/readiness-probe-worker.d.ts.map +1 -0
- package/dist/readiness-probe-worker.js +276 -0
- package/dist/readiness-probe-worker.js.map +1 -0
- package/dist/readiness-probe.d.ts +100 -0
- package/dist/readiness-probe.d.ts.map +1 -0
- package/dist/readiness-probe.js +780 -0
- package/dist/readiness-probe.js.map +1 -0
- 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/request-model-override.d.ts +43 -1
- package/dist/request-model-override.d.ts.map +1 -1
- package/dist/request-model-override.js +178 -19
- package/dist/request-model-override.js.map +1 -1
- package/dist/run-history.d.ts +32 -0
- package/dist/run-history.d.ts.map +1 -0
- package/dist/run-history.js +859 -0
- package/dist/run-history.js.map +1 -0
- package/dist/runs-health.d.ts.map +1 -1
- package/dist/runs-health.js +12 -4
- package/dist/runs-health.js.map +1 -1
- package/dist/runtime-option-extensions.d.ts +14 -0
- package/dist/runtime-option-extensions.d.ts.map +1 -0
- package/dist/runtime-option-extensions.js +98 -0
- package/dist/runtime-option-extensions.js.map +1 -0
- package/dist/runtime-routes.d.ts +9 -0
- package/dist/runtime-routes.d.ts.map +1 -0
- package/dist/runtime-routes.js +13 -0
- package/dist/runtime-routes.js.map +1 -0
- package/dist/sandbox-manager.d.ts +86 -0
- package/dist/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox-manager.js +1402 -0
- package/dist/sandbox-manager.js.map +1 -0
- 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 +56 -0
- package/dist/supermemory-plugin.d.ts.map +1 -0
- package/dist/supermemory-plugin.js +142 -0
- package/dist/supermemory-plugin.js.map +1 -0
- package/dist/tui-command.d.ts +56 -0
- package/dist/tui-command.d.ts.map +1 -1
- package/dist/tui-command.js +305 -31
- package/dist/tui-command.js.map +1 -1
- package/dist/web-command.d.ts +8 -0
- package/dist/web-command.d.ts.map +1 -1
- package/dist/web-command.js +174 -17
- package/dist/web-command.js.map +1 -1
- package/dist/wizard/answers.d.ts +31 -4
- package/dist/wizard/answers.d.ts.map +1 -1
- package/dist/wizard/answers.js +141 -29
- package/dist/wizard/answers.js.map +1 -1
- package/dist/wizard/from-flags.d.ts +7 -0
- package/dist/wizard/from-flags.d.ts.map +1 -1
- package/dist/wizard/from-flags.js +36 -2
- package/dist/wizard/from-flags.js.map +1 -1
- package/dist/wizard/index.d.ts +4 -4
- package/dist/wizard/index.d.ts.map +1 -1
- package/dist/wizard/index.js +2 -2
- package/dist/wizard/index.js.map +1 -1
- package/dist/wizard/model-discovery.d.ts +33 -2
- package/dist/wizard/model-discovery.d.ts.map +1 -1
- package/dist/wizard/model-discovery.js +826 -108
- package/dist/wizard/model-discovery.js.map +1 -1
- package/dist/wizard/presets.d.ts +1 -1
- package/dist/wizard/presets.d.ts.map +1 -1
- package/dist/wizard/presets.js +7 -12
- package/dist/wizard/presets.js.map +1 -1
- package/dist/wizard/prompts.d.ts +56 -9
- package/dist/wizard/prompts.d.ts.map +1 -1
- package/dist/wizard/prompts.js +172 -20
- package/dist/wizard/prompts.js.map +1 -1
- package/dist/wizard/run.d.ts +59 -3
- package/dist/wizard/run.d.ts.map +1 -1
- package/dist/wizard/run.js +1290 -191
- package/dist/wizard/run.js.map +1 -1
- package/package.json +21 -16
- package/resources/srt/package-lock.json +66 -0
- package/resources/srt/package.json +9 -0
- package/schema/mono-agent.config.schema.json +587 -20
- package/skills/mono-agent-composer/SKILL.md +6 -6
- package/skills/mono-agent-composer/references/config-blueprint.md +11 -10
- package/skills/mono-agent-composer/references/discovery-questions.md +33 -18
- package/skills/mono-agent-composer/references/feature-coverage.md +14 -12
- package/skills/mono-agent-composer/references/package-map.md +2 -2
- package/skills/mono-agent-composer/references/playbooks.md +8 -7
- package/skills/mono-agent-composer/references/validation.md +3 -3
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { CONFIG_ENV_KEYS, readMonoAgentConfigJson, } from "@mono-agent/config";
|
|
7
|
+
import { createToolPolicy } from "@mono-agent/agent-harness";
|
|
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";
|
|
11
|
+
import { createConfiguredAgentResponder } from "./configured-agent.js";
|
|
12
|
+
import { CONFIGURATION_PROPOSAL_MCP_SERVER_NAME, CONFIGURATION_PROPOSAL_TOOL_NAME, configurationProposalMcpServerSpec, containsUnsafeConfigurationReviewControl, } from "./configuration-proposal-tool.js";
|
|
13
|
+
import { validateMonoAgentFolder } from "./doctor.js";
|
|
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.";
|
|
19
|
+
const CONFIGURATION_READ_ONLY_TOOLS = [
|
|
20
|
+
"ReadSkill",
|
|
21
|
+
"MemoryRecall",
|
|
22
|
+
CONFIGURATION_PROPOSAL_TOOL_NAME,
|
|
23
|
+
`mcp__${CONFIGURATION_PROPOSAL_MCP_SERVER_NAME}__${CONFIGURATION_PROPOSAL_TOOL_NAME}`,
|
|
24
|
+
];
|
|
25
|
+
/** Build a current-folder responder for ordinary embedded chat only. */
|
|
26
|
+
export async function createLocalConfigurationSession(options) {
|
|
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
|
+
},
|
|
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);
|
|
54
|
+
return {
|
|
55
|
+
configuration: {
|
|
56
|
+
get sessionId() {
|
|
57
|
+
return manager.sessionId;
|
|
58
|
+
},
|
|
59
|
+
conversationId: `tui-configuration-${randomUUID()}`,
|
|
60
|
+
roleLocation: manager.roleLocation,
|
|
61
|
+
initialPrompt: LOCAL_CONFIGURATION_PROMPT,
|
|
62
|
+
prompt: LOCAL_CONFIGURATION_PROMPT,
|
|
63
|
+
operatorPrompt: LOCAL_CONFIGURATION_OPERATOR_PROMPT,
|
|
64
|
+
takeProposal: async () => {
|
|
65
|
+
let proposal;
|
|
66
|
+
try {
|
|
67
|
+
proposal = await manager.takeProposal();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const rotationWarning = await rotateAttemptSafely(manager, options.beforeRotateAttempt);
|
|
71
|
+
if (rotationWarning !== undefined) {
|
|
72
|
+
throw new Error(`${reasonOf(error)} ${rotationWarning}`);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
if (proposal === undefined) {
|
|
77
|
+
const rotationWarning = await rotateAttemptSafely(manager, options.beforeRotateAttempt);
|
|
78
|
+
if (rotationWarning !== undefined)
|
|
79
|
+
throw new Error(rotationWarning);
|
|
80
|
+
}
|
|
81
|
+
return proposal;
|
|
82
|
+
},
|
|
83
|
+
approve: async (id) => {
|
|
84
|
+
return await settleConfigurationAttempt(manager, () => approveAndRestart(manager, id, options.restartBackground), options.beforeRotateAttempt);
|
|
85
|
+
},
|
|
86
|
+
reject: async (id) => {
|
|
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);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
async dispose() {
|
|
96
|
+
await manager.dispose();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
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
|
+
}
|
|
257
|
+
export class LocalConfigurationManager {
|
|
258
|
+
options;
|
|
259
|
+
proposalRoot;
|
|
260
|
+
currentConfig;
|
|
261
|
+
roleLocation;
|
|
262
|
+
prepared = new Map();
|
|
263
|
+
cleanupPaths = new Set();
|
|
264
|
+
currentSessionId;
|
|
265
|
+
currentSessionDir;
|
|
266
|
+
disabledReason;
|
|
267
|
+
constructor(options, proposalRoot, sessionDir, sessionId, currentConfig) {
|
|
268
|
+
this.options = options;
|
|
269
|
+
this.proposalRoot = proposalRoot;
|
|
270
|
+
this.currentSessionId = sessionId;
|
|
271
|
+
this.currentSessionDir = sessionDir;
|
|
272
|
+
this.cleanupPaths.add(sessionDir);
|
|
273
|
+
this.currentConfig = currentConfig;
|
|
274
|
+
this.roleLocation = formatRoleLocation(this.options.cwd, currentConfig.context.identityPath);
|
|
275
|
+
}
|
|
276
|
+
get sessionId() {
|
|
277
|
+
return this.currentSessionId;
|
|
278
|
+
}
|
|
279
|
+
static async create(options) {
|
|
280
|
+
const authenticated = await authenticatedLocalConfig(options.cwd, options.configPath);
|
|
281
|
+
const secureOptions = {
|
|
282
|
+
...options,
|
|
283
|
+
cwd: authenticated.cwd,
|
|
284
|
+
configPath: authenticated.configPath,
|
|
285
|
+
};
|
|
286
|
+
const parent = await ensureOwnedDirectoryInside(secureOptions.cwd, join(secureOptions.cwd, ".mono-agent", "configuration-proposals"), "Configuration proposal directory");
|
|
287
|
+
const sessionId = randomUUID();
|
|
288
|
+
const sessionDir = join(parent, `session-${sessionId}`);
|
|
289
|
+
await mkdir(sessionDir, { mode: 0o700 });
|
|
290
|
+
await chmod(sessionDir, 0o700);
|
|
291
|
+
const currentConfig = await loadAppCoreConfig(secureOptions);
|
|
292
|
+
return new LocalConfigurationManager(secureOptions, parent, sessionDir, sessionId, currentConfig);
|
|
293
|
+
}
|
|
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}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async takeProposal() {
|
|
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.");
|
|
377
|
+
}
|
|
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));
|
|
382
|
+
}
|
|
383
|
+
async reject(id) {
|
|
384
|
+
this.assertAttemptsEnabled();
|
|
385
|
+
if (!this.prepared.delete(id)) {
|
|
386
|
+
throw new Error(`Configuration proposal ${id} is no longer pending.`);
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
kind: "rejected",
|
|
390
|
+
message: `Proposal rejected; no files changed. Ordinary chat is now active.`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async prepareProposal(proposal) {
|
|
394
|
+
this.assertAttemptsEnabled();
|
|
395
|
+
const prepared = await this.prepare(proposal);
|
|
396
|
+
this.prepared.set(proposal.id, prepared);
|
|
397
|
+
return prepared.card;
|
|
398
|
+
}
|
|
399
|
+
async prepare(proposal) {
|
|
400
|
+
const secureConfig = await resolveOwnedRegularFileInside(this.options.cwd, this.options.configPath, "Config file");
|
|
401
|
+
const current = await readMonoAgentConfigJson(secureConfig);
|
|
402
|
+
if (proposal.baseVersion !== current.version) {
|
|
403
|
+
throw new Error("Configuration changed while the agent was preparing its proposal. Run /configure again from the current config.");
|
|
404
|
+
}
|
|
405
|
+
assertProposalContainsNoSecrets(proposal, this.options.env);
|
|
406
|
+
assertNoEnvironmentShadow(proposal.patch, this.options.env);
|
|
407
|
+
const candidate = applyJsonPatch(current.json, proposal.patch);
|
|
408
|
+
assertConversationalPatchAllowed(current.json, candidate);
|
|
409
|
+
const configBefore = await readFile(secureConfig, "utf8");
|
|
410
|
+
if (sha256(configBefore) !== current.version) {
|
|
411
|
+
throw new Error("Configuration changed while the agent was preparing its proposal. Run /configure again from the current config.");
|
|
412
|
+
}
|
|
413
|
+
await this.validateCandidate(candidate, proposal.id);
|
|
414
|
+
let rolePath;
|
|
415
|
+
let roleBefore;
|
|
416
|
+
let roleAfter;
|
|
417
|
+
let expectedRoleHash;
|
|
418
|
+
if (proposal.role !== undefined) {
|
|
419
|
+
if (proposal.patch.some((operation) => pathsOverlap(operation.path, "/context/identityPath"))) {
|
|
420
|
+
throw new Error("Change context.identityPath separately from a Role update so the host can verify one identity file at a time.");
|
|
421
|
+
}
|
|
422
|
+
const effective = await loadAppCoreConfig(this.options);
|
|
423
|
+
rolePath = effective.context.identityPath;
|
|
424
|
+
rolePath = await resolveOwnedRegularFileInside(this.options.cwd, rolePath, "Identity file");
|
|
425
|
+
roleBefore = await readFile(rolePath, "utf8");
|
|
426
|
+
expectedRoleHash = sha256(roleBefore);
|
|
427
|
+
roleAfter = replaceRoleSection(roleBefore, proposal.role);
|
|
428
|
+
}
|
|
429
|
+
const details = proposal.patch.map(formatPatchOperation);
|
|
430
|
+
if (proposal.role !== undefined && rolePath !== undefined) {
|
|
431
|
+
details.push(`replace ${formatRoleLocation(this.options.cwd, rolePath)}`);
|
|
432
|
+
}
|
|
433
|
+
const card = {
|
|
434
|
+
id: proposal.id,
|
|
435
|
+
title: "Agent configuration proposal",
|
|
436
|
+
rationale: proposal.rationale,
|
|
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
|
+
}),
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
proposal,
|
|
449
|
+
candidate,
|
|
450
|
+
expectedConfigVersion: current.version,
|
|
451
|
+
configBefore,
|
|
452
|
+
...(rolePath === undefined ? {} : { rolePath }),
|
|
453
|
+
...(roleBefore === undefined ? {} : { roleBefore }),
|
|
454
|
+
...(roleAfter === undefined ? {} : { roleAfter }),
|
|
455
|
+
...(expectedRoleHash === undefined ? {} : { expectedRoleHash }),
|
|
456
|
+
card,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async apply(id) {
|
|
460
|
+
this.assertAttemptsEnabled();
|
|
461
|
+
const prepared = this.prepared.get(id);
|
|
462
|
+
if (prepared === undefined)
|
|
463
|
+
throw new Error(`Configuration proposal ${id} is no longer pending.`);
|
|
464
|
+
return await withConfigurationTransactionLock(this.options.cwd, id, async () => await this.applyPrepared(id, prepared));
|
|
465
|
+
}
|
|
466
|
+
async applyPrepared(id, prepared) {
|
|
467
|
+
await assertPreparedSourcesCurrent(this.options.cwd, this.options.configPath, prepared, "after the proposal was shown");
|
|
468
|
+
await this.validateCandidate(prepared.candidate, `${id}-approval`);
|
|
469
|
+
const changeId = `${new Date().toISOString().replace(/[:.]/gu, "-")}-${id.slice(0, 8)}`;
|
|
470
|
+
const rollbackRoot = await ensureOwnedDirectoryInside(this.options.cwd, join(this.options.cwd, ".mono-agent", "config-changes"), "Configuration rollback directory");
|
|
471
|
+
const rollbackDir = await ensureOwnedDirectoryInside(this.options.cwd, join(rollbackRoot, changeId), "Configuration change directory");
|
|
472
|
+
await writeFile(join(rollbackDir, "mono-agent.config.json.before"), prepared.configBefore, { flag: "wx", mode: 0o600 });
|
|
473
|
+
if (prepared.roleBefore !== undefined) {
|
|
474
|
+
await writeFile(join(rollbackDir, "identity-document.before"), prepared.roleBefore, { flag: "wx", mode: 0o600 });
|
|
475
|
+
}
|
|
476
|
+
const configAfter = `${JSON.stringify(prepared.candidate, null, 2)}\n`;
|
|
477
|
+
let configWritten = false;
|
|
478
|
+
let roleWritten = false;
|
|
479
|
+
let appliedSnapshot;
|
|
480
|
+
try {
|
|
481
|
+
// Candidate validation and rollback-evidence staging both await I/O. A
|
|
482
|
+
// second comparison at the actual commit boundary prevents either step
|
|
483
|
+
// from opening a stale-snapshot overwrite window. The owner-only lock
|
|
484
|
+
// serializes every mono-agent writer; the comparison also catches an
|
|
485
|
+
// editor or other non-cooperating process.
|
|
486
|
+
await assertPreparedSourcesCurrent(this.options.cwd, this.options.configPath, prepared, "while the approved change was being prepared");
|
|
487
|
+
await atomicReplaceExact(this.options.cwd, this.options.configPath, prepared.configBefore, configAfter);
|
|
488
|
+
configWritten = true;
|
|
489
|
+
if (prepared.rolePath !== undefined && prepared.roleAfter !== undefined) {
|
|
490
|
+
// Config and Role are two files, so re-check both sides after the first
|
|
491
|
+
// rename and before the second. If either changed, the guarded catch
|
|
492
|
+
// restores only files that still equal our exact committed bytes.
|
|
493
|
+
await assertExactOwnedContents(this.options.cwd, this.options.configPath, configAfter, "Committed config changed before the Role update");
|
|
494
|
+
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleBefore, "Configured identity document changed at the Role commit boundary");
|
|
495
|
+
await atomicReplaceExact(this.options.cwd, prepared.rolePath, prepared.roleBefore, prepared.roleAfter);
|
|
496
|
+
roleWritten = true;
|
|
497
|
+
}
|
|
498
|
+
const verified = await readMonoAgentConfigJson(this.options.configPath);
|
|
499
|
+
if (!isDeepStrictEqual(verified.json, prepared.candidate)) {
|
|
500
|
+
throw new Error("The committed config did not verify byte-for-byte against the approved candidate.");
|
|
501
|
+
}
|
|
502
|
+
if (prepared.rolePath !== undefined && prepared.roleAfter !== undefined) {
|
|
503
|
+
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleAfter, "The committed Role did not verify");
|
|
504
|
+
}
|
|
505
|
+
await ensureOwnedDirectoryInside(this.options.cwd, rollbackDir, "Configuration change directory");
|
|
506
|
+
await writeFile(join(rollbackDir, "change.json"), `${JSON.stringify({
|
|
507
|
+
schema: "mono-agent.configuration-change.v1",
|
|
508
|
+
changeId,
|
|
509
|
+
proposalId: prepared.proposal.id,
|
|
510
|
+
appliedAt: new Date().toISOString(),
|
|
511
|
+
previousConfigVersion: prepared.expectedConfigVersion,
|
|
512
|
+
configVersion: verified.version,
|
|
513
|
+
changedPaths: prepared.proposal.patch.map((operation) => operation.path),
|
|
514
|
+
roleChanged: prepared.roleAfter !== undefined,
|
|
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
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
const recoveryErrors = [];
|
|
525
|
+
if (roleWritten && prepared.rolePath !== undefined && prepared.roleAfter !== undefined && prepared.roleBefore !== undefined) {
|
|
526
|
+
try {
|
|
527
|
+
await restoreIfExact(this.options.cwd, prepared.rolePath, prepared.roleAfter, prepared.roleBefore);
|
|
528
|
+
}
|
|
529
|
+
catch (restoreError) {
|
|
530
|
+
recoveryErrors.push(restoreError instanceof Error ? restoreError.message : String(restoreError));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (configWritten) {
|
|
534
|
+
try {
|
|
535
|
+
await restoreIfExact(this.options.cwd, this.options.configPath, configAfter, prepared.configBefore);
|
|
536
|
+
}
|
|
537
|
+
catch (restoreError) {
|
|
538
|
+
recoveryErrors.push(restoreError instanceof Error ? restoreError.message : String(restoreError));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else if (!roleWritten) {
|
|
542
|
+
await rm(rollbackDir, { recursive: true, force: true });
|
|
543
|
+
}
|
|
544
|
+
if (recoveryErrors.length > 0) {
|
|
545
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} Manual recovery is required; concurrent edits were preserved. ` +
|
|
546
|
+
`${recoveryErrors.join(" ")} Rollback evidence: ${rollbackDir}`);
|
|
547
|
+
}
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
if (appliedSnapshot === undefined) {
|
|
551
|
+
throw new Error("The approved configuration was not accompanied by an exact background snapshot.");
|
|
552
|
+
}
|
|
553
|
+
this.prepared.delete(id);
|
|
554
|
+
return {
|
|
555
|
+
changeId,
|
|
556
|
+
rollbackDir,
|
|
557
|
+
snapshot: appliedSnapshot,
|
|
558
|
+
rollback: async () => {
|
|
559
|
+
return await withConfigurationTransactionLock(this.options.cwd, `${id}-rollback`, async () => {
|
|
560
|
+
await assertExactOwnedContents(this.options.cwd, this.options.configPath, configAfter, "Configuration changed before rollback");
|
|
561
|
+
let roleRestored = false;
|
|
562
|
+
if (prepared.rolePath !== undefined && prepared.roleAfter !== undefined) {
|
|
563
|
+
await assertExactOwnedContents(this.options.cwd, prepared.rolePath, prepared.roleAfter, "Configured identity document changed before rollback");
|
|
564
|
+
await atomicReplaceExact(this.options.cwd, prepared.rolePath, prepared.roleAfter, prepared.roleBefore);
|
|
565
|
+
roleRestored = true;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
await atomicReplaceExact(this.options.cwd, this.options.configPath, configAfter, prepared.configBefore);
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
if (roleRestored && prepared.rolePath !== undefined && prepared.roleAfter !== undefined) {
|
|
572
|
+
try {
|
|
573
|
+
await atomicReplaceExact(this.options.cwd, prepared.rolePath, prepared.roleBefore, prepared.roleAfter);
|
|
574
|
+
}
|
|
575
|
+
catch (compensationError) {
|
|
576
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} ` +
|
|
577
|
+
`Role compensation also failed: ${compensationError instanceof Error ? compensationError.message : String(compensationError)} ` +
|
|
578
|
+
`Concurrent edits were preserved; manual recovery is required. Rollback evidence: ${rollbackDir}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
await ensureOwnedDirectoryInside(this.options.cwd, rollbackDir, "Configuration change directory");
|
|
584
|
+
await writeFile(join(rollbackDir, "rollback.json"), `${JSON.stringify({
|
|
585
|
+
schema: "mono-agent.configuration-rollback.v1",
|
|
586
|
+
changeId,
|
|
587
|
+
rolledBackAt: new Date().toISOString(),
|
|
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;
|
|
596
|
+
});
|
|
597
|
+
},
|
|
598
|
+
};
|
|
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
|
+
}
|
|
608
|
+
async validateCandidate(candidate, label) {
|
|
609
|
+
const sessionDir = await ensureOwnedDirectoryInside(this.options.cwd, this.currentSessionDir, "Configuration proposal session");
|
|
610
|
+
const path = join(sessionDir, `${safeFilePart(label)}.candidate.json`);
|
|
611
|
+
await writeFile(path, `${JSON.stringify(candidate, null, 2)}\n`, { mode: 0o600 });
|
|
612
|
+
try {
|
|
613
|
+
const report = await validateMonoAgentFolder({
|
|
614
|
+
cwd: this.options.cwd,
|
|
615
|
+
configPath: path,
|
|
616
|
+
env: this.options.env,
|
|
617
|
+
allowFilesystemWrites: false,
|
|
618
|
+
liveness: false,
|
|
619
|
+
});
|
|
620
|
+
const errors = report.sections.filter((section) => section.status === "error");
|
|
621
|
+
if (errors.length > 0) {
|
|
622
|
+
throw new Error(`Proposed configuration does not validate: ${errors.map((section) => `${section.label}: ${section.details.join(" ")}`).join("; ")}`);
|
|
623
|
+
}
|
|
624
|
+
await loadAppCoreConfig({ cwd: this.options.cwd, configPath: path, env: this.options.env });
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
await rm(path, { force: true });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async dispose() {
|
|
631
|
+
await Promise.all([...new Set([...this.cleanupPaths, this.currentSessionDir])]
|
|
632
|
+
.map((path) => rm(path, { recursive: true, force: true })));
|
|
633
|
+
this.cleanupPaths.clear();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
export function isLocalConfigurationRequest(metadata) {
|
|
637
|
+
return configurationRequestFromMetadata(metadata) !== undefined;
|
|
638
|
+
}
|
|
639
|
+
function configurationRequestFromMetadata(metadata) {
|
|
640
|
+
const tui = metadata?.tui;
|
|
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;
|
|
652
|
+
}
|
|
653
|
+
function parseProposal(contents) {
|
|
654
|
+
const parsed = JSON.parse(contents);
|
|
655
|
+
if (parsed.schema !== "mono-agent.configuration-proposal.v1"
|
|
656
|
+
|| typeof parsed.id !== "string"
|
|
657
|
+
|| typeof parsed.baseVersion !== "string"
|
|
658
|
+
|| typeof parsed.rationale !== "string"
|
|
659
|
+
|| !Array.isArray(parsed.patch)
|
|
660
|
+
|| typeof parsed.createdAt !== "string"
|
|
661
|
+
|| (parsed.role !== undefined && typeof parsed.role !== "string")) {
|
|
662
|
+
throw new Error("The configuration proposal payload was malformed.");
|
|
663
|
+
}
|
|
664
|
+
return parsed;
|
|
665
|
+
}
|
|
666
|
+
export function applyJsonPatch(input, operations) {
|
|
667
|
+
const root = JSON.parse(JSON.stringify(input));
|
|
668
|
+
for (const operation of operations) {
|
|
669
|
+
if (operation.path === "")
|
|
670
|
+
throw new Error("Replacing the entire config document is not allowed.");
|
|
671
|
+
const path = pointerSegments(operation.path);
|
|
672
|
+
if (operation.op === "add")
|
|
673
|
+
addValue(root, path, cloneJson(operation.value));
|
|
674
|
+
else if (operation.op === "remove")
|
|
675
|
+
removeValue(root, path);
|
|
676
|
+
else if (operation.op === "replace")
|
|
677
|
+
replaceValue(root, path, cloneJson(operation.value));
|
|
678
|
+
else if (operation.op === "test") {
|
|
679
|
+
if (!isDeepStrictEqual(getValue(root, path), operation.value)) {
|
|
680
|
+
throw new Error(`JSON Patch test failed at ${operation.path}.`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
if (operation.from === undefined)
|
|
685
|
+
throw new Error(`${operation.op} requires from.`);
|
|
686
|
+
const from = pointerSegments(operation.from);
|
|
687
|
+
const value = cloneJson(getValue(root, from));
|
|
688
|
+
if (operation.op === "move")
|
|
689
|
+
removeValue(root, from);
|
|
690
|
+
addValue(root, path, value);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return root;
|
|
694
|
+
}
|
|
695
|
+
function pointerSegments(pointer) {
|
|
696
|
+
if (!pointer.startsWith("/"))
|
|
697
|
+
throw new Error(`Invalid JSON Pointer: ${pointer}`);
|
|
698
|
+
const segments = pointer.slice(1).split("/").map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
699
|
+
for (const segment of segments) {
|
|
700
|
+
if (segment === "__proto__" || segment === "prototype" || segment === "constructor") {
|
|
701
|
+
throw new Error(`Unsafe JSON Pointer segment: ${segment}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return segments;
|
|
705
|
+
}
|
|
706
|
+
function parentAt(root, segments) {
|
|
707
|
+
if (segments.length === 0)
|
|
708
|
+
throw new Error("Root JSON Patch operations are not allowed.");
|
|
709
|
+
let current = root;
|
|
710
|
+
for (const segment of segments.slice(0, -1)) {
|
|
711
|
+
if (Array.isArray(current)) {
|
|
712
|
+
const index = arrayIndex(segment, current.length, false);
|
|
713
|
+
current = current[index];
|
|
714
|
+
}
|
|
715
|
+
else if (isRecord(current) && Object.hasOwn(current, segment)) {
|
|
716
|
+
current = current[segment];
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
throw new Error(`JSON Patch path does not exist: /${segments.join("/")}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (!Array.isArray(current) && !isRecord(current)) {
|
|
723
|
+
throw new Error(`JSON Patch parent is not a container: /${segments.join("/")}`);
|
|
724
|
+
}
|
|
725
|
+
return { parent: current, key: segments.at(-1) };
|
|
726
|
+
}
|
|
727
|
+
function addValue(root, segments, value) {
|
|
728
|
+
const { parent, key } = parentAt(root, segments);
|
|
729
|
+
if (Array.isArray(parent)) {
|
|
730
|
+
const index = key === "-" ? parent.length : arrayIndex(key, parent.length, true);
|
|
731
|
+
parent.splice(index, 0, value);
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
parent[key] = value;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function replaceValue(root, segments, value) {
|
|
738
|
+
const { parent, key } = parentAt(root, segments);
|
|
739
|
+
if (Array.isArray(parent))
|
|
740
|
+
parent[arrayIndex(key, parent.length, false)] = value;
|
|
741
|
+
else {
|
|
742
|
+
if (!Object.hasOwn(parent, key))
|
|
743
|
+
throw new Error(`JSON Patch replace target does not exist: /${segments.join("/")}`);
|
|
744
|
+
parent[key] = value;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function removeValue(root, segments) {
|
|
748
|
+
const { parent, key } = parentAt(root, segments);
|
|
749
|
+
if (Array.isArray(parent))
|
|
750
|
+
return parent.splice(arrayIndex(key, parent.length, false), 1)[0];
|
|
751
|
+
if (!Object.hasOwn(parent, key))
|
|
752
|
+
throw new Error(`JSON Patch remove target does not exist: /${segments.join("/")}`);
|
|
753
|
+
const value = parent[key];
|
|
754
|
+
delete parent[key];
|
|
755
|
+
return value;
|
|
756
|
+
}
|
|
757
|
+
function getValue(root, segments) {
|
|
758
|
+
let current = root;
|
|
759
|
+
for (const segment of segments) {
|
|
760
|
+
if (Array.isArray(current))
|
|
761
|
+
current = current[arrayIndex(segment, current.length, false)];
|
|
762
|
+
else if (isRecord(current) && Object.hasOwn(current, segment))
|
|
763
|
+
current = current[segment];
|
|
764
|
+
else
|
|
765
|
+
throw new Error(`JSON Patch path does not exist: /${segments.join("/")}`);
|
|
766
|
+
}
|
|
767
|
+
return current;
|
|
768
|
+
}
|
|
769
|
+
function arrayIndex(segment, length, allowEnd) {
|
|
770
|
+
if (!/^(?:0|[1-9]\d*)$/u.test(segment))
|
|
771
|
+
throw new Error(`Invalid JSON Patch array index: ${segment}`);
|
|
772
|
+
const index = Number(segment);
|
|
773
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > length || (!allowEnd && index === length)) {
|
|
774
|
+
throw new Error(`JSON Patch array index out of bounds: ${segment}`);
|
|
775
|
+
}
|
|
776
|
+
return index;
|
|
777
|
+
}
|
|
778
|
+
function cloneJson(value) {
|
|
779
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
780
|
+
}
|
|
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
|
+
}
|
|
788
|
+
const secretValues = Object.entries(env)
|
|
789
|
+
.filter(([name, value]) => /(?:api.?key|credential|password|secret|token)/iu.test(name) && (value?.length ?? 0) >= 4)
|
|
790
|
+
.map(([, value]) => value);
|
|
791
|
+
for (const operation of proposal.patch) {
|
|
792
|
+
if (secretBearingPointer(operation.path) || (operation.from !== undefined && secretBearingPointer(operation.from))) {
|
|
793
|
+
throw new Error("Secret-bearing config fields cannot be proposed in chat. Use the masked mono-agent auth or owner-only .env flow.");
|
|
794
|
+
}
|
|
795
|
+
if (containsSecret(operation.value, secretValues)) {
|
|
796
|
+
throw new Error("A proposal matched a configured secret value. It was rejected and will not be displayed or written.");
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (containsSecret(proposal.rationale, secretValues) || containsSecretLikeValue(proposal.rationale)) {
|
|
800
|
+
throw new Error("The proposal rationale appears to contain a secret. It was rejected.");
|
|
801
|
+
}
|
|
802
|
+
if (proposal.role !== undefined && containsSecret(proposal.role, secretValues)) {
|
|
803
|
+
throw new Error("The proposed Role matched a configured secret value. It was rejected.");
|
|
804
|
+
}
|
|
805
|
+
if (proposal.role !== undefined && containsSecretLikeValue(proposal.role)) {
|
|
806
|
+
throw new Error("The proposed Role appears to contain a secret. It was rejected.");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function secretBearingPointer(pointer) {
|
|
810
|
+
return pointerSegments(pointer).some((segment) => /(?:api.?key|credential|password|secret|token)/iu.test(segment) && !/(?:env|path)$/iu.test(segment));
|
|
811
|
+
}
|
|
812
|
+
function containsSecret(value, secrets) {
|
|
813
|
+
if (typeof value === "string")
|
|
814
|
+
return secrets.some((secret) => value.includes(secret)) || containsSecretLikeValue(value);
|
|
815
|
+
if (Array.isArray(value))
|
|
816
|
+
return value.some((entry) => containsSecret(entry, secrets));
|
|
817
|
+
if (isRecord(value)) {
|
|
818
|
+
return Object.entries(value).some(([key, entry]) => (/(?:api.?key|credential|password|secret|token)/iu.test(key) && !/(?:env|path)$/iu.test(key))
|
|
819
|
+
|| containsSecret(entry, secrets));
|
|
820
|
+
}
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
function containsSecretLikeValue(value) {
|
|
824
|
+
return /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{12,}|Bearer\s+\S{12,}|\d{6,12}:[A-Za-z0-9_-]{20,})\b/u.test(value);
|
|
825
|
+
}
|
|
826
|
+
function assertNoEnvironmentShadow(patch, env) {
|
|
827
|
+
const shadowed = Object.entries(CONFIG_ENV_KEYS).filter(([field, envKey]) => {
|
|
828
|
+
if ((env[envKey]?.trim().length ?? 0) === 0)
|
|
829
|
+
return false;
|
|
830
|
+
const pointer = `/${field.split(".").join("/")}`;
|
|
831
|
+
return patch.some((operation) => pathsOverlap(operation.path, pointer)
|
|
832
|
+
|| (operation.from !== undefined && pathsOverlap(operation.from, pointer)));
|
|
833
|
+
});
|
|
834
|
+
if (shadowed.length > 0) {
|
|
835
|
+
throw new Error(`The effective environment overrides this proposal (${shadowed.map(([field, key]) => `${field} via ${key}`).join(", ")}). ` +
|
|
836
|
+
"Update or remove the durable environment override first, then run /configure again.");
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Conversational configuration deliberately exposes a small positive surface.
|
|
841
|
+
* New schema fields therefore fail closed until they receive an explicit
|
|
842
|
+
* safety decision here; everything involving paths, providers, credentials,
|
|
843
|
+
* background work, network access, or sandbox policy stays in a guided flow.
|
|
844
|
+
*/
|
|
845
|
+
const CONVERSATIONAL_CONFIG_POINTERS = new Set([
|
|
846
|
+
"/agent/name",
|
|
847
|
+
"/runtime/effort",
|
|
848
|
+
"/runtime/maxTurns",
|
|
849
|
+
"/runtime/session/mode",
|
|
850
|
+
"/runtime/session/idleTimeoutMs",
|
|
851
|
+
"/runtime/session/rollover",
|
|
852
|
+
"/runtime/session/rolloverTimezone",
|
|
853
|
+
"/runtime/session/rolloverNotice",
|
|
854
|
+
"/context/selectedSkills",
|
|
855
|
+
"/context/skillMaxBytes",
|
|
856
|
+
"/context/skillDisclosure",
|
|
857
|
+
"/memory/maxBytes",
|
|
858
|
+
"/memory/recallTool/enabled",
|
|
859
|
+
"/tools/allowedTools",
|
|
860
|
+
"/tools/disallowedTools",
|
|
861
|
+
]);
|
|
862
|
+
function assertConversationalPatchAllowed(before, after) {
|
|
863
|
+
const changed = changedJsonPointers(before, after);
|
|
864
|
+
const unsupported = changed.filter((pointer) => !CONVERSATIONAL_CONFIG_POINTERS.has(pointer));
|
|
865
|
+
if (unsupported.length > 0) {
|
|
866
|
+
throw new Error(`Conversational configuration cannot change ${unsupported.join(", ")}. ` +
|
|
867
|
+
"Paths, memory tiers or capture cost, providers/models/routes, embeddings endpoints, credentials, channels/cron/plugins, MCP, sandbox/network policy, exporters, and other new schema fields require the explicit guided flow.");
|
|
868
|
+
}
|
|
869
|
+
if (toolAuthorityBroadened(before.tools, after.tools)) {
|
|
870
|
+
throw new Error("Broader tool authority requires explicit guided confirmation outside the model conversation.");
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function changedJsonPointers(before, after, pointer = "") {
|
|
874
|
+
if (isDeepStrictEqual(before, after))
|
|
875
|
+
return [];
|
|
876
|
+
const beforeObject = isRecord(before);
|
|
877
|
+
const afterObject = isRecord(after);
|
|
878
|
+
if ((beforeObject || before === undefined)
|
|
879
|
+
&& (afterObject || after === undefined)
|
|
880
|
+
&& (beforeObject || afterObject)) {
|
|
881
|
+
const keys = new Set([
|
|
882
|
+
...(beforeObject ? Object.keys(before) : []),
|
|
883
|
+
...(afterObject ? Object.keys(after) : []),
|
|
884
|
+
]);
|
|
885
|
+
if (keys.size === 0)
|
|
886
|
+
return [pointer || "/"];
|
|
887
|
+
return [...keys].flatMap((key) => changedJsonPointers(beforeObject ? before[key] : undefined, afterObject ? after[key] : undefined, `${pointer}/${escapeJsonPointerSegment(key)}`));
|
|
888
|
+
}
|
|
889
|
+
return [pointer || "/"];
|
|
890
|
+
}
|
|
891
|
+
function escapeJsonPointerSegment(value) {
|
|
892
|
+
return value.replace(/~/gu, "~0").replace(/\//gu, "~1");
|
|
893
|
+
}
|
|
894
|
+
function toolAuthorityBroadened(before, after) {
|
|
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);
|
|
998
|
+
}
|
|
999
|
+
function replaceRoleSection(identity, role) {
|
|
1000
|
+
const body = normalizedRoleBody(role);
|
|
1001
|
+
if (/^##\s/mu.test(body)) {
|
|
1002
|
+
throw new Error("The generated Role must be non-empty and cannot introduce another level-two Identity section.");
|
|
1003
|
+
}
|
|
1004
|
+
const match = /^## Role\s*\n([\s\S]*?)(?=\n##\s|$)/mu.exec(identity);
|
|
1005
|
+
if (match === null || match.index === undefined) {
|
|
1006
|
+
throw new Error("The configured identity document has no canonical ## Role section to replace safely.");
|
|
1007
|
+
}
|
|
1008
|
+
const start = match.index;
|
|
1009
|
+
const end = start + match[0].length;
|
|
1010
|
+
return `${identity.slice(0, start)}## Role\n\n${body}\n${identity.slice(end).replace(/^\n*/u, "\n")}`;
|
|
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
|
+
}
|
|
1022
|
+
function formatPatchOperation(operation) {
|
|
1023
|
+
if (operation.op === "remove")
|
|
1024
|
+
return `remove ${operation.path}`;
|
|
1025
|
+
if (operation.op === "move" || operation.op === "copy")
|
|
1026
|
+
return `${operation.op} ${operation.from} -> ${operation.path}`;
|
|
1027
|
+
if (operation.op === "test")
|
|
1028
|
+
return `verify current value at ${operation.path}`;
|
|
1029
|
+
const rendered = JSON.stringify(operation.value);
|
|
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`;
|
|
1038
|
+
}
|
|
1039
|
+
function pathsOverlap(left, right) {
|
|
1040
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
1041
|
+
}
|
|
1042
|
+
async function authenticatedLocalConfig(cwd, configPath) {
|
|
1043
|
+
const lexicalCwd = resolve(cwd);
|
|
1044
|
+
const lexicalConfig = resolve(configPath);
|
|
1045
|
+
assertLexicalPathInside(lexicalCwd, lexicalConfig, "Config path");
|
|
1046
|
+
const canonicalCwd = await realpath(lexicalCwd);
|
|
1047
|
+
await assertOwnedDirectory(canonicalCwd, "Current agent folder");
|
|
1048
|
+
const relativeConfig = relative(lexicalCwd, lexicalConfig);
|
|
1049
|
+
const canonicalConfig = await resolveOwnedRegularFileInside(canonicalCwd, resolve(canonicalCwd, relativeConfig), "Config file");
|
|
1050
|
+
return { cwd: canonicalCwd, configPath: canonicalConfig };
|
|
1051
|
+
}
|
|
1052
|
+
async function resolveOwnedRegularFileInside(root, path, label) {
|
|
1053
|
+
const canonicalRoot = await realpath(resolve(root));
|
|
1054
|
+
const absolute = resolve(path);
|
|
1055
|
+
assertLexicalPathInside(canonicalRoot, absolute, label);
|
|
1056
|
+
const segments = relative(canonicalRoot, absolute).split(sep).filter((segment) => segment.length > 0);
|
|
1057
|
+
if (segments.length === 0)
|
|
1058
|
+
throw new Error(`${label} must name a file inside the current agent folder.`);
|
|
1059
|
+
let parent = canonicalRoot;
|
|
1060
|
+
await assertOwnedDirectory(parent, "Current agent folder");
|
|
1061
|
+
for (const segment of segments.slice(0, -1)) {
|
|
1062
|
+
parent = join(parent, segment);
|
|
1063
|
+
await assertOwnedDirectory(parent, `${label} parent`);
|
|
1064
|
+
}
|
|
1065
|
+
const target = join(parent, segments.at(-1));
|
|
1066
|
+
const info = await lstat(target);
|
|
1067
|
+
assertOwnedRegularFileInfo(info, target, label);
|
|
1068
|
+
const canonicalTarget = await realpath(target);
|
|
1069
|
+
assertLexicalPathInside(canonicalRoot, canonicalTarget, label);
|
|
1070
|
+
if (canonicalTarget !== target) {
|
|
1071
|
+
throw new Error(`${label} must not traverse a symbolic-link parent: ${path}`);
|
|
1072
|
+
}
|
|
1073
|
+
return target;
|
|
1074
|
+
}
|
|
1075
|
+
async function ensureOwnedDirectoryInside(root, path, label) {
|
|
1076
|
+
const canonicalRoot = await realpath(resolve(root));
|
|
1077
|
+
const absolute = resolve(path);
|
|
1078
|
+
assertLexicalPathInside(canonicalRoot, absolute, label);
|
|
1079
|
+
const segments = relative(canonicalRoot, absolute).split(sep).filter((segment) => segment.length > 0);
|
|
1080
|
+
let current = canonicalRoot;
|
|
1081
|
+
await assertOwnedDirectory(current, "Current agent folder");
|
|
1082
|
+
for (const segment of segments) {
|
|
1083
|
+
current = join(current, segment);
|
|
1084
|
+
try {
|
|
1085
|
+
await mkdir(current, { mode: 0o700 });
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
if (error.code !== "EEXIST")
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
await assertOwnedDirectory(current, label);
|
|
1092
|
+
}
|
|
1093
|
+
return current;
|
|
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
|
+
}
|
|
1109
|
+
async function assertOwnedDirectory(path, label) {
|
|
1110
|
+
assertOwnedDirectoryInfo(await lstat(path), path, label);
|
|
1111
|
+
}
|
|
1112
|
+
function assertOwnedDirectoryInfo(info, path, label) {
|
|
1113
|
+
if (!info.isDirectory())
|
|
1114
|
+
throw new Error(`${label} must be a real directory, not a symbolic link: ${path}`);
|
|
1115
|
+
const uid = process.getuid?.();
|
|
1116
|
+
if (uid !== undefined && info.uid !== uid)
|
|
1117
|
+
throw new Error(`${label} must be owned by the current user: ${path}`);
|
|
1118
|
+
if ((info.mode & 0o022) !== 0)
|
|
1119
|
+
throw new Error(`${label} must not be group/world writable: ${path}`);
|
|
1120
|
+
}
|
|
1121
|
+
function assertOwnedRegularFileInfo(info, path, label) {
|
|
1122
|
+
if (!info.isFile() || info.nlink !== 1)
|
|
1123
|
+
throw new Error(`${label} must be one regular file with one link: ${path}`);
|
|
1124
|
+
const uid = process.getuid?.();
|
|
1125
|
+
if (uid !== undefined && info.uid !== uid)
|
|
1126
|
+
throw new Error(`${label} must be owned by the current user: ${path}`);
|
|
1127
|
+
if ((info.mode & 0o022) !== 0)
|
|
1128
|
+
throw new Error(`${label} must not be group/world writable: ${path}`);
|
|
1129
|
+
}
|
|
1130
|
+
function isLexicallyInside(root, path) {
|
|
1131
|
+
const rel = relative(resolve(root), resolve(path));
|
|
1132
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
1133
|
+
}
|
|
1134
|
+
function assertLexicalPathInside(root, path, label) {
|
|
1135
|
+
if (!isLexicallyInside(root, path)) {
|
|
1136
|
+
throw new Error(`${label} must stay inside the current agent folder: ${path}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
async function assertPreparedSourcesCurrent(cwd, configPath, prepared, phase) {
|
|
1140
|
+
const secureConfig = await resolveOwnedRegularFileInside(cwd, configPath, "Config file");
|
|
1141
|
+
const current = await readMonoAgentConfigJson(secureConfig);
|
|
1142
|
+
if (current.version !== prepared.expectedConfigVersion) {
|
|
1143
|
+
throw new Error(`Configuration changed ${phase}. Nothing was written; run /configure again.`);
|
|
1144
|
+
}
|
|
1145
|
+
if (prepared.rolePath !== undefined) {
|
|
1146
|
+
const secureRole = await resolveOwnedRegularFileInside(cwd, prepared.rolePath, "Identity file");
|
|
1147
|
+
const currentRole = await readFile(secureRole, "utf8");
|
|
1148
|
+
if (sha256(currentRole) !== prepared.expectedRoleHash) {
|
|
1149
|
+
throw new Error(`Configured identity document changed ${phase}. Nothing was written; run /configure again.`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async function assertExactOwnedContents(cwd, path, expected, label) {
|
|
1154
|
+
const securePath = await resolveOwnedRegularFileInside(cwd, path, label);
|
|
1155
|
+
if (await readFile(securePath, "utf8") !== expected) {
|
|
1156
|
+
throw new Error(`${label}; the concurrent edit was preserved.`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async function restoreIfExact(cwd, path, expected, restore) {
|
|
1160
|
+
await atomicReplaceExact(cwd, path, expected, restore);
|
|
1161
|
+
}
|
|
1162
|
+
async function atomicReplaceExact(root, path, expected, contents) {
|
|
1163
|
+
const securePath = await resolveOwnedRegularFileInside(root, path, "Configuration transaction file");
|
|
1164
|
+
const info = await lstat(securePath);
|
|
1165
|
+
const temporary = join(dirname(securePath), `.${randomUUID()}.mono-agent-tmp`);
|
|
1166
|
+
let handle;
|
|
1167
|
+
try {
|
|
1168
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), info.mode & 0o777);
|
|
1169
|
+
await handle.writeFile(contents, "utf8");
|
|
1170
|
+
await handle.chmod(info.mode & 0o777);
|
|
1171
|
+
await handle.sync();
|
|
1172
|
+
await handle.close();
|
|
1173
|
+
handle = undefined;
|
|
1174
|
+
// Temp creation and fsync intentionally happen before the last source
|
|
1175
|
+
// comparison. From that comparison through rename there is no JavaScript
|
|
1176
|
+
// yield: an editor that reacts to the staged temp is observed, while a
|
|
1177
|
+
// cooperating mono-agent writer is also serialized by the owner lock.
|
|
1178
|
+
commitAtomicReplacementSync(root, securePath, temporary, expected, info);
|
|
1179
|
+
}
|
|
1180
|
+
finally {
|
|
1181
|
+
await handle?.close();
|
|
1182
|
+
await rm(temporary, { force: true });
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function commitAtomicReplacementSync(root, path, temporary, expected, initialInfo) {
|
|
1186
|
+
const securePath = resolveOwnedRegularFileInsideSync(root, path, "Configuration transaction file");
|
|
1187
|
+
let sourceHandle;
|
|
1188
|
+
try {
|
|
1189
|
+
sourceHandle = openSync(securePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
1190
|
+
const openedBefore = fstatSync(sourceHandle);
|
|
1191
|
+
assertOwnedRegularFileInfo(openedBefore, securePath, "Configuration transaction file");
|
|
1192
|
+
const current = readFileSync(sourceHandle, "utf8");
|
|
1193
|
+
const openedAfter = fstatSync(sourceHandle);
|
|
1194
|
+
const named = lstatSync(securePath);
|
|
1195
|
+
if (current !== expected
|
|
1196
|
+
|| !sameFileIdentityAndMetadata(initialInfo, openedBefore)
|
|
1197
|
+
|| !sameFileIdentityAndMetadata(openedBefore, openedAfter)
|
|
1198
|
+
|| !sameFileIdentityAndMetadata(openedAfter, named)) {
|
|
1199
|
+
throw new Error(`Refusing to replace changed file ${securePath}; the concurrent edit was preserved.`);
|
|
1200
|
+
}
|
|
1201
|
+
renameSync(temporary, securePath);
|
|
1202
|
+
}
|
|
1203
|
+
finally {
|
|
1204
|
+
if (sourceHandle !== undefined)
|
|
1205
|
+
closeSync(sourceHandle);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function resolveOwnedRegularFileInsideSync(root, path, label) {
|
|
1209
|
+
const canonicalRoot = realpathSync(resolve(root));
|
|
1210
|
+
const absolute = resolve(path);
|
|
1211
|
+
assertLexicalPathInside(canonicalRoot, absolute, label);
|
|
1212
|
+
const segments = relative(canonicalRoot, absolute).split(sep).filter((segment) => segment.length > 0);
|
|
1213
|
+
if (segments.length === 0)
|
|
1214
|
+
throw new Error(`${label} must name a file inside the current agent folder.`);
|
|
1215
|
+
let parent = canonicalRoot;
|
|
1216
|
+
assertOwnedDirectoryInfo(lstatSync(parent), parent, "Current agent folder");
|
|
1217
|
+
for (const segment of segments.slice(0, -1)) {
|
|
1218
|
+
parent = join(parent, segment);
|
|
1219
|
+
assertOwnedDirectoryInfo(lstatSync(parent), parent, `${label} parent`);
|
|
1220
|
+
}
|
|
1221
|
+
const target = join(parent, segments.at(-1));
|
|
1222
|
+
assertOwnedRegularFileInfo(lstatSync(target), target, label);
|
|
1223
|
+
const canonicalTarget = realpathSync(target);
|
|
1224
|
+
assertLexicalPathInside(canonicalRoot, canonicalTarget, label);
|
|
1225
|
+
if (canonicalTarget !== target) {
|
|
1226
|
+
throw new Error(`${label} must not traverse a symbolic-link parent: ${path}`);
|
|
1227
|
+
}
|
|
1228
|
+
return target;
|
|
1229
|
+
}
|
|
1230
|
+
function sameFileIdentityAndMetadata(left, right) {
|
|
1231
|
+
return left.dev === right.dev
|
|
1232
|
+
&& left.ino === right.ino
|
|
1233
|
+
&& left.nlink === right.nlink
|
|
1234
|
+
&& left.mode === right.mode
|
|
1235
|
+
&& left.size === right.size
|
|
1236
|
+
&& left.mtimeMs === right.mtimeMs
|
|
1237
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
1238
|
+
}
|
|
1239
|
+
async function withConfigurationTransactionLock(cwd, label, operation) {
|
|
1240
|
+
const monoAgentDir = await ensureOwnedDirectoryInside(cwd, join(cwd, ".mono-agent"), "Configuration state directory");
|
|
1241
|
+
const lockPath = join(monoAgentDir, "configuration.lock");
|
|
1242
|
+
const token = randomUUID();
|
|
1243
|
+
const contents = `${JSON.stringify({
|
|
1244
|
+
schema: "mono-agent.configuration-lock.v1",
|
|
1245
|
+
pid: process.pid,
|
|
1246
|
+
token,
|
|
1247
|
+
label: safeFilePart(label),
|
|
1248
|
+
createdAt: new Date().toISOString(),
|
|
1249
|
+
})}\n`;
|
|
1250
|
+
let handle;
|
|
1251
|
+
let identity;
|
|
1252
|
+
try {
|
|
1253
|
+
try {
|
|
1254
|
+
handle = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
if (error.code === "EEXIST") {
|
|
1258
|
+
throw new Error(`Another local configuration transaction owns ${lockPath}. Wait for it to finish; inspect and remove the lock manually only if its owner crashed.`);
|
|
1259
|
+
}
|
|
1260
|
+
throw error;
|
|
1261
|
+
}
|
|
1262
|
+
await handle.chmod(0o600);
|
|
1263
|
+
await handle.writeFile(contents, "utf8");
|
|
1264
|
+
await handle.sync();
|
|
1265
|
+
const info = await handle.stat();
|
|
1266
|
+
assertOwnedRegularFileInfo(info, lockPath, "Configuration transaction lock");
|
|
1267
|
+
identity = { dev: info.dev, ino: info.ino };
|
|
1268
|
+
await handle.close();
|
|
1269
|
+
handle = undefined;
|
|
1270
|
+
return await operation();
|
|
1271
|
+
}
|
|
1272
|
+
finally {
|
|
1273
|
+
await handle?.close();
|
|
1274
|
+
if (identity !== undefined) {
|
|
1275
|
+
const current = await lstat(lockPath).catch((error) => {
|
|
1276
|
+
if (error.code === "ENOENT")
|
|
1277
|
+
return undefined;
|
|
1278
|
+
throw error;
|
|
1279
|
+
});
|
|
1280
|
+
if (current === undefined
|
|
1281
|
+
|| current.dev !== identity.dev
|
|
1282
|
+
|| current.ino !== identity.ino
|
|
1283
|
+
|| await readFile(lockPath, "utf8") !== contents) {
|
|
1284
|
+
throw new Error(`Configuration transaction lock changed unexpectedly and was left untouched: ${lockPath}`);
|
|
1285
|
+
}
|
|
1286
|
+
await rm(lockPath);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function sha256(value) {
|
|
1291
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1292
|
+
}
|
|
1293
|
+
function safeFilePart(value) {
|
|
1294
|
+
return value.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 120);
|
|
1295
|
+
}
|
|
1296
|
+
function isRecord(value) {
|
|
1297
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1298
|
+
}
|
|
1299
|
+
//# sourceMappingURL=local-configuration.js.map
|