@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.
Files changed (186) hide show
  1. package/README.md +71 -22
  2. package/dist/account-home.d.ts +6 -0
  3. package/dist/account-home.d.ts.map +1 -0
  4. package/dist/account-home.js +9 -0
  5. package/dist/account-home.js.map +1 -0
  6. package/dist/app-config.d.ts +3 -1
  7. package/dist/app-config.d.ts.map +1 -1
  8. package/dist/app-config.js +8 -5
  9. package/dist/app-config.js.map +1 -1
  10. package/dist/app.d.ts +25 -0
  11. package/dist/app.d.ts.map +1 -1
  12. package/dist/app.js +237 -16
  13. package/dist/app.js.map +1 -1
  14. package/dist/background-environment.d.ts +16 -0
  15. package/dist/background-environment.d.ts.map +1 -0
  16. package/dist/background-environment.js +68 -0
  17. package/dist/background-environment.js.map +1 -0
  18. package/dist/background-runtime.d.ts +62 -0
  19. package/dist/background-runtime.d.ts.map +1 -0
  20. package/dist/background-runtime.js +1115 -0
  21. package/dist/background-runtime.js.map +1 -0
  22. package/dist/background-snapshot-key.d.ts +13 -0
  23. package/dist/background-snapshot-key.d.ts.map +1 -0
  24. package/dist/background-snapshot-key.js +194 -0
  25. package/dist/background-snapshot-key.js.map +1 -0
  26. package/dist/background-snapshot.d.ts +96 -0
  27. package/dist/background-snapshot.d.ts.map +1 -0
  28. package/dist/background-snapshot.js +450 -0
  29. package/dist/background-snapshot.js.map +1 -0
  30. package/dist/background-worker-lease.d.ts +39 -0
  31. package/dist/background-worker-lease.d.ts.map +1 -0
  32. package/dist/background-worker-lease.js +356 -0
  33. package/dist/background-worker-lease.js.map +1 -0
  34. package/dist/background.d.ts +74 -2
  35. package/dist/background.d.ts.map +1 -1
  36. package/dist/background.js +815 -73
  37. package/dist/background.js.map +1 -1
  38. package/dist/channel-plugins.d.ts +2 -0
  39. package/dist/channel-plugins.d.ts.map +1 -1
  40. package/dist/channel-plugins.js +8 -0
  41. package/dist/channel-plugins.js.map +1 -1
  42. package/dist/channels.d.ts +10 -0
  43. package/dist/channels.d.ts.map +1 -1
  44. package/dist/channels.js +71 -3
  45. package/dist/channels.js.map +1 -1
  46. package/dist/cli.d.ts +12 -2
  47. package/dist/cli.d.ts.map +1 -1
  48. package/dist/cli.js +341 -63
  49. package/dist/cli.js.map +1 -1
  50. package/dist/config-reference.d.ts.map +1 -1
  51. package/dist/config-reference.js +253 -0
  52. package/dist/config-reference.js.map +1 -1
  53. package/dist/configuration-proposal-tool.d.ts +7 -0
  54. package/dist/configuration-proposal-tool.d.ts.map +1 -1
  55. package/dist/configuration-proposal-tool.js +55 -6
  56. package/dist/configuration-proposal-tool.js.map +1 -1
  57. package/dist/configured-agent.d.ts +4 -0
  58. package/dist/configured-agent.d.ts.map +1 -1
  59. package/dist/configured-agent.js +15 -0
  60. package/dist/configured-agent.js.map +1 -1
  61. package/dist/continuation-command.d.ts +15 -0
  62. package/dist/continuation-command.d.ts.map +1 -0
  63. package/dist/continuation-command.js +170 -0
  64. package/dist/continuation-command.js.map +1 -0
  65. package/dist/continuation-config.d.ts +25 -0
  66. package/dist/continuation-config.d.ts.map +1 -0
  67. package/dist/continuation-config.js +236 -0
  68. package/dist/continuation-config.js.map +1 -0
  69. package/dist/continuation-service.d.ts +99 -0
  70. package/dist/continuation-service.d.ts.map +1 -0
  71. package/dist/continuation-service.js +1313 -0
  72. package/dist/continuation-service.js.map +1 -0
  73. package/dist/continuation-store.d.ts +104 -0
  74. package/dist/continuation-store.d.ts.map +1 -0
  75. package/dist/continuation-store.js +688 -0
  76. package/dist/continuation-store.js.map +1 -0
  77. package/dist/continuations.d.ts +167 -0
  78. package/dist/continuations.d.ts.map +1 -0
  79. package/dist/continuations.js +71 -0
  80. package/dist/continuations.js.map +1 -0
  81. package/dist/doctor.d.ts +2 -0
  82. package/dist/doctor.d.ts.map +1 -1
  83. package/dist/doctor.js +426 -27
  84. package/dist/doctor.js.map +1 -1
  85. package/dist/first-run-managed-memory.d.ts.map +1 -1
  86. package/dist/first-run-managed-memory.js +66 -3
  87. package/dist/first-run-managed-memory.js.map +1 -1
  88. package/dist/first-run-readiness.d.ts.map +1 -1
  89. package/dist/first-run-readiness.js +2 -31
  90. package/dist/first-run-readiness.js.map +1 -1
  91. package/dist/index.d.ts +9 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +4 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/init.d.ts +7 -0
  96. package/dist/init.d.ts.map +1 -1
  97. package/dist/init.js +7 -2
  98. package/dist/init.js.map +1 -1
  99. package/dist/launchd.d.ts +16 -6
  100. package/dist/launchd.d.ts.map +1 -1
  101. package/dist/launchd.js +30 -15
  102. package/dist/launchd.js.map +1 -1
  103. package/dist/local-configuration.d.ts +61 -18
  104. package/dist/local-configuration.d.ts.map +1 -1
  105. package/dist/local-configuration.js +530 -145
  106. package/dist/local-configuration.js.map +1 -1
  107. package/dist/managed-runtime-packages.d.ts +9 -0
  108. package/dist/managed-runtime-packages.d.ts.map +1 -0
  109. package/dist/managed-runtime-packages.js +56 -0
  110. package/dist/managed-runtime-packages.js.map +1 -0
  111. package/dist/memory-embedding-service.d.ts +33 -0
  112. package/dist/memory-embedding-service.d.ts.map +1 -0
  113. package/dist/memory-embedding-service.js +207 -0
  114. package/dist/memory-embedding-service.js.map +1 -0
  115. package/dist/memory-recall.d.ts +5 -4
  116. package/dist/memory-recall.d.ts.map +1 -1
  117. package/dist/memory-recall.js +21 -8
  118. package/dist/memory-recall.js.map +1 -1
  119. package/dist/modules/catalog.d.ts.map +1 -1
  120. package/dist/modules/catalog.js +91 -9
  121. package/dist/modules/catalog.js.map +1 -1
  122. package/dist/notify-runtime.d.ts +26 -0
  123. package/dist/notify-runtime.d.ts.map +1 -0
  124. package/dist/notify-runtime.js +27 -0
  125. package/dist/notify-runtime.js.map +1 -0
  126. package/dist/notify-tool.d.ts +51 -0
  127. package/dist/notify-tool.d.ts.map +1 -0
  128. package/dist/notify-tool.js +182 -0
  129. package/dist/notify-tool.js.map +1 -0
  130. package/dist/proactive-notify.d.ts +2 -0
  131. package/dist/proactive-notify.d.ts.map +1 -1
  132. package/dist/proactive-notify.js +1 -0
  133. package/dist/proactive-notify.js.map +1 -1
  134. package/dist/process-incarnation.d.ts +23 -0
  135. package/dist/process-incarnation.d.ts.map +1 -0
  136. package/dist/process-incarnation.js +208 -0
  137. package/dist/process-incarnation.js.map +1 -0
  138. package/dist/project-skills.d.ts +1 -1
  139. package/dist/project-skills.js +6 -6
  140. package/dist/recipes/base.d.ts +15 -0
  141. package/dist/recipes/base.d.ts.map +1 -0
  142. package/dist/recipes/base.js +51 -0
  143. package/dist/recipes/base.js.map +1 -0
  144. package/dist/recipes/catalog.d.ts +4 -0
  145. package/dist/recipes/catalog.d.ts.map +1 -0
  146. package/dist/recipes/catalog.js +525 -0
  147. package/dist/recipes/catalog.js.map +1 -0
  148. package/dist/recipes/index.d.ts +11 -0
  149. package/dist/recipes/index.d.ts.map +1 -0
  150. package/dist/recipes/index.js +14 -0
  151. package/dist/recipes/index.js.map +1 -0
  152. package/dist/recipes/types.d.ts +70 -0
  153. package/dist/recipes/types.d.ts.map +1 -0
  154. package/dist/recipes/types.js +15 -0
  155. package/dist/recipes/types.js.map +1 -0
  156. package/dist/sandbox-manager.d.ts +17 -0
  157. package/dist/sandbox-manager.d.ts.map +1 -1
  158. package/dist/sandbox-manager.js +673 -127
  159. package/dist/sandbox-manager.js.map +1 -1
  160. package/dist/setup.d.ts +29 -0
  161. package/dist/setup.d.ts.map +1 -0
  162. package/dist/setup.js +97 -0
  163. package/dist/setup.js.map +1 -0
  164. package/dist/supermemory-plugin.d.ts +5 -3
  165. package/dist/supermemory-plugin.d.ts.map +1 -1
  166. package/dist/supermemory-plugin.js +11 -5
  167. package/dist/supermemory-plugin.js.map +1 -1
  168. package/dist/tui-command.d.ts +43 -2
  169. package/dist/tui-command.d.ts.map +1 -1
  170. package/dist/tui-command.js +261 -27
  171. package/dist/tui-command.js.map +1 -1
  172. package/dist/wizard/answers.d.ts +4 -4
  173. package/dist/wizard/answers.d.ts.map +1 -1
  174. package/dist/wizard/answers.js +4 -21
  175. package/dist/wizard/answers.js.map +1 -1
  176. package/dist/wizard/run.d.ts.map +1 -1
  177. package/dist/wizard/run.js +162 -5
  178. package/dist/wizard/run.js.map +1 -1
  179. package/package.json +15 -15
  180. package/schema/mono-agent.config.schema.json +206 -3
  181. package/skills/mono-agent-composer/SKILL.md +4 -4
  182. package/skills/mono-agent-composer/references/config-blueprint.md +6 -6
  183. package/skills/mono-agent-composer/references/discovery-questions.md +25 -10
  184. package/skills/mono-agent-composer/references/feature-coverage.md +7 -7
  185. package/skills/mono-agent-composer/references/package-map.md +1 -1
  186. 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, mkdtemp, open, readFile, realpath, rm, writeFile, } from "node:fs/promises";
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
- export const LOCAL_CONFIGURATION_PROMPT = "Begin local configuration mode. Read the mono-agent-configure skill, then ask the operator one concise question: how would they like to configure you further? Mention that behavior, memory, skills, tools, or channels can be discussed, but do not repeat the setup wizard and do not ask for secrets.";
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 without starting channels or creating service state. */
25
+ /** Build a current-folder responder for ordinary embedded chat only. */
20
26
  export async function createLocalConfigurationSession(options) {
21
- const manager = await LocalConfigurationManager.create(options);
22
- let active = await manager.buildResponder();
23
- const activateResponder = async (replacement) => {
24
- const previous = active;
25
- active = replacement;
26
- await previous.dispose?.();
27
- };
28
- const replaceActiveResponder = async () => {
29
- const replacement = await manager.buildResponder();
30
- await activateResponder(replacement);
31
- };
32
- const proxy = {
33
- respond: async (request, stream) => await active.respond(request, stream),
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
- ...(options.configure ? { initialPrompt: LOCAL_CONFIGURATION_PROMPT } : {}),
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
- // A failed proposal must not leave its request-scoped MCP server in a
50
- // retained provider session. Rotate first, then report the validation
51
- // failure to the TUI.
52
- await replaceActiveResponder();
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
- // Codex and other resumable providers retain the MCP configuration
57
- // from the turn that opened their provider session. Rotating after a
58
- // proposal-free configuration turn makes the next reply start with a
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
- const applied = await manager.apply(id);
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
- const result = await manager.reject(id);
82
- await replaceActiveResponder();
83
- return result;
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
- sessionDir;
259
+ proposalRoot;
95
260
  currentConfig;
96
- pendingSinks = new Set();
261
+ roleLocation;
97
262
  prepared = new Map();
98
- constructor(options, sessionDir, currentConfig) {
263
+ cleanupPaths = new Set();
264
+ currentSessionId;
265
+ currentSessionDir;
266
+ disabledReason;
267
+ constructor(options, proposalRoot, sessionDir, sessionId, currentConfig) {
99
268
  this.options = options;
100
- this.sessionDir = sessionDir;
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 sessionDir = await mkdtemp(join(parent, "session-"));
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
- async runtimeExtension(input) {
125
- if (!isLocalConfigurationRequest(input.request.metadata)) {
126
- return { runtimeOptions: {} };
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
- await ensureOwnedDirectoryInside(this.options.cwd, this.sessionDir, "Configuration proposal session");
159
- for (const sink of [...this.pendingSinks]) {
160
- this.pendingSinks.delete(sink);
161
- const contents = await readOptional(sink);
162
- await rm(sink, { force: true });
163
- if (contents === undefined)
164
- continue;
165
- const proposal = parseProposal(contents);
166
- return await this.prepareProposal(proposal);
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
- return undefined;
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 { message: `Rejected configuration proposal ${id}; no files changed.` };
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("replace IDENTITY.md ## Role body");
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, "IDENTITY.md.before"), prepared.roleBefore, { flag: "wx", mode: 0o600 });
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, "IDENTITY.md changed at the Role commit boundary");
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, "IDENTITY.md changed before rollback");
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.sessionDir, "Configuration proposal session");
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 rm(this.sessionDir, { recursive: true, force: true });
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
- return typeof tui === "object" && tui !== null
383
- && tui.local === true
384
- && tui.configuration === true;
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 = new Set(before?.allowedTools ?? []);
622
- const afterAllowed = new Set(after?.allowedTools ?? []);
623
- const beforeDenied = new Set(before?.disallowedTools ?? []);
624
- const afterDenied = new Set(after?.disallowedTools ?? []);
625
- const beforeAllowsAll = before?.allowedTools === undefined || beforeAllowed.has("*");
626
- const afterAllowsAll = after?.allowedTools === undefined || afterAllowed.has("*");
627
- if (afterAllowsAll) {
628
- return !beforeAllowsAll || [...beforeDenied].some((tool) => !afterDenied.has(tool));
629
- }
630
- return [...afterAllowed].some((tool) => tool !== "*"
631
- && !afterDenied.has(tool)
632
- && ((!beforeAllowsAll && !beforeAllowed.has(tool)) || beforeDenied.has(tool)));
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.trim();
636
- if (body.length === 0 || /^##\s/mu.test(body)) {
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("IDENTITY.md has no canonical ## Role section to replace safely.");
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
- const compact = rendered === undefined ? "" : rendered.length <= 180 ? rendered : `${rendered.slice(0, 179)}…`;
656
- return `${operation.op} ${operation.path} = ${compact}`;
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(`IDENTITY.md changed ${phase}. Nothing was written; run /configure again.`);
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
  }