@bill10/agent-007 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
package/server/auth.js ADDED
@@ -0,0 +1,162 @@
1
+ // Identity & auth (multiplayer phase 1)
2
+ //
3
+ // Users live in ~/.agent-007/users.json (separate from config.json so the
4
+ // `adduser` CLI never races the server's session writes and needs no restart).
5
+ // Each user: { id, displayName, color, tokenHash, createdAt }. Tokens are
6
+ // bearer secrets — only the SHA-256 hash is stored, never the plaintext.
7
+ //
8
+ // Auth is OFF until the first user exists: with zero users the server behaves
9
+ // exactly as it did before (open localhost single-player). Creating a user with
10
+ // `npm run adduser` flips the server into authenticated mode. This keeps the
11
+ // zero-config clone-and-run experience intact while enabling multiplayer.
12
+ //
13
+ // NOTE: like the origin check, this is a trust/identity layer, not a sandbox —
14
+ // every authenticated user can still spawn shells on this machine. Only issue
15
+ // tokens to people you'd give an SSH login. See docs/designs/multiplayer.md.
16
+
17
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
18
+ import { existsSync, readFileSync, statSync } from 'fs';
19
+ import { homedir } from 'os';
20
+ import { join } from 'path';
21
+ import { sessions } from './state.js';
22
+
23
+ export const USERS_PATH = process.env.AGENT007_USERS_PATH
24
+ || join(homedir(), '.agent-007', 'users.json');
25
+
26
+ // Distinct-per-user accent palette (separate from the per-agent color cycle).
27
+ export const USER_COLORS = [
28
+ '#d4a847', '#58a6ff', '#7fbc6a', '#e0853a', '#bc8cff', '#76d9e6', '#ff7b72', '#e3b341',
29
+ ];
30
+
31
+ // WebSocket close code for "authentication required/failed". Shared so the
32
+ // server and client agree (client mirrors this in public/modules/auth.js).
33
+ export const WS_UNAUTHORIZED = 4401;
34
+
35
+ export function hashToken(token) {
36
+ return createHash('sha256').update(String(token)).digest('hex');
37
+ }
38
+
39
+ // users.json may be a bare array or a { users: [...] } wrapper. One normalizer
40
+ // so auth.js and bin/adduser.js can't drift.
41
+ export function normalizeUsers(raw) {
42
+ return Array.isArray(raw) ? raw : (Array.isArray(raw?.users) ? raw.users : []);
43
+ }
44
+
45
+ export function generateToken() {
46
+ return randomBytes(32).toString('base64url');
47
+ }
48
+
49
+ export function newUserId() {
50
+ return 'u_' + randomBytes(4).toString('hex');
51
+ }
52
+
53
+ // Sentinel served when users.json exists but can't be read/parsed and we have no
54
+ // last-known-good copy: length > 0 keeps auth ENABLED, but its unmatchable id and
55
+ // empty tokenHash reject every token. This makes a corrupt file fail CLOSED
56
+ // (deny) rather than open (silently disabling auth for the whole server).
57
+ const DENY_ALL = Object.freeze([Object.freeze({ id: '__deny__', displayName: '', color: '', tokenHash: '' })]);
58
+
59
+ // Cache keyed on mtime AND size so same-second / coarse-FS-resolution rewrites
60
+ // aren't missed (mtime alone can collide on HFS+/NFS). Refreshes without a
61
+ // restart when `adduser` changes the file.
62
+ let _cache = { key: null, users: [] };
63
+ export function loadUsers() {
64
+ if (!existsSync(USERS_PATH)) { _cache = { key: null, users: [] }; return []; }
65
+ try {
66
+ const st = statSync(USERS_PATH);
67
+ const key = `${st.mtimeMs}:${st.size}`;
68
+ if (key === _cache.key) return _cache.users;
69
+ const users = normalizeUsers(JSON.parse(readFileSync(USERS_PATH, 'utf8')));
70
+ _cache = { key, users };
71
+ return users;
72
+ } catch (err) {
73
+ // File exists but couldn't be read/parsed. NEVER disable auth on error:
74
+ // serve the last known-good users if we have them, otherwise deny everything.
75
+ console.warn('users.json unreadable — failing closed (auth stays on):', err.message);
76
+ return _cache.users.length ? _cache.users : DENY_ALL;
77
+ }
78
+ }
79
+
80
+ export function authEnabled() {
81
+ return loadUsers().length > 0;
82
+ }
83
+
84
+ // The shape safe to send to clients (never includes tokenHash).
85
+ export function publicUser(u) {
86
+ return u && { id: u.id, displayName: u.displayName, color: u.color };
87
+ }
88
+
89
+ // Look up a user by id (for labeling session owners). Returns publicUser or null.
90
+ export function userById(id) {
91
+ if (!id) return null;
92
+ return publicUser(loadUsers().find(u => u.id === id)) || null;
93
+ }
94
+
95
+ // Resolve a bearer token to a user, or null. Constant-time compare against each
96
+ // stored hash so a wrong token can't be distinguished by timing.
97
+ export function resolveToken(token) {
98
+ if (!token) return null;
99
+ const presented = Buffer.from(hashToken(token), 'utf8');
100
+ for (const u of loadUsers()) {
101
+ // Skip malformed records; the length guard also satisfies timingSafeEqual's
102
+ // equal-length requirement (hex strings of equal length → equal-size buffers).
103
+ if (!u.tokenHash || u.tokenHash.length !== presented.length) continue;
104
+ if (timingSafeEqual(Buffer.from(u.tokenHash, 'utf8'), presented)) return u;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // Just the Authorization header. Split out because agent tokens are accepted
110
+ // ONLY in this form: a ?token= URL lands in reverse-proxy and access logs, and
111
+ // nothing legitimately puts an agent token in a URL.
112
+ export function tokenFromAuthHeader(req) {
113
+ const auth = req.headers?.authorization;
114
+ if (auth && auth.startsWith('Bearer ')) return auth.slice(7).trim();
115
+ return null;
116
+ }
117
+
118
+ // Pull a bearer token from an Express request: Authorization header first,
119
+ // then a ?token= query param (needed for the WebSocket handshake, where the
120
+ // browser can't set headers).
121
+ export function tokenFromRequest(req) {
122
+ const header = tokenFromAuthHeader(req);
123
+ if (header) return header;
124
+ try {
125
+ const url = new URL(req.url, 'http://localhost');
126
+ const q = url.searchParams.get('token');
127
+ if (q) return q;
128
+ } catch {}
129
+ return null;
130
+ }
131
+
132
+ // --- Agent session tokens (job board: the MCP tool) ---
133
+ //
134
+ // Every agent terminal is spawned with a one-off bearer token so the board's
135
+ // MCP server can tell which agent is calling. The token is minted per session,
136
+ // lives in memory on the session object plus one mode-0600 config file that is
137
+ // deleted when the PTY exits, and stops resolving the moment that session ends.
138
+ // Nothing is written to the agent's environment: an env var is inherited by
139
+ // every child process the agent runs, which is a much wider blast radius than
140
+ // a file only the MCP client reads at startup.
141
+ //
142
+ // It is deliberately NOT a user credential. It identifies one live agent and
143
+ // reaches exactly one surface (POST /mcp and POST /api/jobs); every other /api
144
+ // route is user-only by construction (see requireUser in server/http.js).
145
+ export function mintAgentToken() {
146
+ return 'a007a_' + randomBytes(32).toString('base64url');
147
+ }
148
+
149
+ // Resolve a token to the live session that owns it. Sessions are few, so a scan
150
+ // beats a second index that has to be invalidated on every exit — and an exited
151
+ // session's token stops resolving with no cleanup step at all.
152
+ export function resolveAgentToken(token) {
153
+ if (!token) return null;
154
+ const presented = Buffer.from(String(token), 'utf8');
155
+ for (const session of sessions.values()) {
156
+ if (session.exited || !session.agentToken) continue;
157
+ const stored = Buffer.from(session.agentToken, 'utf8');
158
+ if (stored.length !== presented.length) continue;
159
+ if (timingSafeEqual(stored, presented)) return session;
160
+ }
161
+ return null;
162
+ }
@@ -0,0 +1,176 @@
1
+ // Billion — the one agent you talk to (docs/BILLION.md).
2
+ //
3
+ // A repo-less agent whose working directory is its own git repo, holding its
4
+ // charter and memory. The server starts it on boot (unless it is off, see
5
+ // billionRuns), resuming the last conversation when there is one. Everything here is pure or touches only
6
+ // Billion's own folder; spawning it is server.js's job, like every session.
7
+
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync } from 'fs';
9
+ import { join, dirname, resolve, relative, isAbsolute } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { execFileSync } from 'child_process';
12
+ import { CONFIG_DIR, sessions } from './state.js';
13
+ import { authEnabled } from './auth.js';
14
+
15
+ import { quote } from '../lib/jobs.js';
16
+ import { envSwitchOn } from '../lib/helpers.js';
17
+ export { BILLION_NAME } from '../lib/jobs.js';
18
+
19
+ const TEMPLATE_DIR = fileURLToPath(new URL('../templates/billion/', import.meta.url));
20
+ // Two owners, two kinds of file. The charter is Agent 007's: rewritten on every
21
+ // start, so a new version reaches a Billion that already exists. The rest is
22
+ // the owner's and Billion's, copied once and never touched again. CLAUDE.md
23
+ // (from owner.md) imports the charter and holds the owner's rules. Neither
24
+ // template is named CLAUDE.md here, or an agent working on Agent 007 itself
25
+ // would load Billion's instructions as its own.
26
+ const CHARTER = { from: 'charter.md', to: 'CHARTER.md' };
27
+ const FIRST_RUN_ONLY = { 'owner.md': 'CLAUDE.md', 'STATE.md': 'STATE.md', 'COMPANY.md': 'COMPANY.md' };
28
+ const OS_FILES = ['.DS_Store', 'Thumbs.db', 'desktop.ini'];
29
+ // Written at setup: what makes a folder Billion's. A file name alone is not
30
+ // enough (macOS matches CHARTER.md to a project's charter.md).
31
+ const MARKER = { name: '.billion', text: 'This folder is Billion\'s, set up by Agent 007.\n' };
32
+ // Synchronous, on the server's own thread, so bounded: a global
33
+ // commit.gpgsign waiting on a pinentry, or a hook, must not freeze every
34
+ // terminal. These commits are Agent 007's own bookkeeping in Billion's folder,
35
+ // so the user's signing and hooks are left out of them.
36
+ const GIT_TIMEOUT_MS = 15_000;
37
+ const git = (dir, args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
38
+ const COMMIT = ['-c', 'commit.gpgsign=false', 'commit', '-q', '--no-verify'];
39
+
40
+ // The running Billion, if there is one.
41
+ export function liveBillion() {
42
+ return [...sessions.values()].find(s => s.isBillion && !s.exited) || null;
43
+ }
44
+
45
+ // On unless turned off: BILLION=0 (or false/off/no) in the environment or .env.
46
+ export function billionEnabled(env = process.env) {
47
+ return envSwitchOn(env.BILLION);
48
+ }
49
+
50
+ // Whether this server runs Billion. Not with user accounts: Billion belongs
51
+ // to no one, so every signed-in user could drive an agent that never asks
52
+ // before acting. That waits for a Billion per user.
53
+ export function billionRuns(env = process.env) {
54
+ return billionEnabled(env) && !authEnabled();
55
+ }
56
+
57
+ export function billionDir(env = process.env) {
58
+ return env.BILLION_DIR ? resolve(env.BILLION_DIR) : join(CONFIG_DIR, 'billion');
59
+ }
60
+
61
+ // First run: a git repo with the templates, committed. An existing repo is left
62
+ // exactly as it is — it is Billion's memory. A folder that exists but is not a
63
+ // repo yet (someone made BILLION_DIR by hand) gets the templates it lacks.
64
+ //
65
+ // A repo without Billion's charter is someone else's (BILLION_DIR pointed at a
66
+ // project, say): refused, rather than committing a charter into it and
67
+ // starting an agent there that never asks before acting.
68
+ export function ensureBillionRepo(dir) {
69
+ if (existsSync(join(dir, '.git'))) {
70
+ let marker = null;
71
+ try { marker = readFileSync(join(dir, MARKER.name), 'utf8'); } catch {}
72
+ // Line endings aside: restored with git on Windows it may come back CRLF.
73
+ if (marker?.replace(/\r\n/g, '\n') !== MARKER.text) {
74
+ throw new Error(`${dir} is a git repository without Billion's ${MARKER.name} marker. If it is Billion's folder, restore the file from its git history (git checkout -- ${MARKER.name}); otherwise point BILLION_DIR somewhere else`);
75
+ }
76
+ return { created: false };
77
+ }
78
+ // Nor a folder with anything in it (BILLION_DIR at a home or projects
79
+ // folder): setting up there would put all of it in Billion's repo, and run
80
+ // Billion in it. Only the template files may be there already.
81
+ // The files an OS leaves in any folder it has shown don't count either;
82
+ // they are ignored, so neither this commit nor any of Billion's takes them.
83
+ const ours = new Set([CHARTER.to, ...Object.values(FIRST_RUN_ONLY), '.gitignore', MARKER.name, ...OS_FILES]);
84
+ const theirs = existsSync(dir) ? readdirSync(dir).filter(name => !ours.has(name)) : [];
85
+ if (theirs.length) {
86
+ throw new Error(`${dir} already holds other files (${theirs.slice(0, 3).join(', ')}${theirs.length > 3 ? ', …' : ''}), so it can't be Billion's folder; point BILLION_DIR at a new or empty folder`);
87
+ }
88
+ mkdirSync(dir, { recursive: true });
89
+ for (const [from, to] of [[CHARTER.from, CHARTER.to], ...Object.entries(FIRST_RUN_ONLY)]) {
90
+ const target = join(dir, to);
91
+ if (!existsSync(target)) copyFileSync(join(TEMPLATE_DIR, from), target);
92
+ }
93
+ writeFileSync(join(dir, MARKER.name), MARKER.text);
94
+ const ignore = join(dir, '.gitignore');
95
+ if (!existsSync(ignore)) writeFileSync(ignore, `${OS_FILES.join('\n')}\n`);
96
+ git(dir, ['-c', 'init.defaultBranch=main', 'init', '-q']);
97
+ git(dir, ['add', '-A']);
98
+ // An identity of its own, so a machine with no git user.name still commits.
99
+ git(dir, ['-c', 'user.name=Billion', '-c', 'user.email=billion@agent-007.local', ...COMMIT, '-m', 'Billion: first run']);
100
+ return { created: true };
101
+ }
102
+
103
+ // Bring CHARTER.md up to this version of Agent 007, before Billion starts.
104
+ // Committed on its own — the pathspec leaves anything else Billion had not
105
+ // committed exactly as it was — so the upgrade shows in Billion's history as
106
+ // what it is. Returns whether it changed.
107
+ export function refreshCharter(dir) {
108
+ const target = join(dir, CHARTER.to);
109
+ const text = readFileSync(join(TEMPLATE_DIR, CHARTER.from), 'utf8');
110
+ if (existsSync(target) && readFileSync(target, 'utf8') === text) return false;
111
+ writeFileSync(target, text);
112
+ git(dir, ['add', '--', CHARTER.to]);
113
+ git(dir, ['-c', 'user.name=Agent 007', '-c', 'user.email=agent-007@agent-007.local',
114
+ ...COMMIT, '-m', 'Agent 007: update the charter', '--', CHARTER.to]);
115
+ return true;
116
+ }
117
+
118
+ // The folder holding most of the owner's repos, offered as the place for new
119
+ // ones. The most common parent rather than a common prefix: one repo living
120
+ // elsewhere must not widen the suggestion to the home directory. Repos inside
121
+ // Agent 007's own folder (a worktree added as a repo) are not the owner's
122
+ // projects folder and don't count.
123
+ export function suggestProjectsDir(repoPaths, { ignoreUnder = CONFIG_DIR } = {}) {
124
+ const inside = (p) => { const r = relative(resolve(ignoreUnder), p); return r && !r.startsWith('..') && !isAbsolute(r); };
125
+ const counts = new Map();
126
+ for (const p of repoPaths) {
127
+ if (inside(resolve(p))) continue;
128
+ const parent = dirname(resolve(p));
129
+ counts.set(parent, (counts.get(parent) || 0) + 1);
130
+ }
131
+ let best = null;
132
+ for (const [dir, n] of counts) if (!best || n > counts.get(best)) best = dir;
133
+ return best;
134
+ }
135
+
136
+
137
+ // Everything Billion must do lives in its charter; the prompt only says which
138
+ // part applies. A fresh repo gets the introduction. Any later start says both,
139
+ // because a restart can land mid-introduction: the charter tells it to finish
140
+ // the introduction while STATE.md still says "not started". --continue only
141
+ // when a conversation exists, so a lost transcript still starts cleanly.
142
+ export function billionCommand({ created, hasConversation, dir, projectsHint }) {
143
+ const where = `Your folder is ${dir}.`;
144
+ const hint = projectsHint
145
+ ? `Suggest ${projectsHint} as the projects folder: most of the owner's repos are there.`
146
+ : 'The owner has no repos yet, so ask for a projects folder without suggesting one.';
147
+ const prompt = created
148
+ ? `This is your first run. Introduce yourself as described in CHARTER.md under "First run". ${where} ${hint}`
149
+ : `You were restarted. If STATE.md still says "Status: not started", do or finish your introduction (CHARTER.md, "First run"). ${hint} Otherwise start your operating loop (CHARTER.md, "Operating loop"). ${where}`;
150
+ return `claude --dangerously-skip-permissions${!created && hasConversation ? ' --continue' : ''} ${quote(prompt)}`;
151
+ }
152
+
153
+ // Claude Code asks whether to trust a folder the first time it runs there, and
154
+ // highlights "No, exit". Billion's folder is Agent 007's own, holding only
155
+ // what the server put there, so the server answers for it. Board workers get
156
+ // the same answer for a different reason: queueing a job on a repo already
157
+ // trusts it (server/claude-trust.js has the trade-off and the opt-out). It acts on what the
158
+ // screen shows rather than a fixed key sequence, so a version that highlights
159
+ // "Yes" first still gets the right answer: Ctrl-N off "No", Enter on "Yes",
160
+ // nothing on anything else. The text is everything drawn since the dialog
161
+ // settled, which can hold an older drawing too, so the LAST cursor is the one
162
+ // on screen now. Claude Code draws with cursor moves, so the stripped text may
163
+ // have lost its spaces.
164
+ export function trustDialogKey(screenText) {
165
+ // Both options, not just the phrase: a board worker's screen also shows its
166
+ // job text, which anyone who can post a card chooses.
167
+ if (!/Yes,\s*I\s*trust\s*this\s*folder/i.test(screenText) || !/No,\s*exit/.test(screenText)) return null;
168
+ const at = screenText.lastIndexOf('❯');
169
+ if (at === -1) return null;
170
+ const selected = screenText.slice(at + 1);
171
+ if (/^\s*Yes,\s*I\s*trust\s*this\s*folder/i.test(selected)) return '\r';
172
+ // Ctrl-N, not the down arrow: an arrow starts with ESC, and a lone ESC here
173
+ // is "Esc to cancel", which exits claude. Verified on 2.1.282.
174
+ if (/^\s*No,\s*exit/.test(selected)) return '\x0e';
175
+ return null;
176
+ }
@@ -0,0 +1,66 @@
1
+ // Claude Code stops at a workspace-trust dialog the first time it runs in a
2
+ // folder, and every board worker gets a brand-new worktree, so every dispatch
3
+ // used to wait for a click. Trust is recorded per absolute path in
4
+ // ~/.claude.json (projects[<path>].hasTrustDialogAccepted); writing that entry
5
+ // before the spawn skips the dialog. Verified against claude 2.1.282.
6
+ //
7
+ // Claude Code rewrites this file itself, constantly, so the write is kept as
8
+ // small as it can be: read, add one entry, write a temp file beside it, rename.
9
+ // A running claude that read the file before the rename and writes after it can
10
+ // still drop the entry, and a write of Claude Code's that lands while we
11
+ // build ours makes us give up rather than overwrite it, so the PTY answer
12
+ // (server/pty.js, answerTrustDialog) stays on as a fallback. A missing or
13
+ // unreadable file is left alone: it is Claude Code's, and a guess at its shape
14
+ // would be worse than one dialog.
15
+ import { readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
16
+ import { homedir } from 'os';
17
+ import { join } from 'path';
18
+ import { sessionAgentFromCommand } from '../lib/jobs.js';
19
+ import { envSwitchOn } from '../lib/helpers.js';
20
+
21
+ // Board-dispatched Claude Code workers only, and on unless
22
+ // TRUST_BOARD_WORKTREES says otherwise (0/false/off/no). Hand-started agents
23
+ // keep the dialog.
24
+ export function autoTrusts({ spawnedBy, worktreePath, command }, env = process.env) {
25
+ return spawnedBy === 'board' && !!worktreePath && sessionAgentFromCommand(command) === 'claude'
26
+ && envSwitchOn(env.TRUST_BOARD_WORKTREES);
27
+ }
28
+
29
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
30
+
31
+ // True when the entry is there afterwards. Never throws. Workers inherit the
32
+ // server's environment, so CLAUDE_CONFIG_DIR moves their file and ours alike.
33
+ export function trustClaudeFolder(folder, { home = homedir(), env = process.env } = {}) {
34
+ let file = join(env.CLAUDE_CONFIG_DIR || home, '.claude.json');
35
+ let tmp = null;
36
+ try {
37
+ file = realpathSync(file); // a dotfiles symlink stays a symlink
38
+ tmp = `${file}.agent007-${process.pid}.tmp`;
39
+ const before = statSync(file);
40
+ // claude records the path as its cwd reports it, which is the real one
41
+ // (/tmp is /private/tmp on macOS).
42
+ const path = realpathSync(folder);
43
+ const data = JSON.parse(readFileSync(file, 'utf8'));
44
+ // Any part not shaped as expected means the file is not what we think it
45
+ // is: leave it to Claude Code rather than overwrite what we don't know.
46
+ const projects = data?.projects ?? {};
47
+ if (!isRecord(data) || !isRecord(projects) || !isRecord(projects[path] ?? {})) return false;
48
+ if (projects[path]?.hasTrustDialogAccepted === true) return true;
49
+ data.projects = { ...projects, [path]: { ...projects[path], hasTrustDialogAccepted: true } };
50
+ rmSync(tmp, { force: true }); // a leftover from a crash; 'wx' never follows one
51
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: before.mode & 0o777, flag: 'wx' });
52
+ // Claude Code wrote the file while we built ours: renaming now would drop
53
+ // its change. Leave it to the PTY fallback instead.
54
+ const now = statSync(file);
55
+ if (now.mtimeMs !== before.mtimeMs || now.size !== before.size || now.ino !== before.ino) {
56
+ rmSync(tmp, { force: true });
57
+ return false;
58
+ }
59
+ renameSync(tmp, file);
60
+ return true;
61
+ } catch (err) {
62
+ if (tmp) rmSync(tmp, { force: true });
63
+ console.error(`Could not pre-trust ${folder} in ${file}: ${err.message}`);
64
+ return false;
65
+ }
66
+ }
@@ -0,0 +1,102 @@
1
+ // Windows executable resolution for PTY spawns.
2
+ //
3
+ // node-pty checks that the command exists before returning, but it launches
4
+ // through CreateProcessW with the *unresolved* name. CreateProcessW only ever
5
+ // appends `.exe` — it does not consult PATHEXT — so an npm-installed command
6
+ // like `claude` (which ships as `claude.cmd` plus an extensionless bash shim)
7
+ // passes node-pty's existence check and then fails inside the console host
8
+ // with "Cannot create process, error code: 2" (ERROR_FILE_NOT_FOUND).
9
+ //
10
+ // Worse, that failure is raised on the conout worker thread *after* spawn()
11
+ // has already returned, so it lands as an uncaught exception rather than
12
+ // something the caller can try/catch. Resolving the command to a full path
13
+ // with a launchable extension up front is what keeps it from happening.
14
+
15
+ import { statSync } from 'fs';
16
+ import { join, resolve } from 'path';
17
+
18
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
19
+
20
+ // A Windows PATH is always ';'-separated and its separators are always `\` or
21
+ // `/`, whatever platform this process happens to be running on. Taking those
22
+ // from `path.delimiter` / `path.sep` instead would make the `platform`
23
+ // argument a lie on a POSIX host — and the CI runner is Linux.
24
+ const WIN_PATH_DELIMITER = ';';
25
+ const HAS_SEPARATOR_RE = /[\\/]/;
26
+ const DRIVE_PREFIX_RE = /^[a-zA-Z]:/;
27
+ // Whatever follows the last dot of the final path segment.
28
+ const OWN_EXT_RE = /\.[^.\\/]*$/;
29
+
30
+ function isFile(p) {
31
+ try { return statSync(p).isFile(); } catch { return false; }
32
+ }
33
+
34
+ /**
35
+ * Resolve `file` to a path CreateProcessW can actually launch.
36
+ *
37
+ * Returns the resolved absolute path, or null when nothing matched — callers
38
+ * fall back to the original name so node-pty keeps whatever resolution it can
39
+ * do on its own. Non-Windows platforms are returned unchanged: execvp handles
40
+ * PATH lookup there and has no PATHEXT quirk.
41
+ *
42
+ * @param {string} file command as typed, e.g. 'claude'
43
+ * @param {object} [env] environment to read PATH/PATHEXT from
44
+ * @param {string} [platform]
45
+ * @param {string} [cwd] base for relative paths
46
+ */
47
+ export function resolveExecutable(file, env = process.env, platform = process.platform, cwd = process.cwd()) {
48
+ if (platform !== 'win32' || !file) return null;
49
+
50
+ // PATHEXT is conventionally uppercase but the files on disk are not, and the
51
+ // resolved path ends up in log lines and error messages, so match the
52
+ // lowercase convention. Windows itself does not care either way.
53
+ const exts = (env.PATHEXT || DEFAULT_PATHEXT)
54
+ .split(';')
55
+ .map(e => e.trim().toLowerCase())
56
+ .filter(Boolean);
57
+
58
+ // A command that already carries a launchable extension is used verbatim;
59
+ // anything else gets each PATHEXT entry appended, the same order cmd.exe
60
+ // would try them in.
61
+ const ownExt = (file.match(OWN_EXT_RE) || [''])[0];
62
+ const candidates = ownExt && exts.includes(ownExt.toLowerCase())
63
+ ? [file]
64
+ : exts.map(e => file + e);
65
+
66
+ // A command carrying a separator or a drive letter is a path, not a PATH
67
+ // lookup. Relative ones resolve against the working directory.
68
+ if (HAS_SEPARATOR_RE.test(file) || DRIVE_PREFIX_RE.test(file)) {
69
+ for (const candidate of candidates) {
70
+ const full = resolve(cwd, candidate);
71
+ if (isFile(full)) return full;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ // PATH only — deliberately NOT the working directory, even though cmd.exe
77
+ // searches there first. The working directory is the agent's worktree, so
78
+ // searching it would let a `claude.cmd` committed to a repository replace the
79
+ // command the agent runs. child_process and PowerShell skip it too.
80
+ const dirs = (env.PATH || env.Path || '').split(WIN_PATH_DELIMITER).filter(Boolean);
81
+ for (const dir of dirs) {
82
+ for (const candidate of candidates) {
83
+ const full = join(dir.replace(/^"|"$/g, ''), candidate);
84
+ if (isFile(full)) return full;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * True when `dir` is usable as a PTY working directory.
92
+ *
93
+ * CreateProcessW rejects a missing lpCurrentDirectory with error code 267
94
+ * (ERROR_DIRECTORY) — and, like the command lookup above, it does so
95
+ * asynchronously. A re-spawn whose worktree has since been deleted is the
96
+ * common way to get there, so the caller checks first and reports a real
97
+ * error instead of taking the server down.
98
+ */
99
+ export function isUsableCwd(dir) {
100
+ if (!dir) return false;
101
+ try { return statSync(dir).isDirectory(); } catch { return false; }
102
+ }