@saadnvd1/agent-os 0.3.653 → 0.3.657
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 +3 -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 +46 -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/shell-guard.ts +29 -2
- package/lib/orchestrator/write-guard.ts +61 -7
- package/lib/secret-paths.ts +12 -0
- package/lib/security/demo.ts +1 -0
- package/lib/share/pdf.ts +116 -0
- package/lib/share/view.ts +9 -1
- package/lib/simulator/brief.ts +1 -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()) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { isSecretPath } from "../secret-paths";
|
|
4
|
+
import { isOrchestratorSecretPath as isSecretPath } from "../secret-paths";
|
|
5
5
|
import { TOOL_NAMES } from "./tool-names";
|
|
6
6
|
import { parseShell, UNKNOWN, type SimpleCommand } from "./shell-parse";
|
|
7
7
|
|
|
@@ -26,6 +26,9 @@ export interface ShellContext {
|
|
|
26
26
|
writeRefusal: (p: string) => Promise<string | null>;
|
|
27
27
|
// Whether this file is AgentOS's database or other state of its own.
|
|
28
28
|
isAgentOsState: (p: string) => Promise<boolean>;
|
|
29
|
+
// Whether this path is under ~/.agent-os/audits, where reports go and
|
|
30
|
+
// nothing may be made executable.
|
|
31
|
+
inAudits?: (p: string) => Promise<boolean>;
|
|
29
32
|
// AgentOS's server and the tmux servers it runs sessions in.
|
|
30
33
|
protectedPids: () => Promise<Set<number>>;
|
|
31
34
|
}
|
|
@@ -319,6 +322,8 @@ const GIT_WRITES = new Set([
|
|
|
319
322
|
"mv",
|
|
320
323
|
]);
|
|
321
324
|
|
|
325
|
+
const READ_ONLY_STASH = new Set(["list", "show"]);
|
|
326
|
+
|
|
322
327
|
async function gitRefusal(
|
|
323
328
|
args: string[],
|
|
324
329
|
ctx: ShellContext,
|
|
@@ -348,10 +353,13 @@ async function gitRefusal(
|
|
|
348
353
|
if (sub === "push")
|
|
349
354
|
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.`;
|
|
350
355
|
if (!GIT_WRITES.has(sub)) return null;
|
|
356
|
+
if (sub === "stash" && READ_ONLY_STASH.has(args[i + 1])) return null;
|
|
351
357
|
if (has(dir))
|
|
352
358
|
return `Refused: git ${sub} in a folder only the shell knows (${show(dir)}); name the folder.`;
|
|
353
359
|
const why = await ctx.writeRefusal(dir);
|
|
354
|
-
return why
|
|
360
|
+
return why
|
|
361
|
+
? `Refused: git ${sub} in ${dir}. Orchestrators don't change project checkouts (autodeploy runs from them). To see current code: \`git -C ${dir} fetch\` then \`git -C ${dir} show origin/main:<path>\` or \`git -C ${dir} log origin/main\`.`
|
|
362
|
+
: null;
|
|
355
363
|
}
|
|
356
364
|
|
|
357
365
|
// Commands whose every non-option argument is a file they change, and
|
|
@@ -372,6 +380,16 @@ const WRITES_ALL = new Set([
|
|
|
372
380
|
]);
|
|
373
381
|
const WRITES_LAST = new Set(["cp", "ln", "install", "rsync", "ditto"]);
|
|
374
382
|
|
|
383
|
+
// Whether this chmod mode adds an execute bit (or might: copying another
|
|
384
|
+
// class's bits, or a mode it can't read).
|
|
385
|
+
export function addsExecute(mode: string): boolean {
|
|
386
|
+
if (/^[0-7]{1,4}$/.test(mode)) return [...mode.slice(-3)].some((d) => +d & 1);
|
|
387
|
+
return mode.split(",").some((c) => {
|
|
388
|
+
const m = /^[ugoa]*([-+=])([rwxXst]*|[ugo])$/.exec(c);
|
|
389
|
+
return !m || (m[1] !== "-" && /[xXugo]/.test(m[2]));
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
375
393
|
function writtenFiles(inv: Invocation): string[] {
|
|
376
394
|
const { name, args } = inv;
|
|
377
395
|
const files = [...inv.cmd.writes];
|
|
@@ -550,6 +568,15 @@ export async function shellRefusal(
|
|
|
550
568
|
if (has(cwd) && !f.startsWith("/")) continue;
|
|
551
569
|
const r = await ctx.writeRefusal(path.resolve(cwd, f));
|
|
552
570
|
if (r) return `Refused: ${inv.name} would write ${f}. ${r}`;
|
|
571
|
+
if (
|
|
572
|
+
inv.name === "chmod" &&
|
|
573
|
+
ctx.inAudits &&
|
|
574
|
+
(await ctx.inAudits(path.resolve(cwd, f))) &&
|
|
575
|
+
addsExecute(
|
|
576
|
+
inv.args.find((a) => !/^(-[RHLPfvhc]+|--.*)$/.test(a)) ?? ""
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
return `Refused: chmod would make ${f} executable. ~/.agent-os/audits holds reports, not programs.`;
|
|
553
580
|
}
|
|
554
581
|
}
|
|
555
582
|
}
|
|
@@ -18,7 +18,7 @@ import os from "os";
|
|
|
18
18
|
import path from "path";
|
|
19
19
|
import { db, type Session } from "../db";
|
|
20
20
|
import { git, GitError } from "../git-exec";
|
|
21
|
-
import { isSecretPath } from "../secret-paths";
|
|
21
|
+
import { isOrchestratorSecretPath as isSecretPath } from "../secret-paths";
|
|
22
22
|
import { WORKTREES_DIR } from "../worktrees";
|
|
23
23
|
import { orchestratorDir } from "./home";
|
|
24
24
|
import { LIMITS_FILE } from "./usage";
|
|
@@ -49,6 +49,11 @@ export interface WriteBounds {
|
|
|
49
49
|
// Its own quick changes' worktrees (quick.ts): the one place in a
|
|
50
50
|
// project it may write, everything else in `repos` still refused.
|
|
51
51
|
quick?: string[];
|
|
52
|
+
// Its own Claude Code auto-memory folder (~/.claude/projects/<mangled
|
|
53
|
+
// cwd>/memory), as named, and the one `audits` folder in `state`: the only
|
|
54
|
+
// two places in a protected area it may write.
|
|
55
|
+
memory?: string[];
|
|
56
|
+
audits?: string;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
// Wherever they are, files that configure what an agent, git or a
|
|
@@ -124,6 +129,21 @@ const within = (p: string, root: string) => {
|
|
|
124
129
|
return a === b || a.startsWith(b === "/" ? b : b + "/");
|
|
125
130
|
};
|
|
126
131
|
|
|
132
|
+
// How Claude Code names a project's folder under ~/.claude/projects.
|
|
133
|
+
export const mangleCwd = (cwd: string) => cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
134
|
+
|
|
135
|
+
// A folder that must sit exactly where its name says under `parent` (no
|
|
136
|
+
// link in it or between leading elsewhere). Returns the roots a write may go
|
|
137
|
+
// under, or none.
|
|
138
|
+
async function plainRoot(dir: string, parent: string): Promise<string[]> {
|
|
139
|
+
const byName = path.join(
|
|
140
|
+
await physicalPath(parent),
|
|
141
|
+
path.relative(parent, dir)
|
|
142
|
+
);
|
|
143
|
+
const real = await physicalPath(dir);
|
|
144
|
+
return key(real) === key(byName) ? [byName, path.resolve(dir)] : [];
|
|
145
|
+
}
|
|
146
|
+
|
|
127
147
|
const expandHome = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
|
|
128
148
|
|
|
129
149
|
// Why a write to this path is refused, or null when it may go ahead. For a
|
|
@@ -145,6 +165,8 @@ export async function writeRefusal(
|
|
|
145
165
|
let home: string[];
|
|
146
166
|
let runs: string[];
|
|
147
167
|
let quick: string[][];
|
|
168
|
+
let memory: string[];
|
|
169
|
+
let audits: string[];
|
|
148
170
|
try {
|
|
149
171
|
// Where it lands as written, and with `..` taken by name first, as a
|
|
150
172
|
// tool that normalises the path before opening it would; and where it
|
|
@@ -168,6 +190,14 @@ export async function writeRefusal(
|
|
|
168
190
|
ownDir = await both(bounds.ownDir);
|
|
169
191
|
home = await both(bounds.home);
|
|
170
192
|
quick = await Promise.all((bounds.quick ?? []).map(both));
|
|
193
|
+
memory = (
|
|
194
|
+
await Promise.all(
|
|
195
|
+
(bounds.memory ?? []).map((m) =>
|
|
196
|
+
plainRoot(m, path.join(bounds.home, ".claude", "projects"))
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
).flat();
|
|
200
|
+
audits = bounds.audits ? await plainRoot(bounds.audits, bounds.state) : [];
|
|
171
201
|
} catch (e) {
|
|
172
202
|
return `Couldn't resolve ${filePath}: ${(e as Error).message}.`;
|
|
173
203
|
}
|
|
@@ -178,27 +208,40 @@ export async function writeRefusal(
|
|
|
178
208
|
const ownQuick = quick.find((q) => q.some((d) => within(target, d)));
|
|
179
209
|
const holdsQuick = (r: string) =>
|
|
180
210
|
!!ownQuick && ownQuick.some((d) => within(d, r));
|
|
181
|
-
const
|
|
211
|
+
const inAudits = audits.find((d) => within(target, d));
|
|
212
|
+
const holdsAudits = (r: string) =>
|
|
213
|
+
!!inAudits && within(inAudits, r) && !within(r, inAudits);
|
|
214
|
+
const repo = repos.find(
|
|
215
|
+
(r) => within(target, r) && !holdsQuick(r) && !holdsAudits(r)
|
|
216
|
+
);
|
|
182
217
|
if (repo)
|
|
183
|
-
return `${filePath} is inside ${repo}
|
|
218
|
+
return `${key(filePath) === key(repo) ? repo : `${filePath} is inside ${repo}`} is a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), or for a small edit, in a quick change's worktree (quick_change), so its PR goes through the merge gates.`;
|
|
184
219
|
if (ownQuick && !shell && GIT_LAYOUT_FILE.test(target))
|
|
185
220
|
return `${filePath} is part of a git repository's layout (HEAD, .git/, .gitattributes); git config can run programs, so orchestrators never write those.`;
|
|
186
221
|
if (AGENTOS_TEMP.test(target))
|
|
187
222
|
return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
|
|
188
223
|
// Its folder, or a quick worktree (which sits in ~/.agent-os/worktrees):
|
|
189
224
|
// AgentOS's state and the home's dot-folders hold it, but it's its own.
|
|
190
|
-
const own =
|
|
225
|
+
const own =
|
|
226
|
+
ownDir.some((d) => within(target, d)) || !!ownQuick || !!inAudits;
|
|
191
227
|
if (state.some((d) => within(target, d)) && !own)
|
|
192
228
|
return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
|
|
193
229
|
if (shell) continue;
|
|
194
230
|
if (isSecretPath(target) || isSecretPath(filePath))
|
|
195
231
|
return `${filePath} holds or guards secrets; orchestrators never write those.`;
|
|
196
232
|
const runsIn = runs.find((r) => within(target, r));
|
|
233
|
+
// In its memory folder or audits, the rest of the path is what counts:
|
|
234
|
+
// the `.claude` and `.agent-os` above it are why it's allowed.
|
|
235
|
+
const inMemory = memory.find((d) => within(target, d));
|
|
236
|
+
const sub = inMemory ?? inAudits;
|
|
237
|
+
const rest = sub ? target.slice(sub.length) : null;
|
|
197
238
|
if (
|
|
198
|
-
|
|
199
|
-
|
|
239
|
+
(rest === null
|
|
240
|
+
? RUNS_CODE.test(target) || RUNS_CODE.test(filePath)
|
|
241
|
+
: RUNS_CODE.test(rest)) ||
|
|
200
242
|
runsIn ||
|
|
201
243
|
(!own &&
|
|
244
|
+
!inMemory &&
|
|
202
245
|
home.some(
|
|
203
246
|
(h) =>
|
|
204
247
|
within(target, h) &&
|
|
@@ -235,8 +278,9 @@ export async function orchestratorWriteBounds(
|
|
|
235
278
|
.all() as { dir: string }[];
|
|
236
279
|
const dbPath = agentOsDbPath();
|
|
237
280
|
const repos = projects.map((p) => expandHome(p.dir)).filter((d) => d !== "");
|
|
281
|
+
const cwd = expandHome(session.working_directory);
|
|
238
282
|
return {
|
|
239
|
-
cwd
|
|
283
|
+
cwd,
|
|
240
284
|
home: os.homedir(),
|
|
241
285
|
runs: [
|
|
242
286
|
path.join(os.homedir(), "Library"),
|
|
@@ -248,6 +292,10 @@ export async function orchestratorWriteBounds(
|
|
|
248
292
|
...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
|
|
249
293
|
],
|
|
250
294
|
ownDir: orchestratorDir(session.workspace_id ?? ""),
|
|
295
|
+
memory: [...new Set([cwd, await physicalPath(cwd)])].map((c) =>
|
|
296
|
+
path.join(os.homedir(), ".claude", "projects", mangleCwd(c), "memory")
|
|
297
|
+
),
|
|
298
|
+
audits: path.join(os.homedir(), ".agent-os", "audits"),
|
|
251
299
|
quick: quickWorktrees(session.id),
|
|
252
300
|
state: path.join(os.homedir(), ".agent-os"),
|
|
253
301
|
repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
|
|
@@ -344,6 +392,12 @@ async function commandRefusal(
|
|
|
344
392
|
const state = await physicalPath(bounds.state);
|
|
345
393
|
return within(real, state) && !within(real, own);
|
|
346
394
|
},
|
|
395
|
+
inAudits: async (p) => {
|
|
396
|
+
const roots = bounds.audits
|
|
397
|
+
? await plainRoot(bounds.audits, bounds.state)
|
|
398
|
+
: [];
|
|
399
|
+
return roots.some((r) => within(p, r));
|
|
400
|
+
},
|
|
347
401
|
protectedPids,
|
|
348
402
|
});
|
|
349
403
|
}
|
package/lib/secret-paths.ts
CHANGED
|
@@ -7,3 +7,15 @@ export const SECRET_PATH =
|
|
|
7
7
|
/(^|\/)\.env(\.[^/]*)?$|(^|\/)[^/]*(secret|credential|tokenvault|keychain)[^/]*(\/|$)|\.(pem|key|p12|pfx)$|(^|\/)(id_(rsa|dsa|ecdsa|ed25519)|\.npmrc|\.netrc|\.pgpass)$|(^|\/)\.(ssh|aws|azure|gnupg|kube|docker)(\/|$)|(^|\/)\.config\/(gh|gcloud|op)(\/|$)/i;
|
|
8
8
|
|
|
9
9
|
export const isSecretPath = (p: string): boolean => SECRET_PATH.test(p);
|
|
10
|
+
|
|
11
|
+
// What an orchestrator treats as secret: the same, except the committed
|
|
12
|
+
// templates, matched on the whole file name (`.env.example.bak` is still an
|
|
13
|
+
// env file) and only when no folder above them is a secret one.
|
|
14
|
+
const ENV_TEMPLATES = new Set([".env.example", ".env.sample", ".env.template"]);
|
|
15
|
+
|
|
16
|
+
export const isOrchestratorSecretPath = (p: string): boolean => {
|
|
17
|
+
if (!isSecretPath(p)) return false;
|
|
18
|
+
const i = p.lastIndexOf("/");
|
|
19
|
+
if (!ENV_TEMPLATES.has(p.slice(i + 1))) return true;
|
|
20
|
+
return i >= 0 && isSecretPath(`${p.slice(0, i)}/x`);
|
|
21
|
+
};
|
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/lib/simulator/brief.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function simulatorBrief(
|
|
|
12
12
|
return "- The iOS simulator tools are not available on this machine; skip simulator checks and say so in the PR.";
|
|
13
13
|
return [
|
|
14
14
|
"- iOS simulator (optional; it never gates a PR or a build, and a failure is reported, not a blocker): `aos sim open` makes your own simulator and installs this worktree's build (run it with run_in_background, a cold build takes minutes; `aos sim status` shows where it is).",
|
|
15
|
-
' Drive and check state with `aos sim snapshot -i` (accessibility tree with @refs, text, cheap), `aos sim click @e3`, `aos sim fill @e5 "text"`, `aos sim logs --last 2m`. Refs are only valid from the latest snapshot.',
|
|
15
|
+
' Drive and check state with `aos sim snapshot -i` (accessibility tree with @refs, text, cheap), `aos sim click @e3`, `aos sim fill @e5 "text"`, `aos sim logs --last 2m`. Refs are only valid from the latest snapshot. Never click or type through Python (Quartz) or cliclick; `aos sim` is the only path and needs neither.',
|
|
16
16
|
" Every image you read stays in your context for every later turn, so screenshot only the final changed screens, and prefer `aos sim screenshot --region @e3` (one element from the latest snapshot) for a single row or banner. It prints two paths: read the `read:` one (reduced to 800px); attach the `full-size` file to the PR.",
|
|
17
17
|
" Screenshots are written outside the worktree: don't put them in commits or the PR body unless they belong there. `aos sim close` when you're done; it is closed for you when the task ends.",
|
|
18
18
|
].join("\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saadnvd1/agent-os",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.657",
|
|
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) => {
|