@saadnvd1/agent-os 0.3.654 → 0.3.658
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/health/env-failures/route.test.ts +60 -0
- package/app/api/health/env-failures/route.ts +51 -0
- package/app/api/shares/pdf/route.test.ts +122 -0
- package/app/api/shares/pdf/route.ts +31 -0
- package/app/api/shares/preview/route.ts +5 -0
- package/components/Health/EnvFailures/ClassRow.tsx +104 -0
- package/components/Health/EnvFailures/SessionList.tsx +49 -0
- package/components/Health/EnvFailures/index.tsx +46 -0
- package/components/Health/HealthPanel.tsx +11 -1
- package/components/Share/DownloadMenu.tsx +112 -0
- package/components/Share/ShareDialog.tsx +31 -47
- package/data/health/index.ts +31 -0
- package/data/shares/queries.ts +11 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/agents/self-env.ts +1 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/db/migrations/20261016T1800_env_failures.ts +33 -0
- package/lib/dev-instance/index.ts +6 -0
- package/lib/dev-instance/liveServer.ts +57 -0
- package/lib/dev-instance/pickPort.ts +66 -0
- package/lib/dev-instance/processInfo.ts +68 -0
- package/lib/dev-instance/spawnServer.ts +36 -0
- package/lib/dev-instance/startDevServer.ts +80 -0
- package/lib/dev-instance/stopDevServer.ts +98 -0
- package/lib/dev-instance/types.ts +13 -0
- package/lib/health/config.ts +8 -0
- package/lib/health/env/classes.ts +121 -0
- package/lib/health/env/classify.ts +67 -0
- package/lib/health/env/envTick.ts +46 -0
- package/lib/health/env/fixText.ts +24 -0
- package/lib/health/env/helpers.test-util.ts +21 -0
- package/lib/health/env/index.ts +6 -0
- package/lib/health/env/raiseAlerts.ts +84 -0
- package/lib/health/env/recordHit.ts +62 -0
- package/lib/health/env/sendFixNotes.ts +80 -0
- package/lib/health/env/summarize.ts +94 -0
- package/lib/health/env/types.ts +73 -0
- package/lib/health/tick.ts +6 -0
- package/lib/orchestrator/brief.ts +1 -1
- package/lib/orchestrator/shell-guard.ts +43 -10
- package/lib/orchestrator/write-guard.ts +52 -0
- package/lib/security/demo.ts +1 -0
- package/lib/share/pdf.ts +116 -0
- package/lib/share/view.ts +9 -1
- package/package.json +5 -3
- package/scripts/dev-stop.ts +7 -0
- package/scripts/dev-task.ts +16 -0
- package/scripts/screenshots/demo.ts +4 -8
- package/scripts/screenshots/run.ts +5 -8
- package/server.ts +25 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What the environment-failure detector knows and shows. Plain data, no I/O.
|
|
2
|
+
|
|
3
|
+
export interface EnvClass {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
// shell: only shell tools' output; guard: any tool's refused call.
|
|
7
|
+
scope: "shell" | "guard";
|
|
8
|
+
// Any of these, tested per line.
|
|
9
|
+
match: RegExp[];
|
|
10
|
+
// Also has to hold, tested on the whole output.
|
|
11
|
+
requires?: RegExp;
|
|
12
|
+
// Vetoes: a command or output that shows it isn't this machine's fault.
|
|
13
|
+
except?: { command?: RegExp; output?: RegExp };
|
|
14
|
+
// Sent once to a session that hit it; null means record and escalate only.
|
|
15
|
+
fix: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EnvHit {
|
|
19
|
+
class: string;
|
|
20
|
+
line: string;
|
|
21
|
+
command: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EnvFailureRow {
|
|
25
|
+
id: number;
|
|
26
|
+
session_id: string;
|
|
27
|
+
class: string;
|
|
28
|
+
project_id: string | null;
|
|
29
|
+
workspace_id: string | null;
|
|
30
|
+
command: string | null;
|
|
31
|
+
line: string | null;
|
|
32
|
+
last_item_id: string | null;
|
|
33
|
+
hits: number;
|
|
34
|
+
first_at: number;
|
|
35
|
+
last_at: number;
|
|
36
|
+
nudged_at: number | null;
|
|
37
|
+
nudge_skipped: string | null;
|
|
38
|
+
alerted: string | null;
|
|
39
|
+
alerted_at: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EnvClassView {
|
|
43
|
+
id: string;
|
|
44
|
+
label: string;
|
|
45
|
+
fix: string | null;
|
|
46
|
+
fixOverridden: boolean;
|
|
47
|
+
sessions7: number;
|
|
48
|
+
sessions30: number;
|
|
49
|
+
projects: {
|
|
50
|
+
id: string | null;
|
|
51
|
+
name: string;
|
|
52
|
+
sessions7: number;
|
|
53
|
+
sessions30: number;
|
|
54
|
+
}[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EnvSummary {
|
|
58
|
+
now: number;
|
|
59
|
+
classes: EnvClassView[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EnvSessionView {
|
|
63
|
+
sessionId: string;
|
|
64
|
+
name: string;
|
|
65
|
+
project: string | null;
|
|
66
|
+
command: string | null;
|
|
67
|
+
line: string | null;
|
|
68
|
+
hits: number;
|
|
69
|
+
firstAt: number;
|
|
70
|
+
lastAt: number;
|
|
71
|
+
// "sent", or why not.
|
|
72
|
+
fix: string;
|
|
73
|
+
}
|
package/lib/health/tick.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { leaveHeartbeat } from "./boots";
|
|
|
10
10
|
import { healthConfig, type HealthConfig } from "./config";
|
|
11
11
|
import { runDetectors } from "./detectors";
|
|
12
12
|
import { gatherFacts, liveBackgroundJobs } from "./facts";
|
|
13
|
+
import { envTick, realEnv, type EnvDeps } from "./env/envTick";
|
|
13
14
|
import { addNote } from "./notes";
|
|
14
15
|
import { closeResolvedPieces } from "./pieces";
|
|
15
16
|
import { deliver, deliverClosed, type RouteDeps, realRoute } from "./route";
|
|
@@ -52,6 +53,7 @@ export interface TickDeps {
|
|
|
52
53
|
facts: (now: number, c: HealthConfig) => HealthFacts;
|
|
53
54
|
watch: WatchDeps;
|
|
54
55
|
route: RouteDeps;
|
|
56
|
+
env: EnvDeps;
|
|
55
57
|
wake: typeof wake;
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -60,6 +62,7 @@ export const realTick: TickDeps = {
|
|
|
60
62
|
facts: gatherFacts,
|
|
61
63
|
watch: { run: runClaude, usagePct: currentUsagePct },
|
|
62
64
|
route: realRoute,
|
|
65
|
+
env: realEnv,
|
|
63
66
|
wake,
|
|
64
67
|
};
|
|
65
68
|
|
|
@@ -137,6 +140,9 @@ export function healthTick(
|
|
|
137
140
|
closeResolvedPieces(now);
|
|
138
141
|
leaveHeartbeat(now, liveBackgroundJobs());
|
|
139
142
|
giveUpStale(now, deps);
|
|
143
|
+
void envTick(now, c, deps.env).catch((error) =>
|
|
144
|
+
console.error("[health] environment failures:", error)
|
|
145
|
+
);
|
|
140
146
|
|
|
141
147
|
const running: Promise<void>[] = [];
|
|
142
148
|
for (const row of openAnomalies()) {
|
|
@@ -160,7 +160,7 @@ Acting:
|
|
|
160
160
|
|
|
161
161
|
The MCP servers this workspace allows (Settings → Workspaces) are yours to use through their own tools, for reading and for additive, reversible changes. Anything outbound through them (sending a message, an email, a post) is a hard line: park it with \`ask_saad\`.
|
|
162
162
|
|
|
163
|
-
Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs anything without asking (Saad chose this on 2026-10-11), so use it to read and investigate yourself: \`gh pr list\`, \`gh run view --log-failed\`, \`gh api\` GETs, \`curl\`, \`sqlite3 -readonly\`, git in any repository, a skill's scripts, pipes and chains. Never use it to get around the tools. A deny list refuses, naming the tool to use instead: merging, closing or retargeting a PR (\`${TOOL_NAMES.sign_off}\`, \`${TOOL_NAMES.arm}\`, \`${TOOL_NAMES.land}\`, \`${TOOL_NAMES.drop}\`), \`git push\` (\`${TOOL_NAMES.quick_pr}\`), writing in a project repository or task worktree other than your own quick changes (\`${TOOL_NAMES.start_task}\`, \`${TOOL_NAMES.quick_change}\`), writing AgentOS's state (sqlite3 without \`-readonly\`, or files in ~/.agent-os outside your folder), stopping or restarting AgentOS or its tmux, and reading secret files (env files, keys, ~/.ssh, ~/.aws). Tokenvault is available through \`tv\`: use a token inside the command that needs it (e.g. \`FOO="\$(tv get <project> <desc>)" cmd\`), never print, echo or log a value, and never send one off the machine except to the service it's for. The list catches honest mistakes, not a determined bypass: a script or \`python -c\` could do any of these unseen, and doing so would break Saad's trust in every gate. \`aos notify "<text>"\` pushes a message to Saad's phone. Use it for what he'd want to see now and asked for: the morning report, a real milestone (a stack landed, a blocker only he can clear). Never for routine status: one a minute at most, and he reads the rest here. You can read files anywhere (Read, Grep, Glob), and write them (Write, Edit, NotebookEdit) anywhere except: a project repository or task worktree other than your own open quick changes' worktrees (start a task, or a quick change for a small edit, so the change goes through CI, review and scope), AgentOS's own state in ~/.agent-os outside your folder, secret files (.env, keys, ~/.ssh, ~/.aws, tokenvault and the like), and files that make something run: dotfiles and dot-folders at the top of your home (shell, git, Claude and tool config), ~/Library and /Library's launch agents, git's core.hooksPath folders, the folders on PATH, and any .git, .claude, .mcp.json, CLAUDE.md or AGENTS.md, which would hand you a shell or run code around the gates. Each write is checked before it runs, on the path with symlinks and \`..\` resolved; a refused one says why.
|
|
163
|
+
Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs anything without asking (Saad chose this on 2026-10-11), so use it to read and investigate yourself: \`gh pr list\`, \`gh run view --log-failed\`, \`gh api\` GETs, \`curl\`, \`sqlite3 -readonly\`, git in any repository, a skill's scripts, pipes and chains. Never use it to get around the tools. A deny list refuses, naming the tool to use instead: merging, closing or retargeting a PR (\`${TOOL_NAMES.sign_off}\`, \`${TOOL_NAMES.arm}\`, \`${TOOL_NAMES.land}\`, \`${TOOL_NAMES.drop}\`), \`git push\` (\`${TOOL_NAMES.quick_pr}\`), writing in a project repository or task worktree other than your own quick changes, except in a project checkout of your own workspace, where you may update and move the branch: \`git pull\` in any form, \`merge\`, \`reset\` (even \`--hard\`), \`checkout\`, \`switch\` and \`rebase\` (also as \`git -C <path>\`), then \`bundle install\`, \`npm install\` or \`bin/rails db:prepare\`. Commits, \`stash\`, \`clean\`, \`restore\` and file edits there still go through tasks and quick changes, and other workspaces' repositories, task worktrees and AgentOS's own live checkout (autodeploy deploys it) are refused for all of it are refused (\`${TOOL_NAMES.start_task}\`, \`${TOOL_NAMES.quick_change}\`), writing AgentOS's state (sqlite3 without \`-readonly\`, or files in ~/.agent-os outside your folder), stopping or restarting AgentOS or its tmux, and reading secret files (env files, keys, ~/.ssh, ~/.aws). Tokenvault is available through \`tv\`: use a token inside the command that needs it (e.g. \`FOO="\$(tv get <project> <desc>)" cmd\`), never print, echo or log a value, and never send one off the machine except to the service it's for. The list catches honest mistakes, not a determined bypass: a script or \`python -c\` could do any of these unseen, and doing so would break Saad's trust in every gate. \`aos notify "<text>"\` pushes a message to Saad's phone. Use it for what he'd want to see now and asked for: the morning report, a real milestone (a stack landed, a blocker only he can clear). Never for routine status: one a minute at most, and he reads the rest here. You can read files anywhere (Read, Grep, Glob), and write them (Write, Edit, NotebookEdit) anywhere except: a project repository or task worktree other than your own open quick changes' worktrees (start a task, or a quick change for a small edit, so the change goes through CI, review and scope), AgentOS's own state in ~/.agent-os outside your folder, secret files (.env, keys, ~/.ssh, ~/.aws, tokenvault and the like), and files that make something run: dotfiles and dot-folders at the top of your home (shell, git, Claude and tool config), ~/Library and /Library's launch agents, git's core.hooksPath folders, the folders on PATH, and any .git, .claude, .mcp.json, CLAUDE.md or AGENTS.md, which would hand you a shell or run code around the gates. Each write is checked before it runs, on the path with symlinks and \`..\` resolved; a refused one says why.
|
|
164
164
|
|
|
165
165
|
## Untrusted text
|
|
166
166
|
|
|
@@ -29,6 +29,10 @@ export interface ShellContext {
|
|
|
29
29
|
// Whether this path is under ~/.agent-os/audits, where reports go and
|
|
30
30
|
// nothing may be made executable.
|
|
31
31
|
inAudits?: (p: string) => Promise<boolean>;
|
|
32
|
+
// What a folder is for fast-forwarding: a project checkout of this
|
|
33
|
+
// workspace ("own"), the checkout AgentOS's live server and autodeploy run
|
|
34
|
+
// from ("live"), or anything else.
|
|
35
|
+
checkoutKind?: (dir: string) => Promise<"own" | "live" | "other">;
|
|
32
36
|
// AgentOS's server and the tmux servers it runs sessions in.
|
|
33
37
|
protectedPids: () => Promise<Set<number>>;
|
|
34
38
|
}
|
|
@@ -324,22 +328,40 @@ const GIT_WRITES = new Set([
|
|
|
324
328
|
|
|
325
329
|
const READ_ONLY_STASH = new Set(["list", "show"]);
|
|
326
330
|
|
|
331
|
+
// What an orchestrator may do to a project checkout of its own workspace:
|
|
332
|
+
// bring it up to date and move its branch (Saad, 2026-10-18). Commits, stash,
|
|
333
|
+
// clean and file edits still go through tasks and quick changes.
|
|
334
|
+
const CHECKOUT_UPDATES = new Set([
|
|
335
|
+
"pull",
|
|
336
|
+
"merge",
|
|
337
|
+
"reset",
|
|
338
|
+
"checkout",
|
|
339
|
+
"switch",
|
|
340
|
+
"rebase",
|
|
341
|
+
]);
|
|
342
|
+
|
|
327
343
|
async function gitRefusal(
|
|
328
|
-
|
|
344
|
+
inv: Invocation,
|
|
329
345
|
ctx: ShellContext,
|
|
330
346
|
cwd: string
|
|
331
347
|
): Promise<string | null> {
|
|
348
|
+
const args = inv.args;
|
|
349
|
+
// Every repository the command names: the folder after each -C, and each
|
|
350
|
+
// git dir and work tree, by option or environment.
|
|
351
|
+
const named = new Set<string>();
|
|
332
352
|
let dir = cwd;
|
|
333
353
|
let i = 0;
|
|
334
354
|
for (; i < args.length; i++) {
|
|
335
355
|
const a = args[i];
|
|
336
356
|
if (a === "-C" || a === "--git-dir" || a === "--work-tree") {
|
|
337
|
-
|
|
357
|
+
const to = path.resolve(dir, args[++i] ?? "");
|
|
358
|
+
if (a === "-C") dir = to;
|
|
359
|
+
named.add(to);
|
|
338
360
|
continue;
|
|
339
361
|
}
|
|
340
362
|
const eq = /^--(git-dir|work-tree)=(.*)$/.exec(a);
|
|
341
363
|
if (eq) {
|
|
342
|
-
|
|
364
|
+
named.add(path.resolve(dir, eq[2]));
|
|
343
365
|
continue;
|
|
344
366
|
}
|
|
345
367
|
if (a === "-c" || a === "--namespace" || a === "--exec-path") {
|
|
@@ -349,17 +371,28 @@ async function gitRefusal(
|
|
|
349
371
|
if (a.startsWith("-")) continue;
|
|
350
372
|
break;
|
|
351
373
|
}
|
|
374
|
+
named.add(dir);
|
|
375
|
+
for (const w of inv.cmd.words) {
|
|
376
|
+
const env = /^GIT_(?:DIR|WORK_TREE)=(.*)$/.exec(w);
|
|
377
|
+
if (env) named.add(path.resolve(cwd, env[1]));
|
|
378
|
+
}
|
|
352
379
|
const sub = args[i];
|
|
353
380
|
if (sub === "push")
|
|
354
381
|
return `Refused: ${T.quick_pr} pushes a quick change's branch and opens its PR, and tasks push their own; nothing pushes from the orchestrator's shell.`;
|
|
355
382
|
if (!GIT_WRITES.has(sub)) return null;
|
|
356
383
|
if (sub === "stash" && READ_ONLY_STASH.has(args[i + 1])) return null;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
384
|
+
for (const d of named) {
|
|
385
|
+
if (has(d))
|
|
386
|
+
return `Refused: git ${sub} in a folder only the shell knows (${show(d)}); name the folder.`;
|
|
387
|
+
if (!(await ctx.writeRefusal(d))) continue;
|
|
388
|
+
const kind = (await ctx.checkoutKind?.(d)) ?? "other";
|
|
389
|
+
const updates = CHECKOUT_UPDATES.has(sub);
|
|
390
|
+
if (kind === "live" && updates)
|
|
391
|
+
return `Refused: git ${sub} in ${d}. This is the checkout AgentOS's live server runs from; autodeploy deploys it (restart gap and health check), and changing it here would swap the code under the running server. For a manual deploy use scripts/redeploy.`;
|
|
392
|
+
if (kind === "own" && updates) continue;
|
|
393
|
+
return `Refused: git ${sub} in ${d}. Orchestrators don't change project checkouts except to update them: in a project checkout of your own workspace you may \`git pull\` (any form), \`merge\`, \`reset\`, \`checkout\`, \`switch\` and \`rebase\`. Commits, \`stash\`, \`clean\`, \`restore\` and file edits go through ${T.start_task} or ${T.quick_change}; other workspaces' repositories, task worktrees and AgentOS's live checkout are never changed from here. To see current code: \`git -C ${d} fetch\` then \`git -C ${d} show origin/main:<path>\` or \`git -C ${d} log origin/main\`.`;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
363
396
|
}
|
|
364
397
|
|
|
365
398
|
// Commands whose every non-option argument is a file they change, and
|
|
@@ -557,7 +590,7 @@ export async function shellRefusal(
|
|
|
557
590
|
(inv.name === "gh" ? ghRefusal(inv.args) : null) ??
|
|
558
591
|
(inv.name === "curl" ? curlRefusal(inv.args) : null) ??
|
|
559
592
|
(inv.name === "aos" ? aosRefusal(inv.args) : null) ??
|
|
560
|
-
(inv.name === "git" ? await gitRefusal(inv
|
|
593
|
+
(inv.name === "git" ? await gitRefusal(inv, ctx, cwd) : null) ??
|
|
561
594
|
(inv.name === "sqlite3"
|
|
562
595
|
? await sqliteRefusal(inv, ctx, cwd, command)
|
|
563
596
|
: null) ??
|
|
@@ -24,6 +24,7 @@ import { orchestratorDir } from "./home";
|
|
|
24
24
|
import { LIMITS_FILE } from "./usage";
|
|
25
25
|
import { quickWorktrees } from "./quick-store";
|
|
26
26
|
import { shellRefusal } from "./shell-guard";
|
|
27
|
+
import { liveCheckout } from "../dev-instance/liveServer";
|
|
27
28
|
|
|
28
29
|
// The tools that write a file, and the argument naming it.
|
|
29
30
|
export const WRITE_TOOLS: Record<string, string> = {
|
|
@@ -398,10 +399,61 @@ async function commandRefusal(
|
|
|
398
399
|
: [];
|
|
399
400
|
return roots.some((r) => within(p, r));
|
|
400
401
|
},
|
|
402
|
+
checkoutKind: (dir) => checkoutKind(session, dir),
|
|
401
403
|
protectedPids,
|
|
402
404
|
});
|
|
403
405
|
}
|
|
404
406
|
|
|
407
|
+
// Whether a folder is a project checkout of the session's own workspace
|
|
408
|
+
// (where it may update and move the branch), a checkout AgentOS's live
|
|
409
|
+
// server or autodeploy runs from (never), or neither. The live ones are the
|
|
410
|
+
// server's folder, the one live-server.json recorded (known even while the
|
|
411
|
+
// server restarts), and any main checkout holding scripts/autodeploy, since
|
|
412
|
+
// that script deploys the folder it sits in (a task worktree has a .git
|
|
413
|
+
// file, not a folder).
|
|
414
|
+
export async function checkoutKind(
|
|
415
|
+
session: Session,
|
|
416
|
+
dir: string
|
|
417
|
+
): Promise<"own" | "live" | "other"> {
|
|
418
|
+
const real = await physicalPath(dir);
|
|
419
|
+
const roots = async (paths: (string | undefined)[]) =>
|
|
420
|
+
Promise.all(
|
|
421
|
+
paths
|
|
422
|
+
.filter((p): p is string => !!p)
|
|
423
|
+
.flatMap((p) => [path.resolve(p), p])
|
|
424
|
+
.map((p) => physicalPath(p))
|
|
425
|
+
);
|
|
426
|
+
const live = await roots([process.cwd(), liveCheckout() ?? undefined]);
|
|
427
|
+
if (live.some((r) => within(real, r)) || deploysFrom(real)) return "live";
|
|
428
|
+
if (!session.workspace_id) return "other";
|
|
429
|
+
const own = db
|
|
430
|
+
.prepare(
|
|
431
|
+
`SELECT working_directory AS dir FROM projects
|
|
432
|
+
WHERE workspace_id = ? AND is_uncategorized = 0 AND host_id = 'local'
|
|
433
|
+
UNION SELECT r.path FROM project_repositories r
|
|
434
|
+
JOIN projects p ON p.id = r.project_id
|
|
435
|
+
WHERE p.workspace_id = ? AND p.host_id = 'local'`
|
|
436
|
+
)
|
|
437
|
+
.all(session.workspace_id, session.workspace_id) as { dir: string }[];
|
|
438
|
+
const mine = await roots(own.map((p) => expandHome(p.dir)).filter(Boolean));
|
|
439
|
+
return mine.some((r) => within(real, r)) ? "own" : "other";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function deploysFrom(real: string): boolean {
|
|
443
|
+
for (let at = real; ; at = path.dirname(at)) {
|
|
444
|
+
try {
|
|
445
|
+
if (
|
|
446
|
+
fs.statSync(path.join(at, "scripts", "autodeploy")).isFile() &&
|
|
447
|
+
fs.statSync(path.join(at, ".git")).isDirectory()
|
|
448
|
+
)
|
|
449
|
+
return true;
|
|
450
|
+
} catch {
|
|
451
|
+
// Not here; look higher.
|
|
452
|
+
}
|
|
453
|
+
if (path.dirname(at) === at) return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
405
457
|
// AgentOS's server (whatever listens on its port), the tmux servers its
|
|
406
458
|
// sessions run in, and this worker. A lookup that fails refuses the kill.
|
|
407
459
|
async function protectedPids(): Promise<Set<number>> {
|
package/lib/security/demo.ts
CHANGED
package/lib/share/pdf.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share page as a PDF, printed by headless Chromium from the exact HTML
|
|
3
|
+
* the preview shows. One browser is launched lazily, reused, and closed after
|
|
4
|
+
* a quiet spell. Nothing on the page can reach the network: only the inlined
|
|
5
|
+
* markup is rendered.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import type { Browser, LaunchOptions } from "playwright";
|
|
10
|
+
|
|
11
|
+
const IDLE_MS = 60_000;
|
|
12
|
+
const RENDER_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
const CHROME_PATHS = [
|
|
15
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
16
|
+
"/usr/bin/google-chrome",
|
|
17
|
+
"/usr/bin/google-chrome-stable",
|
|
18
|
+
"/usr/bin/chromium",
|
|
19
|
+
"/usr/bin/chromium-browser",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** Print-only rules, added here so the HTML download stays as it was. */
|
|
23
|
+
const PRINT_CSS = `
|
|
24
|
+
@page{margin:16mm 14mm}
|
|
25
|
+
html,body{background:#fff!important}
|
|
26
|
+
.wrap{max-width:none;padding:0}
|
|
27
|
+
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
|
28
|
+
pre code,pre,details.tool pre{white-space:pre-wrap!important;word-break:break-word!important;overflow-wrap:anywhere;overflow:visible;max-height:none}
|
|
29
|
+
table{display:table;width:100%;table-layout:auto;overflow-wrap:anywhere}
|
|
30
|
+
pre,table,tr,.plan,.error,details.tool{break-inside:avoid-page}
|
|
31
|
+
h1,h2,h3,h4{break-after:avoid}
|
|
32
|
+
img{max-width:100%;break-inside:avoid}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
let found: LaunchOptions | null | undefined;
|
|
36
|
+
|
|
37
|
+
/** How to launch a browser on this machine, or null when there is none. */
|
|
38
|
+
export async function findBrowser(): Promise<LaunchOptions | null> {
|
|
39
|
+
if (found !== undefined) return found;
|
|
40
|
+
const { chromium } = await import("playwright");
|
|
41
|
+
const candidates = [chromium.executablePath(), ...CHROME_PATHS];
|
|
42
|
+
const path = candidates.find((p) => p && fs.existsSync(p));
|
|
43
|
+
found = path ? { executablePath: path, headless: true } : null;
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const pdfAvailable = async () => (await findBrowser()) !== null;
|
|
48
|
+
|
|
49
|
+
/** Test hook: forget the cached lookup and any open browser. */
|
|
50
|
+
export async function resetPdfBrowser() {
|
|
51
|
+
found = undefined;
|
|
52
|
+
await closeBrowser();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let browser: Promise<Browser> | null = null;
|
|
56
|
+
let idle: NodeJS.Timeout | null = null;
|
|
57
|
+
|
|
58
|
+
async function closeBrowser() {
|
|
59
|
+
if (idle) clearTimeout(idle);
|
|
60
|
+
idle = null;
|
|
61
|
+
const b = browser;
|
|
62
|
+
browser = null;
|
|
63
|
+
if (b) await (await b).close().catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function getBrowser(): Promise<Browser> {
|
|
67
|
+
const opts = await findBrowser();
|
|
68
|
+
if (!opts) throw new Error("No browser to print with on this machine.");
|
|
69
|
+
if (!browser) {
|
|
70
|
+
const { chromium } = await import("playwright");
|
|
71
|
+
const launched = chromium.launch(opts);
|
|
72
|
+
browser = launched;
|
|
73
|
+
launched.then(
|
|
74
|
+
(b) =>
|
|
75
|
+
b.on("disconnected", () => browser === launched && (browser = null)),
|
|
76
|
+
() => {
|
|
77
|
+
if (browser === launched) browser = null;
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return browser;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const withPrint = (html: string) =>
|
|
85
|
+
html.includes("</head>")
|
|
86
|
+
? html.replace("</head>", () => `<style>${PRINT_CSS}</style></head>`)
|
|
87
|
+
: `<style>${PRINT_CSS}</style>${html}`;
|
|
88
|
+
|
|
89
|
+
/** The page printed to an A4 PDF. */
|
|
90
|
+
export async function renderPdf(html: string): Promise<Buffer> {
|
|
91
|
+
const b = await getBrowser();
|
|
92
|
+
if (idle) clearTimeout(idle);
|
|
93
|
+
const ctx = await b.newContext({
|
|
94
|
+
javaScriptEnabled: false,
|
|
95
|
+
colorScheme: "light",
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await ctx.setOffline(true);
|
|
99
|
+
const page = await ctx.newPage();
|
|
100
|
+
page.setDefaultTimeout(RENDER_MS);
|
|
101
|
+
await page.emulateMedia({ media: "print", colorScheme: "light" });
|
|
102
|
+
// "load" waits for images and fonts; scripts are off, so nothing runs after.
|
|
103
|
+
await page.setContent(withPrint(html), { waitUntil: "load" });
|
|
104
|
+
return Buffer.from(
|
|
105
|
+
await page.pdf({
|
|
106
|
+
format: "A4",
|
|
107
|
+
printBackground: true,
|
|
108
|
+
preferCSSPageSize: true,
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
} finally {
|
|
112
|
+
await ctx.close().catch(() => {});
|
|
113
|
+
idle = setTimeout(() => void closeBrowser(), IDLE_MS);
|
|
114
|
+
idle.unref();
|
|
115
|
+
}
|
|
116
|
+
}
|
package/lib/share/view.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface PreviewItem {
|
|
|
22
22
|
excerpt: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Download formats a server can make; the client lists what it is told. */
|
|
26
|
+
export type ShareFormat = "pdf" | "html" | "md";
|
|
27
|
+
|
|
25
28
|
export interface SharePreview {
|
|
26
29
|
html: string;
|
|
27
30
|
markdown: string;
|
|
@@ -33,6 +36,8 @@ export interface SharePreview {
|
|
|
33
36
|
hits: Record<"secret" | "path" | "ip" | "host" | "email", number>;
|
|
34
37
|
tooBig: boolean;
|
|
35
38
|
connectLinked: boolean;
|
|
39
|
+
/** Best first. pdf only when this machine has a browser to print with. */
|
|
40
|
+
formats: ShareFormat[];
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface SharedLinkRow {
|
|
@@ -103,7 +108,10 @@ export function formatBytes(n: number): string {
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/** A file name for a download: the title, plain, with .html (or `ext`). */
|
|
106
|
-
export function fileName(
|
|
111
|
+
export function fileName(
|
|
112
|
+
title: string,
|
|
113
|
+
ext: "html" | "md" | "pdf" = "html"
|
|
114
|
+
): string {
|
|
107
115
|
const slug = title
|
|
108
116
|
.toLowerCase()
|
|
109
117
|
.replace(/[^a-z0-9]+/g, "-")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saadnvd1/agent-os",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.658",
|
|
4
4
|
"description": "Self-hosted web UI for managing Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-os": "scripts/agent-os"
|
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"predev": "node scripts/ensure-native.mjs && node scripts/gen-migrations-index.mjs",
|
|
33
33
|
"dev": "AGENTOS_DEV=1 tsx server.ts",
|
|
34
|
+
"dev:task": "tsx scripts/dev-task.ts",
|
|
35
|
+
"dev:stop": "tsx scripts/dev-stop.ts",
|
|
34
36
|
"prebuild": "node scripts/gen-migrations-index.mjs",
|
|
35
37
|
"build": "next build",
|
|
36
38
|
"prestart": "node scripts/ensure-native.mjs && node scripts/gen-migrations-index.mjs",
|
|
37
|
-
"start": "NODE_ENV=production tsx server.ts",
|
|
39
|
+
"start": "NODE_ENV=production AGENTOS_SERVER=live node --import tsx server.ts",
|
|
38
40
|
"pretypecheck": "node scripts/gen-migrations-index.mjs",
|
|
39
41
|
"typecheck": "tsc --noEmit",
|
|
40
42
|
"setup": "bash scripts/setup.sh",
|
|
@@ -141,6 +143,7 @@
|
|
|
141
143
|
"next": "^16.3.8",
|
|
142
144
|
"next-themes": "^0.4.6",
|
|
143
145
|
"node-pty": "^1.2.0-beta.6",
|
|
146
|
+
"playwright": "^1.63.0",
|
|
144
147
|
"qrcode": "^1.5.4",
|
|
145
148
|
"react": "^19.2.1",
|
|
146
149
|
"react-dom": "^19.2.1",
|
|
@@ -171,7 +174,6 @@
|
|
|
171
174
|
"eslint-config-next": "^16.3.8",
|
|
172
175
|
"husky": "^9.1.7",
|
|
173
176
|
"jsdom": "^30.1.2",
|
|
174
|
-
"playwright": "^1.63.0",
|
|
175
177
|
"prettier": "^3.7.4",
|
|
176
178
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
177
179
|
"sharp": "^0.35.5",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { startDevServer } from "@/lib/dev-instance";
|
|
3
|
+
|
|
4
|
+
const i = process.argv.indexOf("--port");
|
|
5
|
+
const requested = i === -1 ? undefined : Number(process.argv[i + 1]);
|
|
6
|
+
|
|
7
|
+
startDevServer({ worktree: fs.realpathSync(process.cwd()), requested })
|
|
8
|
+
.then((r) =>
|
|
9
|
+
console.log(
|
|
10
|
+
`Dev server ${r.reused ? "already running" : "started"}: pid ${r.pid} on http://localhost:${r.port} (log ${r.log}). Stop it with npm run dev:stop.`
|
|
11
|
+
)
|
|
12
|
+
)
|
|
13
|
+
.catch((e: Error) => {
|
|
14
|
+
console.error(e.message);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import fs from "fs";
|
|
13
13
|
import path from "path";
|
|
14
|
-
import {
|
|
14
|
+
import { spawnServer } from "@/lib/dev-instance";
|
|
15
15
|
import { PORT, REPO, demoEnv } from "./config";
|
|
16
16
|
import { seed, teardownTmux } from "./seed";
|
|
17
17
|
import { startPeer } from "./peer";
|
|
@@ -35,13 +35,9 @@ async function main() {
|
|
|
35
35
|
process.env[k] ? [[k, process.env[k]!]] : []
|
|
36
36
|
)
|
|
37
37
|
);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
const tsx = path.join(REPO, "node_modules", "tsx", "dist", "cli.mjs");
|
|
43
|
-
const server = spawn(process.execPath, [tsx, "server.ts"], {
|
|
44
|
-
cwd: REPO,
|
|
38
|
+
const server = spawnServer({
|
|
39
|
+
repo: REPO,
|
|
40
|
+
port: PORT,
|
|
45
41
|
env: demoEnv({
|
|
46
42
|
AGENTOS_BIND: "127.0.0.1",
|
|
47
43
|
...passed,
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
|
-
import {
|
|
11
|
+
import { type ChildProcess } from "child_process";
|
|
12
|
+
import { spawnServer } from "@/lib/dev-instance";
|
|
12
13
|
import { BASE_URL, PORT, REPO, ROOT, demoEnv } from "./config";
|
|
13
14
|
import { seed, teardownTmux } from "./seed";
|
|
14
15
|
import { shoot } from "./shoot";
|
|
@@ -38,14 +39,10 @@ async function waitForServer(server: ChildProcess): Promise<void> {
|
|
|
38
39
|
|
|
39
40
|
function startServer(): ChildProcess {
|
|
40
41
|
const log = fs.openSync(path.join(ROOT, "server.log"), "w");
|
|
41
|
-
// server.ts imports the generated migrations index.
|
|
42
|
-
execFileSync(process.execPath, ["scripts/gen-migrations-index.mjs"], {
|
|
43
|
-
cwd: REPO,
|
|
44
|
-
});
|
|
45
|
-
const tsx = path.join(REPO, "node_modules", "tsx", "dist", "cli.mjs");
|
|
46
42
|
// Loopback only: the demo never listens on another network.
|
|
47
|
-
return
|
|
48
|
-
|
|
43
|
+
return spawnServer({
|
|
44
|
+
repo: REPO,
|
|
45
|
+
port: PORT,
|
|
49
46
|
// No watchers: the seeded orchestrator and tasks must sit still, never
|
|
50
47
|
// start an agent.
|
|
51
48
|
env: demoEnv({
|
package/server.ts
CHANGED
|
@@ -78,6 +78,11 @@ import { failInterruptedSetups } from "./lib/sessions/worktree-setup";
|
|
|
78
78
|
import { sweepOrphanTmux } from "./lib/tmux/orphans";
|
|
79
79
|
import { releaseInterruptedClaims } from "./lib/orchestrator/ask-approvals";
|
|
80
80
|
import { resumeReviews } from "./lib/orchestrator/review";
|
|
81
|
+
import {
|
|
82
|
+
isLiveServer,
|
|
83
|
+
readLiveRecord,
|
|
84
|
+
writeLiveRecord,
|
|
85
|
+
} from "./lib/dev-instance";
|
|
81
86
|
import { claimAgentosEnv } from "./lib/agents/self-env";
|
|
82
87
|
import { wslNotes, wslVersion } from "./lib/wsl";
|
|
83
88
|
import { getAllProjects } from "./lib/projects";
|
|
@@ -89,6 +94,19 @@ const hostname = "127.0.0.1";
|
|
|
89
94
|
|
|
90
95
|
// Support: npm run dev -- -p 3012
|
|
91
96
|
const port = serverPort();
|
|
97
|
+
const live = isLiveServer();
|
|
98
|
+
// A distinct title keeps `pkill -f server.ts` and `pkill tsx` off the
|
|
99
|
+
// production server; an AgentOS in a worktree can't take its port.
|
|
100
|
+
process.title = live ? "agentos-server" : "agentos-dev";
|
|
101
|
+
if (!live) {
|
|
102
|
+
const rec = readLiveRecord();
|
|
103
|
+
if (rec && rec.port === port && rec.pid !== process.pid) {
|
|
104
|
+
console.error(
|
|
105
|
+
`Refusing to start: port ${port} is the live AgentOS server (pid ${rec.pid}). Use npm run dev:task.`
|
|
106
|
+
);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
92
110
|
claimAgentosEnv(port);
|
|
93
111
|
// Demo mode: seeded data, nothing that runs code (lib/security/demo).
|
|
94
112
|
const demo = demoMode();
|
|
@@ -302,6 +320,13 @@ app.prepare().then(async () => {
|
|
|
302
320
|
});
|
|
303
321
|
server.listen(port, address, () => {
|
|
304
322
|
console.log(`> Agent-OS ready on http://${address}:${port}`);
|
|
323
|
+
if (live)
|
|
324
|
+
writeLiveRecord({
|
|
325
|
+
pid: process.pid,
|
|
326
|
+
port,
|
|
327
|
+
cwd: process.cwd(),
|
|
328
|
+
startedAt: Date.now(),
|
|
329
|
+
});
|
|
305
330
|
});
|
|
306
331
|
};
|
|
307
332
|
const stopListening = (address: string) => {
|