@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.
- package/app/api/audits/[kind]/[id]/route.test.ts +2 -2
- package/app/api/mcp-auth/callback/route.test.ts +42 -0
- package/app/api/mcp-auth/callback/route.ts +56 -0
- package/app/api/projects/init/route.test.ts +2 -4
- package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
- package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
- package/app/api/sessions/[id]/route.ts +3 -3
- package/app/api/shares/route.test.ts +3 -4
- package/app/api/workspaces/[id]/route.ts +9 -1
- package/app/api/worktree-sweep/route.test.ts +40 -1
- package/app/api/worktree-sweep/route.ts +45 -6
- package/components/Chat/ChatPanel.tsx +6 -3
- package/components/Chat/McpServers.tsx +61 -24
- package/components/Chat/McpSignIn.tsx +122 -0
- package/components/Chat/session.ts +8 -0
- package/components/Health/SweepRuns.tsx +77 -1
- package/components/Workspaces/WorkspaceSettings.tsx +6 -0
- package/data/chat/mcpAuth.ts +42 -0
- package/data/workspaces/index.ts +6 -0
- package/data/worktree-sweep/index.ts +15 -0
- package/lib/agentos-home.ts +11 -0
- package/lib/agents/launch.ts +3 -7
- package/lib/artifacts/store.ts +2 -5
- package/lib/chat/driver.ts +24 -2
- package/lib/chat/drivers/claude-approvals.ts +7 -0
- package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
- package/lib/chat/drivers/claude.ts +7 -0
- package/lib/chat/drivers/pi-approvals.ts +2 -2
- package/lib/chat/events.ts +12 -0
- package/lib/chat/mcp-auth.ts +193 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +18 -0
- package/lib/chat/worker/client.ts +35 -1
- package/lib/chat/worker/host.ts +9 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/mcp-auth.ts +119 -0
- package/lib/chat/worker/protocol.ts +21 -2
- package/lib/connect/config.ts +2 -3
- package/lib/db/backup.ts +2 -2
- package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -0
- package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
- package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
- package/lib/db/types.ts +3 -0
- package/lib/dev-instance/processInfo.ts +48 -27
- package/lib/dev-instance/startDevServer.ts +1 -1
- package/lib/dev-instance/stopDevServer.ts +20 -17
- package/lib/dev-servers.ts +2 -1
- package/lib/done/index.ts +2 -2
- package/lib/done/testing.ts +2 -2
- package/lib/env-setup.ts +8 -1
- package/lib/health/boots.ts +31 -0
- package/lib/health/ci.ts +23 -2
- package/lib/health/detectors.ts +2 -1
- package/lib/health/evidence.ts +1 -1
- package/lib/health/facts.ts +3 -3
- package/lib/health/fixtures.ts +1 -0
- package/lib/health/tick.ts +6 -1
- package/lib/health/types.ts +2 -0
- package/lib/orchestrator/armed.test-helpers.ts +3 -2
- package/lib/orchestrator/brief.ts +14 -9
- package/lib/orchestrator/external-gates.ts +62 -1
- package/lib/orchestrator/full-access.ts +21 -0
- package/lib/orchestrator/home.ts +2 -5
- package/lib/orchestrator/quick.ts +47 -4
- package/lib/orchestrator/serve.ts +11 -2
- package/lib/orchestrator/shell-guard.ts +24 -0
- package/lib/orchestrator/signoff.test-helpers.ts +3 -2
- package/lib/orchestrator/sweep-arm.ts +180 -0
- package/lib/orchestrator/tool-names.ts +4 -1
- package/lib/orchestrator/tool-schemas.ts +33 -1
- package/lib/orchestrator/tools.ts +6 -2
- package/lib/orchestrator/write-guard.ts +37 -9
- package/lib/plans/features.ts +2 -2
- package/lib/program-status/claude-hooks.ts +2 -2
- package/lib/program-status/tap.ts +2 -2
- package/lib/project-config/brief.ts +3 -1
- package/lib/project-config/database.ts +77 -16
- package/lib/project-config/index.ts +108 -21
- package/lib/project-config/schema.ts +10 -4
- package/lib/project-config/session.ts +27 -6
- package/lib/security/net.ts +7 -4
- package/lib/security/tailnet-https.ts +2 -2
- package/lib/session-artifacts/collect.ts +18 -1
- package/lib/session-artifacts/keep.ts +2 -4
- package/lib/session-audit/store.ts +2 -5
- package/lib/session-logs/folder.ts +2 -3
- package/lib/sessions/launch.ts +2 -2
- package/lib/sessions/leftovers.ts +61 -0
- package/lib/simulator/agent-device.ts +2 -2
- package/lib/tasks/finish.ts +2 -2
- package/lib/tasks/import.ts +3 -5
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/move.ts +2 -2
- package/lib/tasks/moved-away.ts +2 -0
- package/lib/tasks/state.ts +7 -3
- package/lib/tasks/ui-brief.ts +2 -2
- package/lib/test-temp.ts +30 -0
- package/lib/workspaces.ts +6 -2
- package/lib/worktree-deps/fastcopy.ts +2 -2
- package/lib/worktree-deps/spare.ts +2 -2
- package/lib/worktree-sweep/caches.ts +129 -0
- package/lib/worktree-sweep/databases.ts +222 -0
- package/lib/worktree-sweep/du.ts +13 -0
- package/lib/worktree-sweep/extras.ts +196 -0
- package/lib/worktree-sweep/findOrphans.ts +19 -8
- package/lib/worktree-sweep/report.ts +86 -0
- package/lib/worktree-sweep/runSweep.ts +46 -13
- package/lib/worktree-sweep/settings.ts +24 -0
- package/lib/worktree-sweep/state.ts +5 -0
- package/lib/worktree-sweep/store.ts +11 -0
- package/lib/worktree-sweep/testTemp.ts +113 -0
- package/lib/worktree-sweep/types.ts +46 -1
- package/lib/worktrees.ts +3 -0
- package/package.json +2 -1
- package/scripts/autodeploy.test.ts +2 -2
- package/scripts/check-bundle.test.ts +2 -2
- package/scripts/disk-dry-run.ts +57 -0
- package/scripts/hooks/banned-terms.test.ts +3 -2
- package/scripts/hooks/staged-secrets.test.ts +2 -2
- package/scripts/lib/build-swap.test.ts +2 -3
- package/scripts/lib/wsl.test.ts +2 -3
- package/scripts/update-run.test.ts +2 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where an orchestrator may write
|
|
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:
|
|
304
|
+
audits: agentosHome("audits"),
|
|
300
305
|
quick: quickWorktrees(session.id),
|
|
301
|
-
state:
|
|
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<
|
|
369
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/lib/plans/features.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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))
|
|
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(
|
|
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)
|
|
415
|
-
|
|
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
|
|
3
|
-
* else the legacy `.agent-os/worktrees.json` / `.agent-os.json
|
|
4
|
-
*
|
|
5
|
-
* to read the next
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
236
|
-
|
|
237
|
-
.
|
|
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 {
|
|
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);
|
package/lib/security/net.ts
CHANGED
|
@@ -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
|
|
150
|
-
// reached by a cross-site top-level navigation.
|
|
151
|
-
// this instance issued, which a foreign page can't know.
|
|
152
|
-
const CROSS_SITE_NAVIGATIONS = [
|
|
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 =
|
|
79
|
+
dir = agentosHome("tls")
|
|
80
80
|
) {
|
|
81
81
|
const ts = await tailscaleStatus();
|
|
82
82
|
const bin = tailscaleBinary();
|