@saadnvd1/agent-os 0.3.657 → 0.3.659

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.
@@ -10,6 +10,7 @@ import { runInBackground } from "../async-operations";
10
10
  import { trustPromptKeys } from "../tasks/state";
11
11
  import { BUS_BRIEF } from "./brief";
12
12
  import { selfUrl } from "./self-env";
13
+ import { sessionPath } from "./tool-path";
13
14
  import { autoCompactEnv } from "../chat/refresh";
14
15
  import { subagentEnv } from "../models/subagents";
15
16
  import { COMPACT_BRIEF } from "../chat/working-state";
@@ -43,7 +44,7 @@ export function agentEnv(sessionId: string): Record<string, string> {
43
44
  ...subagentEnv(),
44
45
  AGENTOS_SESSION_ID: sessionId,
45
46
  AGENTOS_URL: selfUrl(),
46
- PATH: `${AOS_BIN_DIR}:${process.env.PATH ?? ""}`,
47
+ PATH: `${AOS_BIN_DIR}:${sessionPath()}`,
47
48
  };
48
49
  }
49
50
 
@@ -0,0 +1,61 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+
5
+ // Where Saad's language version managers and user-level tools put their
6
+ // binaries, in the order they should win. A shell's rc files normally add
7
+ // these; a session AgentOS starts doesn't read them, so `bin/rails` would
8
+ // find the system Ruby. Built in code so it is fast and the same on the Mac
9
+ // and the Linux box (missing dirs are skipped).
10
+ const USER_TOOL_DIRS = [
11
+ ".rbenv/shims",
12
+ ".local/share/mise/shims",
13
+ ".asdf/shims",
14
+ ".pyenv/shims",
15
+ ".local/bin",
16
+ ".bun/bin",
17
+ ".cargo/bin",
18
+ ];
19
+ const SYSTEM_TOOL_DIRS = ["/opt/homebrew/bin", "/opt/homebrew/sbin"];
20
+
21
+ function isDir(p: string): boolean {
22
+ try {
23
+ return fs.statSync(p).isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ // `npm run` puts every node_modules/.bin from the server's directory upward on
30
+ // PATH; for a session in another repo those would shadow the project's tools.
31
+ function serverBinDirs(cwd: string): Set<string> {
32
+ const dirs = new Set<string>();
33
+ for (let d = path.resolve(cwd); ; d = path.dirname(d)) {
34
+ dirs.add(path.join(d, "node_modules", ".bin"));
35
+ if (path.dirname(d) === d) break;
36
+ }
37
+ return dirs;
38
+ }
39
+
40
+ export function sessionPath(
41
+ inherited: string | undefined = process.env.PATH,
42
+ home: string = os.homedir(),
43
+ cwd: string = process.cwd()
44
+ ): string {
45
+ const drop = serverBinDirs(cwd);
46
+ const out: string[] = [];
47
+ const seen = new Set<string>();
48
+ const add = (dir: string) => {
49
+ if (!dir || seen.has(dir)) return;
50
+ seen.add(dir);
51
+ out.push(dir);
52
+ };
53
+ for (const d of [
54
+ ...USER_TOOL_DIRS.map((d) => path.join(home, d)),
55
+ ...SYSTEM_TOOL_DIRS,
56
+ ])
57
+ if (isDir(d)) add(d);
58
+ for (const d of (inherited ?? "").split(path.delimiter))
59
+ if (!drop.has(d)) add(d);
60
+ return out.join(path.delimiter);
61
+ }
@@ -34,6 +34,17 @@ export function writeLiveRecord(
34
34
  fs.renameSync(tmp, recordPath(home));
35
35
  }
36
36
 
37
+ // The recorded checkout the live server runs from, even when its process is
38
+ // gone (mid-restart).
39
+ export function liveCheckout(home = agentOsHome()): string | null {
40
+ try {
41
+ const rec = JSON.parse(fs.readFileSync(recordPath(home), "utf8"));
42
+ return typeof rec?.cwd === "string" && rec.cwd ? rec.cwd : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
37
48
  export function readLiveRecord(home = agentOsHome()): InstanceRecord | null {
38
49
  try {
39
50
  const rec = JSON.parse(fs.readFileSync(recordPath(home), "utf8"));
@@ -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
- args: string[],
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
- dir = path.resolve(dir, args[++i] ?? "");
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
- dir = path.resolve(dir, eq[2]);
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
- if (has(dir))
358
- return `Refused: git ${sub} in a folder only the shell knows (${show(dir)}); name the folder.`;
359
- const why = await ctx.writeRefusal(dir);
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;
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.args, ctx, cwd) : null) ??
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.657",
3
+ "version": "0.3.659",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"