@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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- 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
|
+
}
|