crew-runner 0.1.0 → 0.2.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.
@@ -0,0 +1,151 @@
1
+ import type * as acp from "@agentclientprotocol/sdk";
2
+ import { runAcp, type SeenCall } from "./acp.ts";
3
+ import type { Claimed, RunEvent, RunnerApi } from "./api.ts";
4
+ import type { Outcome } from "./claude.ts";
5
+ import { chmod, mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { computerFolders, computerPrompt, judgeInRun, newRunMemory, noteAllowed, prepareWorkspaceFolder, type Folders, type Grant, type Verdict } from "./computer.ts";
9
+
10
+ const POLL_MS = 3_000;
11
+ // How often a computer run checks it is still running. The owner's off switch cancels it on the server.
12
+ const ALIVE_MS = 5_000;
13
+ const sleep = (ms: number, signal?: AbortSignal) =>
14
+ new Promise<void>((r) => {
15
+ const t = setTimeout(r, ms);
16
+ signal?.addEventListener("abort", () => (clearTimeout(t), r()), { once: true });
17
+ });
18
+
19
+ type Ask = Pick<RunnerApi, "askOwner" | "ownerAnswer" | "alive">;
20
+
21
+ // Waits for the owner's answer on their CrewCortex card. Anything but a yes (no, expired, run stopped, network) is a no.
22
+ // "task": the owner chose "Allow for this task" on a card that offered it; on any other card it counts once.
23
+ export type Yes = "once" | "task";
24
+ async function answerTo(api: Pick<RunnerApi, "ownerAnswer">, runId: string, card: string, v: Extract<Verdict, { kind: "ask" }>, signal?: AbortSignal, pollMs = POLL_MS): Promise<Yes | false> {
25
+ while (!signal?.aborted) {
26
+ const answer = await api.ownerAnswer(runId, card).catch(() => null);
27
+ if (answer === "approved_task") return v.grant ? "task" : "once";
28
+ if (answer === "approved") return "once";
29
+ if (answer !== "pending") return false;
30
+ await sleep(pollMs, signal);
31
+ }
32
+ return false;
33
+ }
34
+
35
+ const ask = (api: Pick<RunnerApi, "askOwner">, runId: string, v: Extract<Verdict, { kind: "ask" }>) =>
36
+ (v.grant ? api.askOwner(runId, v.category, v.detail, v.grant) : api.askOwner(runId, v.category, v.detail)).catch(() => null);
37
+
38
+ export async function ownerSaysYes(api: Pick<RunnerApi, "askOwner" | "ownerAnswer">, runId: string, v: Extract<Verdict, { kind: "ask" }>, signal?: AbortSignal, pollMs = POLL_MS): Promise<Yes | false> {
39
+ const card = await ask(api, runId, v);
40
+ return card ? answerTo(api, runId, card, v, signal, pollMs) : false;
41
+ }
42
+
43
+ // One run's decisions. Grants the owner gave with "Allow for this task", and whether the run has read anything on this
44
+ // computer yet, live here and nowhere else, so they end with the run and never reach another one. Every step that runs under a grant is written to the action log with the card that
45
+ // allowed it; a step runs only once its record is saved, and a refusal is recorded as well as possible.
46
+ export function stepDecider(api: Pick<RunnerApi, "askOwner" | "ownerAnswer">, runId: string, f: Folders, record: (output: Record<string, unknown>) => Promise<boolean>, signal?: AbortSignal) {
47
+ const grants = new Map<Grant, string>();
48
+ const mem = newRunMemory();
49
+ return async (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>): Promise<boolean> => {
50
+ const v = judgeInRun(f, p, calls, mem);
51
+ const why = v.kind === "allow" ? {} : v.kind === "deny" ? { reason: v.reason } : { asked: v.category, detail: v.detail };
52
+ let how: Record<string, unknown> = {};
53
+ let yes = v.kind === "allow";
54
+ if (v.kind === "ask") {
55
+ const grantedBy = v.grant && grants.get(v.grant);
56
+ if (grantedBy) {
57
+ yes = true;
58
+ how = { allowed_by_card: grantedBy };
59
+ } else {
60
+ const card = await ask(api, runId, v);
61
+ const answer = card ? await answerTo(api, runId, card, v, signal) : false;
62
+ yes = answer !== false;
63
+ if (answer === "task" && card) how = { card, scope: "task" };
64
+ if (answer === "task" && v.grant && card) grants.set(v.grant, card);
65
+ }
66
+ }
67
+ const allowed = yes && (await record({ decision: "allowed", ...why, ...how }));
68
+ if (allowed) noteAllowed(f, p, calls, v, mem);
69
+ else await record({ decision: "refused", ...why, ...how });
70
+ return allowed;
71
+ };
72
+ }
73
+
74
+ // Claude Code's working folder for one run: new, empty and read-only, so Claude Code never finds anything there to read
75
+ // or run without asking the runner, and no approved step can leave a file or link there. Removed after the run.
76
+ export async function makeScratch(): Promise<string> {
77
+ const dir = await mkdtemp(join(tmpdir(), "crew-mac-"));
78
+ await chmod(dir, 0o500);
79
+ return dir;
80
+ }
81
+
82
+ export type ComputerRunOptions = {
83
+ timeoutMs: number; bin?: string; signal?: AbortSignal; home?: string; aliveMs?: number;
84
+ launch?: { command: string; args: string[]; env?: Record<string, string> };
85
+ };
86
+
87
+ // Aborts `stop` once the server says the run is no longer running (the owner switched @mac off, or it was cancelled).
88
+ // A network error or an older server (no answer) is not a stop. Returns the function that ends the watch.
89
+ export function watchRun(api: Pick<RunnerApi, "alive">, runId: string, stop: AbortController, everyMs = ALIVE_MS): () => void {
90
+ let busy = false;
91
+ const timer = setInterval(() => {
92
+ if (busy || stop.signal.aborted) return;
93
+ busy = true;
94
+ api.alive(runId).catch(() => null).then((running) => {
95
+ busy = false;
96
+ if (running === false) stop.abort();
97
+ });
98
+ }, everyMs);
99
+ return () => clearInterval(timer);
100
+ }
101
+
102
+ // One @mac run: the workspace folder, Claude Code's own tools, every decision written to the run's action log.
103
+ export async function runComputer(api: Ask, pkg: Claimed, sink: (events: RunEvent[]) => Promise<void>, opts: ComputerRunOptions): Promise<Outcome> {
104
+ const slug = pkg.computer?.workspace_slug;
105
+ if (!slug) return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
106
+ let f;
107
+ let scratch;
108
+ try {
109
+ f = computerFolders(slug, opts.home);
110
+ await prepareWorkspaceFolder(f);
111
+ scratch = await makeScratch();
112
+ } catch {
113
+ return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
114
+ }
115
+ // One signal for Ctrl+C and for the owner's off switch.
116
+ const stop = new AbortController();
117
+ const onOuter = () => stop.abort();
118
+ if (opts.signal?.aborted) stop.abort();
119
+ else opts.signal?.addEventListener("abort", onOuter, { once: true });
120
+ const unwatch = watchRun(api, pkg.run_id, stop, opts.aliveMs);
121
+ // Every decision goes into the run's action log. A step is allowed only once its record is saved; a refusal is
122
+ // recorded as well as possible (refusing never needs the record to land).
123
+ const record = async (output: Record<string, unknown>): Promise<boolean> => {
124
+ for (let attempt = 0; attempt < 3; attempt++) {
125
+ try {
126
+ await sink([{ type: "step", kind: "tool", name: "permission", output }]);
127
+ return true;
128
+ } catch {
129
+ if (stop.signal.aborted) return false;
130
+ }
131
+ }
132
+ return false;
133
+ };
134
+ const decide = stepDecider(api, pkg.run_id, f, record, stop.signal);
135
+ const systemPrompt = `${computerPrompt(f, slug)}\n\nWorkspace guidance (from CrewCortex):\n${pkg.instructions}`;
136
+ try {
137
+ return await runAcp("claude", pkg, sink, {
138
+ timeoutMs: opts.timeoutMs,
139
+ bin: opts.bin,
140
+ signal: stop.signal,
141
+ launch: opts.launch,
142
+ // No additional directories: Claude Code then asks the runner about everything outside the scratch folder (the
143
+ // workspace folder, Downloads and dev projects included), instead of approving reads and read-only commands by itself.
144
+ computer: { cwd: scratch, additionalDirectories: [], systemPrompt, decide },
145
+ });
146
+ } finally {
147
+ unwatch();
148
+ await chmod(scratch, 0o700).then(() => rm(scratch, { recursive: true, force: true })).catch(() => undefined);
149
+ opts.signal?.removeEventListener("abort", onOuter);
150
+ }
151
+ }
@@ -0,0 +1,187 @@
1
+ // Web requests in computer mode. A plain curl that only downloads a public page (to the screen or into this workspace's
2
+ // folder) runs without asking. A curl that sends what its own command line shows can be allowed for the rest of a task.
3
+ // Anything this cannot read with certainty is left to the card. Reading is strict on purpose: it accepts a small part of
4
+ // what zsh accepts and gives up on everything else, so a command never means more to the shell than it does here.
5
+
6
+ // Unquoted characters zsh gives no meaning to in an argument. "~" is handled at the start of a word only; "=" and "%"
7
+ // never start one, and "=" never follows "=" or ":" (zsh expands =name to a program's path, also after = or : under
8
+ // MAGIC_EQUAL_SUBST).
9
+ const PLAIN_CHAR = /^[A-Za-z0-9_\-./:@%+,=]$/;
10
+
11
+ // The words of one plain command, or null when anything could be expanded, chained, redirected or read differently by
12
+ // zsh: unquoted specials, $, backticks, !, escapes, "''" (one quote under RC_QUOTES), non-ASCII and control characters.
13
+ // `quoted`: the word had a quote in it, so a leading ~ is a literal folder name, not the home folder.
14
+ export function plainWords(command: string): { words: string[]; quoted: boolean[] } | null {
15
+ if (!/^[\x20-\x7e]*$/.test(command)) return null;
16
+ const words: string[] = [];
17
+ const quoted: boolean[] = [];
18
+ let word: string | null = null;
19
+ let q = false;
20
+ const end = () => {
21
+ if (word !== null) {
22
+ words.push(word);
23
+ quoted.push(q);
24
+ }
25
+ word = null;
26
+ q = false;
27
+ };
28
+ for (let i = 0; i < command.length; i++) {
29
+ const c = command[i]!;
30
+ if (c === " ") {
31
+ end();
32
+ } else if (c === "'") {
33
+ const close = command.indexOf("'", i + 1);
34
+ if (close < 0 || command[close + 1] === "'") return null;
35
+ word = (word ?? "") + command.slice(i + 1, close);
36
+ q = true;
37
+ i = close;
38
+ } else if (c === '"') {
39
+ const close = command.indexOf('"', i + 1);
40
+ if (close < 0) return null;
41
+ const inner = command.slice(i + 1, close);
42
+ // Inside double quotes zsh still expands $, ` and !, and a backslash escapes only those and \ and ". A backslash
43
+ // before a letter or digit stays as it is (curl reads "\n" in a write-out format itself).
44
+ if (/[$`!]/.test(inner) || /\\(?![A-Za-z0-9])/.test(inner)) return null;
45
+ word = (word ?? "") + inner;
46
+ q = true;
47
+ i = close;
48
+ } else if (c === "~" && word === null && (command[i + 1] === "/" || command[i + 1] === " " || i + 1 === command.length)) {
49
+ word = "~";
50
+ } else if (PLAIN_CHAR.test(c) && !(c === "=" && (word === null || /[=:]$/.test(word))) && !(word === null && c === "%")) {
51
+ word = (word ?? "") + c;
52
+ } else {
53
+ return null;
54
+ }
55
+ }
56
+ end();
57
+ return { words, quoted };
58
+ }
59
+
60
+ const MAX_ADDRESS = 500;
61
+ // Names that only make sense on a local network or are reserved for testing.
62
+ const PRIVATE_SUFFIXES = new Set(["localhost", "local", "localdomain", "internal", "intranet", "lan", "home", "corp", "private", "arpa", "test", "invalid"]);
63
+ const LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
64
+ // http(s), a dotted name and nothing else before the path: no login, no port, no numeric or bracketed address.
65
+ const ADDRESS = new RegExp(`^https?://(${LABEL}(?:\\.${LABEL})+)(?=[/?#]|$)[A-Za-z0-9\\-._~:/?#@!$&'()*+,;=%]*$`);
66
+
67
+ // The host of a web address on a public name that curl and the WHATWG parser read the same way, or null. Letter case,
68
+ // brackets and braces (curl expands those into several addresses) and anything unusual are refused, not interpreted.
69
+ // A public name can still resolve to a private address; that reaches only ports 80 and 443 with a GET.
70
+ export function publicHost(raw: string): string | null {
71
+ if (raw.length > MAX_ADDRESS) return null;
72
+ const m = ADDRESS.exec(raw);
73
+ if (!m) return null;
74
+ const host = m[1]!;
75
+ const top = host.slice(host.lastIndexOf(".") + 1);
76
+ if (!/^[a-z]{2,}$/.test(top) && !/^xn--[a-z0-9-]+$/.test(top)) return null;
77
+ if (PRIVATE_SUFFIXES.has(top)) return null;
78
+ try {
79
+ const url = new URL(raw);
80
+ return url.hostname === host && url.username === "" && url.password === "" && url.port === "" ? host : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // curl's options a download may use. Switches only change what curl prints or how it waits; none sends anything.
87
+ // Following redirects (-L) is not among them: a public site could send the request on to a local address, so it gets a
88
+ // card (and may then be allowed for the task, for that host).
89
+ const DOWNLOAD_SHORT = "sSfiI";
90
+ const DOWNLOAD_SWITCHES = new Set(["--silent", "--show-error", "--fail", "--include", "--head", "--compressed", "--create-dirs"]);
91
+ const REDIRECT_SWITCH = "--location";
92
+ const NUMBER = /^\d{1,3}$/;
93
+ // A write-out format of plain text and %{variable} only: %output{} writes a file, %header{} and @file read more.
94
+ const WRITE_OUT = /^(?:[^%@\\]|%\{[a-z_]+\}|\\[nrt])*$/;
95
+ const DOWNLOAD_VALUED: ReadonlyMap<string, (v: string) => boolean> = new Map([
96
+ ["-m", (v: string) => NUMBER.test(v)],
97
+ ["--max-time", (v: string) => NUMBER.test(v)],
98
+ ["--connect-timeout", (v: string) => NUMBER.test(v)],
99
+ ["--retry", (v: string) => /^[0-5]$/.test(v)],
100
+ ["-A", () => true],
101
+ ["--user-agent", () => true],
102
+ ["-w", (v: string) => WRITE_OUT.test(v)],
103
+ ["--write-out", (v: string) => WRITE_OUT.test(v)],
104
+ ]);
105
+ // What a request may also carry when the owner allowed web requests for the task: only text written on the command line.
106
+ // A value starting with @ would read a local file (curl's -d @file, -H @file, --json @file).
107
+ const inline = (v: string) => !v.startsWith("@");
108
+ const SEND_VALUED: ReadonlyMap<string, (v: string) => boolean> = new Map([
109
+ ["-X", (v: string) => /^[A-Z]{1,10}$/.test(v)],
110
+ ["--request", (v: string) => /^[A-Z]{1,10}$/.test(v)],
111
+ ["-d", inline],
112
+ ["--data", inline],
113
+ ["--data-raw", () => true],
114
+ ["--json", inline],
115
+ ["-H", inline],
116
+ ["--header", inline],
117
+ ]);
118
+ const OUTPUT = new Set(["-o", "--output"]);
119
+ const DATA = new Set(["-d", "--data", "--data-raw", "--json"]);
120
+
121
+ // "download": a read-only GET (or HEAD) of one public address, printed or saved where `saveOk` allows. "web": also sends
122
+ // text written on the command line (a method, data, headers) or follows redirects, so it needs a card but can be allowed
123
+ // for the task for the same method and host. null: not a curl request this can read with certainty (an upload of a file,
124
+ // a login, a cookie jar, a config file, a pipe, another program), so it gets a card and is never covered by a task grant.
125
+ // `output`: the file a download saves to, as written.
126
+ // `redirects`: follows redirects (-L). `extras`: carries data or headers.
127
+ export type CurlRequest = { kind: "download" | "web"; method: string; host: string; output: string | null; redirects: boolean; extras: boolean };
128
+
129
+ export function curlRequest(command: string, saveOk: (path: string, quoted: boolean) => boolean): CurlRequest | null {
130
+ const parsed = plainWords(command);
131
+ if (!parsed || parsed.words[0] !== "curl") return null;
132
+ const { words, quoted } = parsed;
133
+ let sends = false;
134
+ let method: string | null = null;
135
+ let head = false;
136
+ let data = false;
137
+ let redirects = false;
138
+ let extras = false;
139
+ let host: string | null = null;
140
+ let output: string | null = null;
141
+ let outputs = 0;
142
+ for (let i = 1; i < words.length; i++) {
143
+ const w = words[i]!;
144
+ const value = words[i + 1];
145
+ if (!w.startsWith("-")) {
146
+ if (host !== null) return null;
147
+ host = publicHost(w);
148
+ if (host === null) return null;
149
+ } else if (/^-[A-Za-z]+$/.test(w) && [...w.slice(1)].every((c) => DOWNLOAD_SHORT.includes(c) || c === "L")) {
150
+ if (w.includes("L")) redirects = sends = true;
151
+ if (w.includes("I")) head = true;
152
+ } else if (w === REDIRECT_SWITCH) {
153
+ redirects = sends = true;
154
+ } else if (DOWNLOAD_SWITCHES.has(w)) {
155
+ if (w === "--head") head = true;
156
+ } else if (OUTPUT.has(w)) {
157
+ if (value === undefined || (value !== "-" && !saveOk(value, quoted[i + 1]!))) return null;
158
+ output = value === "-" ? null : value;
159
+ outputs++;
160
+ i++;
161
+ } else if (DOWNLOAD_VALUED.has(w)) {
162
+ if (value === undefined || !DOWNLOAD_VALUED.get(w)!(value)) return null;
163
+ i++;
164
+ } else if (SEND_VALUED.has(w)) {
165
+ if (value === undefined || !SEND_VALUED.get(w)!(value)) return null;
166
+ if (w === "-X" || w === "--request") method = value;
167
+ if (DATA.has(w)) data = true;
168
+ if (w !== "-X" && w !== "--request") extras = true;
169
+ sends = true;
170
+ i++;
171
+ } else {
172
+ return null;
173
+ }
174
+ }
175
+ if (host === null || outputs > 1) return null;
176
+ return { kind: sends ? "web" : "download", method: method ?? (data ? "POST" : head ? "HEAD" : "GET"), host, output, redirects, extras };
177
+ }
178
+
179
+
180
+ // Where curl reads a config file without being told to (curl -q is the only way to skip it). A config can add data,
181
+ // uploads or a login to every request, so while one exists a download is never run without a card.
182
+ export function curlConfigFiles(home: string, env: Record<string, string | undefined>): string[] {
183
+ const files = [`${home}/.curlrc`, `${home}/.config/curlrc`, `${home}/.config/.curlrc`];
184
+ if (env.CURL_HOME) files.push(`${env.CURL_HOME}/.curlrc`);
185
+ if (env.XDG_CONFIG_HOME) files.push(`${env.XDG_CONFIG_HOME}/curlrc`, `${env.XDG_CONFIG_HOME}/.curlrc`);
186
+ return files;
187
+ }