@saadnvd1/agent-os 0.3.662 → 0.3.671

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 (122) hide show
  1. package/app/api/audits/[kind]/[id]/route.test.ts +2 -2
  2. package/app/api/mcp-auth/callback/route.test.ts +42 -0
  3. package/app/api/mcp-auth/callback/route.ts +56 -0
  4. package/app/api/projects/init/route.test.ts +2 -4
  5. package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
  6. package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
  7. package/app/api/sessions/[id]/route.ts +3 -3
  8. package/app/api/shares/route.test.ts +3 -4
  9. package/app/api/workspaces/[id]/route.ts +9 -1
  10. package/app/api/worktree-sweep/route.test.ts +40 -1
  11. package/app/api/worktree-sweep/route.ts +45 -6
  12. package/components/Chat/ChatPanel.tsx +6 -3
  13. package/components/Chat/McpServers.tsx +61 -24
  14. package/components/Chat/McpSignIn.tsx +122 -0
  15. package/components/Chat/session.ts +8 -0
  16. package/components/Health/SweepRuns.tsx +77 -1
  17. package/components/Workspaces/WorkspaceSettings.tsx +6 -0
  18. package/data/chat/mcpAuth.ts +42 -0
  19. package/data/workspaces/index.ts +6 -0
  20. package/data/worktree-sweep/index.ts +15 -0
  21. package/lib/agentos-home.ts +11 -0
  22. package/lib/agents/launch.ts +3 -7
  23. package/lib/artifacts/store.ts +2 -5
  24. package/lib/chat/driver.ts +24 -2
  25. package/lib/chat/drivers/claude-approvals.ts +7 -0
  26. package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
  27. package/lib/chat/drivers/claude.ts +7 -0
  28. package/lib/chat/drivers/pi-approvals.ts +2 -2
  29. package/lib/chat/events.ts +12 -0
  30. package/lib/chat/mcp-auth.ts +193 -0
  31. package/lib/chat/registry.ts +2 -0
  32. package/lib/chat/runner.ts +18 -0
  33. package/lib/chat/worker/client.ts +35 -1
  34. package/lib/chat/worker/host.ts +9 -0
  35. package/lib/chat/worker/main.ts +1 -1
  36. package/lib/chat/worker/mcp-auth.ts +119 -0
  37. package/lib/chat/worker/protocol.ts +21 -2
  38. package/lib/connect/config.ts +2 -3
  39. package/lib/db/backup.ts +2 -2
  40. package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -0
  41. package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
  42. package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
  43. package/lib/db/types.ts +3 -0
  44. package/lib/dev-instance/processInfo.ts +48 -27
  45. package/lib/dev-instance/startDevServer.ts +1 -1
  46. package/lib/dev-instance/stopDevServer.ts +20 -17
  47. package/lib/dev-servers.ts +2 -1
  48. package/lib/done/index.ts +2 -2
  49. package/lib/done/testing.ts +2 -2
  50. package/lib/env-setup.ts +8 -1
  51. package/lib/health/boots.ts +31 -0
  52. package/lib/health/ci.ts +23 -2
  53. package/lib/health/detectors.ts +2 -1
  54. package/lib/health/evidence.ts +1 -1
  55. package/lib/health/facts.ts +3 -3
  56. package/lib/health/fixtures.ts +1 -0
  57. package/lib/health/tick.ts +6 -1
  58. package/lib/health/types.ts +2 -0
  59. package/lib/orchestrator/armed.test-helpers.ts +3 -2
  60. package/lib/orchestrator/brief.ts +14 -9
  61. package/lib/orchestrator/external-gates.ts +62 -1
  62. package/lib/orchestrator/full-access.ts +21 -0
  63. package/lib/orchestrator/home.ts +2 -5
  64. package/lib/orchestrator/quick.ts +47 -4
  65. package/lib/orchestrator/serve.ts +11 -2
  66. package/lib/orchestrator/shell-guard.ts +24 -0
  67. package/lib/orchestrator/signoff.test-helpers.ts +3 -2
  68. package/lib/orchestrator/sweep-arm.ts +180 -0
  69. package/lib/orchestrator/tool-names.ts +4 -1
  70. package/lib/orchestrator/tool-schemas.ts +33 -1
  71. package/lib/orchestrator/tools.ts +6 -2
  72. package/lib/orchestrator/write-guard.ts +37 -9
  73. package/lib/plans/features.ts +2 -2
  74. package/lib/program-status/claude-hooks.ts +2 -2
  75. package/lib/program-status/tap.ts +2 -2
  76. package/lib/project-config/brief.ts +3 -1
  77. package/lib/project-config/database.ts +77 -16
  78. package/lib/project-config/index.ts +108 -21
  79. package/lib/project-config/schema.ts +10 -4
  80. package/lib/project-config/session.ts +27 -6
  81. package/lib/security/net.ts +7 -4
  82. package/lib/security/tailnet-https.ts +2 -2
  83. package/lib/session-artifacts/collect.ts +18 -1
  84. package/lib/session-artifacts/keep.ts +2 -4
  85. package/lib/session-audit/store.ts +2 -5
  86. package/lib/session-logs/folder.ts +2 -3
  87. package/lib/sessions/launch.ts +2 -2
  88. package/lib/sessions/leftovers.ts +61 -0
  89. package/lib/simulator/agent-device.ts +2 -2
  90. package/lib/tasks/finish.ts +2 -2
  91. package/lib/tasks/import.ts +3 -5
  92. package/lib/tasks/move-testing.ts +2 -2
  93. package/lib/tasks/move.ts +2 -2
  94. package/lib/tasks/moved-away.ts +2 -0
  95. package/lib/tasks/state.ts +7 -3
  96. package/lib/tasks/ui-brief.ts +2 -2
  97. package/lib/test-temp.ts +30 -0
  98. package/lib/workspaces.ts +6 -2
  99. package/lib/worktree-deps/fastcopy.ts +2 -2
  100. package/lib/worktree-deps/spare.ts +2 -2
  101. package/lib/worktree-sweep/caches.ts +129 -0
  102. package/lib/worktree-sweep/databases.ts +222 -0
  103. package/lib/worktree-sweep/du.ts +13 -0
  104. package/lib/worktree-sweep/extras.ts +196 -0
  105. package/lib/worktree-sweep/findOrphans.ts +19 -8
  106. package/lib/worktree-sweep/report.ts +86 -0
  107. package/lib/worktree-sweep/runSweep.ts +46 -13
  108. package/lib/worktree-sweep/settings.ts +24 -0
  109. package/lib/worktree-sweep/state.ts +5 -0
  110. package/lib/worktree-sweep/store.ts +11 -0
  111. package/lib/worktree-sweep/testTemp.ts +113 -0
  112. package/lib/worktree-sweep/types.ts +46 -1
  113. package/lib/worktrees.ts +3 -0
  114. package/package.json +2 -1
  115. package/scripts/autodeploy.test.ts +2 -2
  116. package/scripts/check-bundle.test.ts +2 -2
  117. package/scripts/disk-dry-run.ts +57 -0
  118. package/scripts/hooks/banned-terms.test.ts +3 -2
  119. package/scripts/hooks/staged-secrets.test.ts +2 -2
  120. package/scripts/lib/build-swap.test.ts +2 -3
  121. package/scripts/lib/wsl.test.ts +2 -3
  122. package/scripts/update-run.test.ts +2 -2
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Where an orchestrator may write. Anywhere on the machine except the
2
+ * Where an orchestrator may write (unless its workspace has Orchestrator full
3
+ * access on, the default: orchestratorToolRefusal then checks nothing but
4
+ * warns about stopping AgentOS; full-access.ts). Anywhere on the machine except the
3
5
  * workspace's project repositories and their task worktrees (code changes go
4
6
  * through tasks, so the merge gates keep meaning something), AgentOS's own
5
7
  * install and state (all of ~/.agent-os but its own folder), secret files,
@@ -12,18 +14,21 @@
12
14
  * `..` resolved the way the system will resolve it.
13
15
  */
14
16
 
17
+ import { agentosHome } from "../agentos-home";
15
18
  import { execFile } from "child_process";
16
19
  import fs from "fs";
17
20
  import os from "os";
18
21
  import path from "path";
19
22
  import { db, type Session } from "../db";
23
+ import type { ToolGuardResult } from "../chat/driver";
20
24
  import { git, GitError } from "../git-exec";
21
25
  import { isOrchestratorSecretPath as isSecretPath } from "../secret-paths";
22
26
  import { WORKTREES_DIR } from "../worktrees";
23
27
  import { orchestratorDir } from "./home";
24
28
  import { LIMITS_FILE } from "./usage";
25
29
  import { quickWorktrees } from "./quick-store";
26
- import { shellRefusal } from "./shell-guard";
30
+ import { shellRefusal, shellWarning, type ShellContext } from "./shell-guard";
31
+ import { orchestratorFullAccess } from "./full-access";
27
32
  import { liveCheckout } from "../dev-instance/liveServer";
28
33
 
29
34
  // The tools that write a file, and the argument naming it.
@@ -296,9 +301,9 @@ export async function orchestratorWriteBounds(
296
301
  memory: [...new Set([cwd, await physicalPath(cwd)])].map((c) =>
297
302
  path.join(os.homedir(), ".claude", "projects", mangleCwd(c), "memory")
298
303
  ),
299
- audits: path.join(os.homedir(), ".agent-os", "audits"),
304
+ audits: agentosHome("audits"),
300
305
  quick: quickWorktrees(session.id),
301
- state: path.join(os.homedir(), ".agent-os"),
306
+ state: agentosHome(),
302
307
  repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
303
308
  };
304
309
  }
@@ -365,24 +370,47 @@ export async function orchestratorToolRefusal(
365
370
  session: Session,
366
371
  tool: string,
367
372
  input: Record<string, unknown>
368
- ): Promise<string | null> {
369
- if (tool === "Bash") return commandRefusal(session, input.command);
373
+ ): Promise<ToolGuardResult> {
374
+ const full = orchestratorFullAccess(session);
375
+ if (tool === "Bash")
376
+ return full
377
+ ? commandWarning(session, input.command)
378
+ : commandRefusal(session, input.command);
370
379
  const arg = WRITE_TOOLS[tool];
371
380
  if (!arg) return null;
372
381
  const filePath = input[arg];
382
+ if (full) return null;
373
383
  if (typeof filePath !== "string") return "No file path to check.";
374
384
  return writeRefusal(filePath, await orchestratorWriteBounds(session));
375
385
  }
376
386
 
387
+ // With full access: only a warning for stopping AgentOS or its tmux.
388
+ async function commandWarning(
389
+ session: Session,
390
+ command: unknown
391
+ ): Promise<ToolGuardResult> {
392
+ const warning = await shellWarning(
393
+ command,
394
+ shellContext(session, await orchestratorWriteBounds(session))
395
+ );
396
+ return warning ? { warning } : null;
397
+ }
398
+
377
399
  // A shell command, against the deny list (shell-guard.ts), its writes held
378
400
  // to the shell's share of the write bounds.
379
401
  async function commandRefusal(
380
402
  session: Session,
381
403
  command: unknown
382
404
  ): Promise<string | null> {
383
- const bounds = await orchestratorWriteBounds(session);
405
+ return shellRefusal(
406
+ command,
407
+ shellContext(session, await orchestratorWriteBounds(session))
408
+ );
409
+ }
410
+
411
+ function shellContext(session: Session, bounds: WriteBounds): ShellContext {
384
412
  const dbFile = agentOsDbPath();
385
- return shellRefusal(command, {
413
+ return {
386
414
  cwd: bounds.cwd,
387
415
  writeRefusal: (p) => writeRefusal(p, bounds, { shell: true }),
388
416
  isAgentOsState: async (p) => {
@@ -401,7 +429,7 @@ async function commandRefusal(
401
429
  },
402
430
  checkoutKind: (dir) => checkoutKind(session, dir),
403
431
  protectedPids,
404
- });
432
+ };
405
433
  }
406
434
 
407
435
  // Whether a folder is a project checkout of the session's own workspace
@@ -1,6 +1,7 @@
1
1
  // A plan that split into pieces: the feature, its pieces, and the one-time
2
2
  // checks that turn a plan file into rows. A one-piece plan makes none.
3
3
 
4
+ import { agentosHome } from "../agentos-home";
4
5
  import fs from "fs";
5
6
  import os from "os";
6
7
  import path from "path";
@@ -61,8 +62,7 @@ export interface FeaturePiece {
61
62
  const expandHome = (p: string) => p.replace(/^~/, os.homedir());
62
63
 
63
64
  export const plansDir = () =>
64
- process.env.AGENTOS_PLANS_DIR ||
65
- path.join(os.homedir(), ".agent-os", "plans");
65
+ process.env.AGENTOS_PLANS_DIR || agentosHome("plans");
66
66
  export const planPathOf = (featureId: string) =>
67
67
  path.join(plansDir(), `${featureId}.md`);
68
68
 
@@ -1,9 +1,9 @@
1
+ import { agentosHome } from "../agentos-home";
1
2
  import fs from "fs";
2
- import os from "os";
3
3
  import path from "path";
4
4
  import { NO_ATTRIBUTION } from "../agents/attribution";
5
5
 
6
- const DIR = path.join(os.homedir(), ".agent-os");
6
+ const DIR = agentosHome();
7
7
  const HOOK = path.join(DIR, "bin", "agentos-status");
8
8
  const SETTINGS = path.join(DIR, "claude-status-hooks.json");
9
9
  const SUBAGENT_HOOK = path.join(DIR, "bin", "agentos-subagent-model.mjs");
@@ -9,9 +9,9 @@
9
9
  * Remote-host sessions are not tapped: their output never passes through
10
10
  * this machine except while attached. They keep reading the screen.
11
11
  */
12
+ import { agentosHome } from "../agentos-home";
12
13
  import fs from "fs";
13
14
  import net from "net";
14
- import os from "os";
15
15
  import path from "path";
16
16
  import { execFile } from "child_process";
17
17
  import { promisify } from "util";
@@ -50,7 +50,7 @@ const replied = new Map<string, number>();
50
50
 
51
51
  // In a directory only this user can enter: only they can write reports.
52
52
  export function tapSocketPath(port: number): string {
53
- return path.join(os.homedir(), ".agent-os", "run", `osc-${port}.sock`);
53
+ return agentosHome("run", `osc-${port}.sock`);
54
54
  }
55
55
 
56
56
  async function foreground(name: string): Promise<string | undefined> {
@@ -18,9 +18,11 @@ import { simulatorBrief } from "../simulator/brief";
18
18
  export function runningBrief(
19
19
  config: ProjectConfig,
20
20
  ports: Record<string, number>,
21
- database: SessionDatabase | null = null
21
+ database: SessionDatabase | null = null,
22
+ warning: string | null = null
22
23
  ): string {
23
24
  const lines: string[] = [];
25
+ if (warning) lines.push(`- ${warning}`);
24
26
  const names = Object.entries(ports);
25
27
  if (names.length)
26
28
  lines.push(
@@ -41,8 +41,14 @@ export type PgRun = (
41
41
  env: NodeJS.ProcessEnv
42
42
  ) => Promise<{ code: number; out: string }>;
43
43
 
44
- const pgRun: PgRun = (cmd, args, env) =>
44
+ export const pgRun: PgRun = (cmd, args, env) =>
45
45
  new Promise((resolve) => {
46
+ // A test that reached the real server could list or drop the owner's
47
+ // databases: every test injects its own run.
48
+ if (process.env.VITEST)
49
+ throw new Error(
50
+ "Refusing to run the real Postgres tools under the test runner"
51
+ );
46
52
  execFile(
47
53
  cmd,
48
54
  args,
@@ -66,10 +72,10 @@ const pgRun: PgRun = (cmd, args, env) =>
66
72
  // A plain database name: never a connection string (`host=…`,
67
73
  // `postgres://…`) or an option, which pg_dump would take as one.
68
74
  const DB_NAME = /^[A-Za-z0-9_][A-Za-z0-9_$.-]{0,62}$/;
69
- const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
75
+ export const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
70
76
  // One socket folder: libpq reads a comma as a list of hosts to try in turn,
71
77
  // so `/nope,db.example.com` would reach the second.
72
- const SOCKET_DIR = /^\/[^,=\s'"\\]*$/;
78
+ export const SOCKET_DIR = /^\/[^,=\s'"\\]*$/;
73
79
 
74
80
  // Why `declared` can't be used, or null. A socket folder is local too.
75
81
  export function refusal(declared: DatabaseDecl): string | null {
@@ -86,13 +92,22 @@ export function refusal(declared: DatabaseDecl): string | null {
86
92
  return null;
87
93
  }
88
94
 
89
- const MARK = (sessionId: string) => `agentos session ${sessionId}`;
95
+ export const MARK = (sessionId: string) => `agentos session ${sessionId}`;
90
96
  const PENDING = (sessionId: string) => `${MARK(sessionId)} (copying)`;
91
97
 
98
+ export const sessionHash = (sessionId: string) =>
99
+ createHash("sha256").update(sessionId).digest("hex").slice(0, 12);
100
+
101
+ // The token a session's copy carries, and so does anything a project derives
102
+ // from it (`<copy>_test`, `<from>_test_aos_<hash>`): the one convention that
103
+ // says AgentOS's session made a database.
104
+ const TASK_DB = /_aos_([0-9a-f]{12})(?:_[A-Za-z0-9]+)*$/;
105
+ export const taskHashOf = (name: string): string | null =>
106
+ TASK_DB.exec(name)?.[1] ?? null;
107
+
92
108
  // `<from>_aos_<12 hex of the session id>`, within Postgres's 63 bytes.
93
109
  export function databaseName(from: string, sessionId: string): string {
94
- const hash = createHash("sha256").update(sessionId).digest("hex");
95
- const suffix = `_aos_${hash.slice(0, 12)}`;
110
+ const suffix = `_aos_${sessionHash(sessionId)}`;
96
111
  return `${from.slice(0, 63 - suffix.length)}${suffix}`;
97
112
  }
98
113
 
@@ -122,7 +137,7 @@ const oneLine = (s: string, n = 200) => {
122
137
  };
123
138
 
124
139
  // Every database on the server with its comment, or why there's no answer.
125
- async function listDatabases(
140
+ export async function listDatabases(
126
141
  run: PgRun,
127
142
  env: NodeJS.ProcessEnv
128
143
  ): Promise<Map<string, string> | string> {
@@ -165,7 +180,7 @@ async function copy(
165
180
  // recognised and remade rather than left or reused half-filled.
166
181
  const created = await run(
167
182
  "createdb",
168
- ["-T", "template0", target, PENDING(sessionId)],
183
+ ["-T", "template0", "--", target, PENDING(sessionId)],
169
184
  env
170
185
  );
171
186
  if (created.code !== 0)
@@ -175,7 +190,7 @@ async function copy(
175
190
  try {
176
191
  let step = await run(
177
192
  "pg_dump",
178
- ["--no-owner", "--no-privileges", "-f", dump, from],
193
+ ["--no-owner", "--no-privileges", "-f", dump, "--", from],
179
194
  env
180
195
  );
181
196
  if (step.code === 0) {
@@ -191,7 +206,7 @@ async function copy(
191
206
  );
192
207
  }
193
208
  if (step.code !== 0) {
194
- await run("dropdb", ["--if-exists", target], env);
209
+ await run("dropdb", ["--if-exists", "--", target], env);
195
210
  return `could not copy ${from} into ${target} (${oneLine(step.out, 300)}); ${target} was dropped again`;
196
211
  }
197
212
  return null;
@@ -243,7 +258,7 @@ async function makeDatabase(
243
258
  // An interrupted copy of ours: made again from the start.
244
259
  if (!record({ ...state, name: target, pending: true }))
245
260
  return { ...state, error: ENDED };
246
- const dropped = await run("dropdb", ["--if-exists", target], env);
261
+ const dropped = await run("dropdb", ["--if-exists", "--", target], env);
247
262
  if (dropped.code !== 0)
248
263
  return {
249
264
  ...state,
@@ -399,7 +414,10 @@ async function dropDatabase(
399
414
  console.warn(`[database] ${name} left: ${listed}`);
400
415
  return;
401
416
  }
402
- if (!listed.has(name)) return void forget();
417
+ if (!listed.has(name)) {
418
+ await dropDerived(sessionId, state, listed, run, env);
419
+ return void forget();
420
+ }
403
421
  const comment = listed.get(name);
404
422
  if (comment !== MARK(sessionId) && comment !== PENDING(sessionId)) {
405
423
  console.warn(`[database] ${name} left: AgentOS didn't make it`);
@@ -408,9 +426,52 @@ async function dropDatabase(
408
426
  // The session is over: a dev server left running in its worktree and
409
427
  // still connected is a straggler, ended by --force (Postgres 13+; a plain
410
428
  // drop for an older server).
411
- let dropped = await run("dropdb", ["--if-exists", "--force", name], env);
429
+ let dropped = await run(
430
+ "dropdb",
431
+ ["--if-exists", "--force", "--", name],
432
+ env
433
+ );
412
434
  if (dropped.code !== 0)
413
- dropped = await run("dropdb", ["--if-exists", name], env);
414
- if (dropped.code === 0) forget();
415
- else console.warn(`[database] ${name} left: ${oneLine(dropped.out)}`);
435
+ dropped = await run("dropdb", ["--if-exists", "--", name], env);
436
+ if (dropped.code === 0) {
437
+ await dropDerived(sessionId, state, listed, run, env);
438
+ forget();
439
+ } else console.warn(`[database] ${name} left: ${oneLine(dropped.out)}`);
440
+ }
441
+
442
+ // What the project's own tooling made from the copy (a Rails `_test`
443
+ // database): same session token, never the declared source.
444
+ async function dropDerived(
445
+ sessionId: string,
446
+ state: SessionDatabase,
447
+ listed: Map<string, string>,
448
+ run: PgRun,
449
+ env: NodeJS.ProcessEnv
450
+ ): Promise<void> {
451
+ const hash = sessionHash(sessionId);
452
+ for (const other of listed.keys()) {
453
+ if (other === state.from || other === state.name) continue;
454
+ if (taskHashOf(other) !== hash) continue;
455
+ // A plain drop, and not while anyone is connected: it is another
456
+ // task's or a person's session if the token matches by accident.
457
+ const busy = await run(
458
+ "psql",
459
+ [
460
+ "-X",
461
+ "-d",
462
+ "postgres",
463
+ "-tAq",
464
+ "-c",
465
+ `select count(*) from pg_stat_activity where datname = ${quoteLiteral(other)}`,
466
+ ],
467
+ env
468
+ );
469
+ if (busy.code !== 0 || Number(busy.out.trim()) > 0) {
470
+ console.warn(`[database] ${other} left: connected or unreadable`);
471
+ continue;
472
+ }
473
+ const done = await run("dropdb", ["--if-exists", "--", other], env);
474
+ if (done.code !== 0)
475
+ console.warn(`[database] ${other} left: ${oneLine(done.out)}`);
476
+ }
416
477
  }
@@ -1,17 +1,28 @@
1
1
  /**
2
- * The one reader of a project's config: `agentos.json`, else `.dispatch.json`,
3
- * else the legacy `.agent-os/worktrees.json` / `.agent-os.json`. The first
4
- * file that exists is the config; a broken one is an error, never a reason
5
- * to read the next. Design: docs/decisions/2026-10-10-agentos-json.md.
2
+ * The one reader of a project's config: `agentos.json` and `.dispatch.json`
3
+ * together, else the legacy `.agent-os/worktrees.json` / `.agent-os.json`
4
+ * (the first of those that exists). A broken file is an error, never a
5
+ * reason to read the next or to ignore it.
6
+ *
7
+ * When both agentos.json and .dispatch.json exist they merge by top-level
8
+ * key: agentos.json wins any key it sets, and its value replaces
9
+ * .dispatch.json's whole (no deep merge, so a partial `browse` never mixes
10
+ * with the old one); keys only .dispatch.json has still apply, after its
11
+ * normalization (`workspace` becomes `cards.workspace`, dispatch-only keys
12
+ * are dropped). `inherited` names those keys and `warning` says to move them.
13
+ * Design: docs/decisions/2026-10-10-agentos-json.md.
6
14
  */
7
15
 
8
16
  import * as fs from "fs";
9
17
  import * as path from "path";
10
18
  import type { z } from "zod";
11
19
  import {
20
+ agentosFields,
12
21
  agentosJson,
22
+ dispatchFields,
13
23
  dispatchJson,
14
24
  legacyJson,
25
+ mergedJson,
15
26
  RESERVED_ENV,
16
27
  type ProjectConfig,
17
28
  } from "./schema";
@@ -34,9 +45,20 @@ export interface LoadedConfig {
34
45
  source: string | null;
35
46
  // Why the file was refused, naming it and every bad field.
36
47
  error: string | null;
48
+ // Top-level keys that came from .dispatch.json because agentos.json lacks
49
+ // them; empty unless both files exist.
50
+ inherited: string[];
51
+ // One line for the brief when both files exist; null otherwise.
52
+ warning: string | null;
37
53
  }
38
54
 
39
- const NONE: LoadedConfig = { config: {}, source: null, error: null };
55
+ export const NONE: LoadedConfig = Object.freeze({
56
+ config: {},
57
+ source: null,
58
+ error: null,
59
+ inherited: Object.freeze([]) as unknown as string[],
60
+ warning: null,
61
+ });
40
62
 
41
63
  function describe(file: string, error: z.ZodError): string {
42
64
  const fields = error.issues.map((i) => {
@@ -61,7 +83,9 @@ function fromLegacy(raw: z.infer<typeof legacyJson>): ProjectConfig {
61
83
  return config;
62
84
  }
63
85
 
64
- function parse(file: string, text: string): LoadedConfig {
86
+ // `checked: false` leaves the port-name check to the merge, which can see
87
+ // the ports the other file declares.
88
+ function parse(file: string, text: string, checked = true): LoadedConfig {
65
89
  let raw: unknown;
66
90
  try {
67
91
  raw = JSON.parse(text);
@@ -73,31 +97,102 @@ function parse(file: string, text: string): LoadedConfig {
73
97
  };
74
98
  }
75
99
  if (file === "agentos.json") {
76
- const got = agentosJson.safeParse(raw);
100
+ const got = (checked ? agentosJson : agentosFields).safeParse(raw);
77
101
  return got.success
78
- ? { config: got.data, source: file, error: null }
102
+ ? { ...NONE, config: got.data, source: file }
79
103
  : { ...NONE, source: file, error: describe(file, got.error) };
80
104
  }
81
105
  if (file === ".dispatch.json") {
82
- const got = dispatchJson.safeParse(raw);
106
+ const got = (checked ? dispatchJson : dispatchFields).safeParse(raw);
83
107
  if (!got.success)
84
108
  return { ...NONE, source: file, error: describe(file, got.error) };
85
109
  const { workspace, ...config } = got.data;
86
110
  if (workspace && !config.cards?.workspace)
87
111
  config.cards = { ...config.cards, workspace };
88
- return { config, source: file, error: null };
112
+ return { ...NONE, config, source: file };
89
113
  }
90
114
  const got = legacyJson.safeParse(raw);
91
115
  return got.success
92
- ? { config: fromLegacy(got.data), source: file, error: null }
116
+ ? { ...NONE, config: fromLegacy(got.data), source: file }
93
117
  : { ...NONE, source: file, error: describe(file, got.error) };
94
118
  }
95
119
 
120
+ export const bothFilesWarning = (inherited: string[]): string =>
121
+ `This repo has both agentos.json and .dispatch.json; keys ${inherited.join(", ")} come from .dispatch.json. Move them into agentos.json and delete .dispatch.json.`;
122
+
123
+ function merge(agentos: ProjectConfig, dispatch: ProjectConfig): LoadedConfig {
124
+ const inherited = Object.keys(dispatch).filter((k) => !(k in agentos));
125
+ const config = {
126
+ ...Object.fromEntries(
127
+ inherited.map((k) => [k, dispatch[k as keyof ProjectConfig]])
128
+ ),
129
+ ...agentos,
130
+ } as ProjectConfig;
131
+ const got = mergedJson.safeParse(config);
132
+ if (!got.success)
133
+ return {
134
+ ...NONE,
135
+ source: "agentos.json",
136
+ error: describe("agentos.json with .dispatch.json", got.error),
137
+ };
138
+ return {
139
+ ...NONE,
140
+ config,
141
+ source: "agentos.json",
142
+ inherited,
143
+ warning: inherited.length ? bothFilesWarning(inherited) : null,
144
+ };
145
+ }
146
+
147
+ function statOf(projectPath: string, file: string): fs.Stats | null {
148
+ try {
149
+ const stat = fs.statSync(path.join(projectPath, file));
150
+ return stat.isFile() ? stat : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function readFile(projectPath: string, file: string): string | LoadedConfig {
157
+ try {
158
+ return fs.readFileSync(path.join(projectPath, file), "utf-8");
159
+ } catch (e) {
160
+ return {
161
+ ...NONE,
162
+ source: file,
163
+ error: `${file} could not be read: ${(e as Error).message}`,
164
+ };
165
+ }
166
+ }
167
+
168
+ // Both files, merged. A broken one is the error, whichever it is.
169
+ function loadBoth(projectPath: string): LoadedConfig {
170
+ const parsed: LoadedConfig[] = [];
171
+ for (const file of ["agentos.json", ".dispatch.json"]) {
172
+ const text = readFile(projectPath, file);
173
+ if (typeof text !== "string") return text;
174
+ const got = parse(file, text, false);
175
+ if (got.error) return got;
176
+ parsed.push(got);
177
+ }
178
+ return merge(parsed[0].config, parsed[1].config);
179
+ }
180
+
96
181
  // Keyed on the project; reused while the file's mtime and size hold, since
97
182
  // agentEnv reads it for every terminal and chat start.
98
183
  const cache = new Map<string, { stamp: string; loaded: LoadedConfig }>();
99
184
 
100
185
  export function loadProjectConfig(projectPath: string): LoadedConfig {
186
+ const a = statOf(projectPath, "agentos.json");
187
+ const d = statOf(projectPath, ".dispatch.json");
188
+ if (a && d) {
189
+ const stamp = `both:${a.mtimeMs}:${a.size}:${d.mtimeMs}:${d.size}`;
190
+ const hit = cache.get(projectPath);
191
+ if (hit?.stamp === stamp) return hit.loaded;
192
+ const loaded = loadBoth(projectPath);
193
+ cache.set(projectPath, { stamp, loaded });
194
+ return loaded;
195
+ }
101
196
  for (const file of CONFIG_FILES) {
102
197
  const full = path.join(projectPath, file);
103
198
  let stat: fs.Stats;
@@ -110,16 +205,8 @@ export function loadProjectConfig(projectPath: string): LoadedConfig {
110
205
  const stamp = `${file}:${stat.mtimeMs}:${stat.size}`;
111
206
  const hit = cache.get(projectPath);
112
207
  if (hit?.stamp === stamp) return hit.loaded;
113
- let text: string;
114
- try {
115
- text = fs.readFileSync(full, "utf-8");
116
- } catch (e) {
117
- return {
118
- ...NONE,
119
- source: file,
120
- error: `${file} could not be read: ${(e as Error).message}`,
121
- };
122
- }
208
+ const text = readFile(projectPath, file);
209
+ if (typeof text !== "string") return text;
123
210
  const loaded = parse(file, text);
124
211
  cache.set(projectPath, { stamp, loaded });
125
212
  return loaded;
@@ -228,13 +228,19 @@ function portRefs(
228
228
 
229
229
  // agentos.json: an unknown key is an error, because it is nearly always a
230
230
  // typo that would otherwise be silently ignored.
231
- export const agentosJson = z.strictObject(shape).superRefine(portRefs);
231
+ export const agentosFields = z.strictObject(shape);
232
+ export const agentosJson = agentosFields.superRefine(portRefs);
232
233
 
233
234
  // .dispatch.json: the same fields, but dispatch has keys of its own. Its
234
235
  // top-level `workspace` is `cards.workspace` here.
235
- export const dispatchJson = z
236
- .object({ ...shape, workspace: z.string().optional() })
237
- .superRefine(portRefs);
236
+ export const dispatchFields = z.object({
237
+ ...shape,
238
+ workspace: z.string().optional(),
239
+ });
240
+ export const dispatchJson = dispatchFields.superRefine(portRefs);
241
+
242
+ // The cross-key port check on its own, for a config merged from two files.
243
+ export const mergedJson = z.looseObject(shape).superRefine(portRefs);
238
244
 
239
245
  export const legacyJson = z.object({
240
246
  setup: z.array(z.string()).optional(),
@@ -4,11 +4,18 @@
4
4
  * an agent editing agentos.json on its branch can't change its own launch.
5
5
  */
6
6
 
7
+ import fs from "fs";
7
8
  import os from "os";
9
+ import { sessionTmpDir } from "../sessions/leftovers";
8
10
  import { db } from "../db";
9
11
  import { getProject } from "../projects";
10
12
  import { sessionPorts } from "../ports";
11
- import { loadProjectConfig, projectEnv, type LoadedConfig } from "./index";
13
+ import {
14
+ loadProjectConfig,
15
+ NONE,
16
+ projectEnv,
17
+ type LoadedConfig,
18
+ } from "./index";
12
19
  import { runningBrief } from "./brief";
13
20
  import { databaseEnv, sessionDatabase } from "./database";
14
21
 
@@ -29,12 +36,25 @@ export function sessionConfig(
29
36
  sessionId: string
30
37
  ): LoadedConfig & { ports: Record<string, number> | null } {
31
38
  const projectPath = projectPathOf(sessionId);
32
- const loaded = projectPath
33
- ? loadProjectConfig(projectPath)
34
- : { config: {}, source: null, error: null };
39
+ const loaded = projectPath ? loadProjectConfig(projectPath) : NONE;
35
40
  return { ...loaded, ports: sessionPorts(sessionId) };
36
41
  }
37
42
 
43
+ // A task's own temp folder, so what it leaves there is removed with it.
44
+ // Only a session in a worktree: an orchestrator or a terminal in a checkout
45
+ // is long-lived and keeps the system's.
46
+ function taskTmpEnv(sessionId: string): Record<string, string> {
47
+ const row = db
48
+ .prepare(`SELECT worktree_path FROM sessions WHERE id = ?`)
49
+ .get(sessionId) as { worktree_path: string | null } | undefined;
50
+ if (!row?.worktree_path) return {};
51
+ const dir = sessionTmpDir(sessionId);
52
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ // A folder left by an earlier run keeps its old mode otherwise.
54
+ fs.chmodSync(dir, 0o700);
55
+ return { TMPDIR: dir };
56
+ }
57
+
38
58
  // The project's env and the session's ports. Never throws: a terminal or a
39
59
  // chat must still open when the config can't be read.
40
60
  export function sessionProjectEnv(sessionId: string): Record<string, string> {
@@ -43,6 +63,7 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
43
63
  return {
44
64
  ...projectEnv(config, ports),
45
65
  ...(config.database ? databaseEnv(sessionDatabase(sessionId)) : {}),
66
+ ...taskTmpEnv(sessionId),
46
67
  };
47
68
  } catch (error) {
48
69
  console.error(`[project-config] env for ${sessionId}:`, error);
@@ -52,9 +73,9 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
52
73
 
53
74
  export function sessionRunningBrief(sessionId: string): string {
54
75
  try {
55
- const { config, source, ports } = sessionConfig(sessionId);
76
+ const { config, source, warning, ports } = sessionConfig(sessionId);
56
77
  return source
57
- ? runningBrief(config, ports ?? {}, sessionDatabase(sessionId))
78
+ ? runningBrief(config, ports ?? {}, sessionDatabase(sessionId), warning)
58
79
  : "";
59
80
  } catch (error) {
60
81
  console.error(`[project-config] brief for ${sessionId}:`, error);
@@ -146,10 +146,13 @@ export function originAllowed(
146
146
 
147
147
  const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
148
148
 
149
- // LumifyHub's approve page sends the browser back here, so this one URL is
150
- // reached by a cross-site top-level navigation. It acts only on a `state`
151
- // this instance issued, which a foreign page can't know.
152
- const CROSS_SITE_NAVIGATIONS = ["/api/lumifyhub/callback"];
149
+ // LumifyHub's approve page and an MCP server's sign-in send the browser back
150
+ // here, so these URLs are reached by a cross-site top-level navigation. Each
151
+ // acts only on a `state` this instance issued, which a foreign page can't know.
152
+ const CROSS_SITE_NAVIGATIONS = [
153
+ "/api/lumifyhub/callback",
154
+ "/api/mcp-auth/callback",
155
+ ];
153
156
 
154
157
  function crossSiteNavigationAllowed(req: {
155
158
  method?: string;
@@ -10,10 +10,10 @@
10
10
  * address. AgentOS never runs `tailscale serve` or funnel.
11
11
  */
12
12
 
13
+ import { agentosHome } from "../agentos-home";
13
14
  import { execFile } from "child_process";
14
15
  import fs from "fs";
15
16
  import https from "https";
16
- import os from "os";
17
17
  import path from "path";
18
18
  import type { IncomingMessage, ServerResponse } from "http";
19
19
  import type { Socket } from "net";
@@ -76,7 +76,7 @@ function runCert(
76
76
  /** Gets (or refreshes) the certificate; null when the tailnet can't issue one. */
77
77
  export async function tailnetCert(
78
78
  log: (line: string) => void = console.log,
79
- dir = path.join(os.homedir(), ".agent-os", "tls")
79
+ dir = agentosHome("tls")
80
80
  ) {
81
81
  const ts = await tailscaleStatus();
82
82
  const bin = tailscaleBinary();