crew-runner 0.0.0-stage → 0.2.0
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 +131 -2
- package/STUDIO.md +128 -0
- package/bin/crew-runner.mjs +6 -0
- package/bin/crew-studio.mjs +5 -0
- package/package.json +44 -4
- package/src/acp.ts +391 -0
- package/src/api.ts +87 -0
- package/src/claude.ts +155 -0
- package/src/cli.ts +186 -0
- package/src/codex.ts +158 -0
- package/src/computer-run.ts +118 -0
- package/src/computer.ts +324 -0
- package/src/loop.ts +38 -0
- package/src/pair-code.ts +14 -0
- package/src/spawn.ts +120 -0
- package/src/store.ts +30 -0
- package/src/studio-audio-admission.ts +47 -0
- package/src/studio-cli.ts +47 -0
- package/src/studio-mcp.ts +38 -0
- package/src/studio-source.ts +77 -0
- package/src/studio.ts +402 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createApi, type ClaimMode, type RunnerApi } from "./api.ts";
|
|
4
|
+
import { runAcp } from "./acp.ts";
|
|
5
|
+
import { runComputer } from "./computer-run.ts";
|
|
6
|
+
import { checkClaude, runClaude, type Check } from "./claude.ts";
|
|
7
|
+
import { checkCodex, runCodex } from "./codex.ts";
|
|
8
|
+
import { loop, RUN_TIMEOUT_MS, type Runner } from "./loop.ts";
|
|
9
|
+
import { pairConfirmationCode } from "./pair-code.ts";
|
|
10
|
+
import * as store from "./store.ts";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_APP_URL = "https://app.crewcortex.com";
|
|
13
|
+
const LINK_WAIT_MS = 5 * 60 * 1000;
|
|
14
|
+
const LINK_POLL_MS = 2_000;
|
|
15
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
function openBrowser(url: string): void {
|
|
18
|
+
// Not `cmd /c start`: cmd treats the & in a URL as the end of the command. rundll32 takes the URL as one argument.
|
|
19
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
20
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
21
|
+
try {
|
|
22
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
|
|
23
|
+
} catch {
|
|
24
|
+
// The URL is printed too.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The token is made here and never sent. The server only gets its SHA-256 (the "code") through the signed-in member.
|
|
29
|
+
async function login(appUrlArg: string | undefined): Promise<number> {
|
|
30
|
+
const appUrl = (appUrlArg || process.env.CREW_APP_URL || DEFAULT_APP_URL).replace(/\/+$/, "");
|
|
31
|
+
if (!/^https?:\/\//.test(appUrl)) {
|
|
32
|
+
console.error("Usage: crew-runner login [app url]");
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
// 32 bytes base64url (43 chars): the only shape the server's bearerToken() accepts.
|
|
36
|
+
const token = randomBytes(32).toString("base64url");
|
|
37
|
+
const code = createHash("sha256").update(token).digest("hex");
|
|
38
|
+
const url = `${appUrl}/runner/link?code=${code}`;
|
|
39
|
+
console.log(`Open this page, sign in and confirm this computer:\n${url}`);
|
|
40
|
+
console.log(`\nConfirmation code: ${pairConfirmationCode(code)}\nOnly approve if the page shows this exact code. If it differs, or you did not run this command, close the page.`);
|
|
41
|
+
openBrowser(url);
|
|
42
|
+
const api = createApi({ appUrl, token });
|
|
43
|
+
const until = Date.now() + LINK_WAIT_MS;
|
|
44
|
+
while (Date.now() < until) {
|
|
45
|
+
if (await api.ping().catch(() => false)) {
|
|
46
|
+
await store.save({ appUrl, token });
|
|
47
|
+
console.log("Linked. Now run `npx crew-runner@latest start` (or `crew-runner start` if you installed it) and leave it running.");
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
await sleep(LINK_POLL_MS);
|
|
51
|
+
}
|
|
52
|
+
console.error("Timed out waiting for the link to be confirmed.");
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type CliName = "claude" | "codex";
|
|
57
|
+
|
|
58
|
+
// Default is the ACP path. CREW_RUNNER_PROTOCOL=legacy keeps the old direct adapters for one release.
|
|
59
|
+
const legacy = () => process.env.CREW_RUNNER_PROTOCOL === "legacy";
|
|
60
|
+
|
|
61
|
+
const CLIS: Record<CliName, { label: string; bin: () => string; check: (bin: string) => Promise<Check>; runner: (bin: string) => Runner }> = {
|
|
62
|
+
claude: {
|
|
63
|
+
label: "Claude Code",
|
|
64
|
+
bin: () => process.env.CREW_CLAUDE_BIN || "claude",
|
|
65
|
+
check: checkClaude,
|
|
66
|
+
runner: (bin) => (pkg, sink, signal) =>
|
|
67
|
+
legacy() ? runClaude(pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS }) : runAcp("claude", pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS, signal }),
|
|
68
|
+
},
|
|
69
|
+
codex: {
|
|
70
|
+
label: "Codex",
|
|
71
|
+
bin: () => process.env.CREW_CODEX_BIN || "codex",
|
|
72
|
+
check: checkCodex,
|
|
73
|
+
runner: (bin) => (pkg, sink, signal) =>
|
|
74
|
+
legacy() ? runCodex(pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS }) : runAcp("codex", pkg, sink, { timeoutMs: RUN_TIMEOUT_MS, signal }),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const CLI_NAMES = Object.keys(CLIS) as CliName[];
|
|
78
|
+
|
|
79
|
+
function flagValue(args: string[], flag: string): string | undefined {
|
|
80
|
+
const i = args.indexOf(flag);
|
|
81
|
+
if (i >= 0) return args[i + 1];
|
|
82
|
+
return args.find((a) => a.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Picks which app runs the agents: --cli, then CREW_RUNNER_CLI, else the first one that is installed and signed in.
|
|
86
|
+
async function chooseCli(args: string[]): Promise<{ name: CliName; version: string } | { error: string }> {
|
|
87
|
+
const asked = flagValue(args, "--cli") ?? process.env.CREW_RUNNER_CLI;
|
|
88
|
+
if (asked) {
|
|
89
|
+
if (!CLI_NAMES.includes(asked as CliName)) return { error: `Unknown --cli "${asked}". Use claude or codex.` };
|
|
90
|
+
const name = asked as CliName;
|
|
91
|
+
const check = await CLIS[name].check(CLIS[name].bin());
|
|
92
|
+
return check.ok ? { name, version: check.version } : { error: check.message };
|
|
93
|
+
}
|
|
94
|
+
const messages: string[] = [];
|
|
95
|
+
for (const name of CLI_NAMES) {
|
|
96
|
+
const check = await CLIS[name].check(CLIS[name].bin());
|
|
97
|
+
if (check.ok) return { name, version: check.version };
|
|
98
|
+
messages.push(check.message);
|
|
99
|
+
}
|
|
100
|
+
return { error: messages.join("\n") };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function start(args: string[]): Promise<number> {
|
|
104
|
+
const cfg = await store.load();
|
|
105
|
+
if (!cfg) {
|
|
106
|
+
console.error("Not linked. Run `npx crew-runner@latest login` first.");
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
if (args.includes("--computer")) return startComputer(cfg);
|
|
110
|
+
const chosen = await chooseCli(args);
|
|
111
|
+
if ("error" in chosen) {
|
|
112
|
+
console.error(chosen.error);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
const cli = CLIS[chosen.name];
|
|
116
|
+
return serve(cfg, [`Ready. ${cli.label} ${chosen.version}. Waiting for runs. Press Ctrl+C to stop.`], () => cli.runner(cli.bin()), "default");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Computer mode: only this member's @mac runs, with Claude Code's own tools in the allowed folders. An ordinary
|
|
120
|
+
// `start` never receives them. Claude Code only (Codex is not supported here).
|
|
121
|
+
async function startComputer(cfg: store.Stored): Promise<number> {
|
|
122
|
+
const bin = CLIS.claude.bin();
|
|
123
|
+
const check = await CLIS.claude.check(bin);
|
|
124
|
+
if (!check.ok) {
|
|
125
|
+
console.error(check.message);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
return serve(cfg, [
|
|
129
|
+
`Computer mode. Claude Code ${check.version}. Works in ~/CrewCortex/<workspace>, ~/Downloads and ~/.cursor/dev projects.`,
|
|
130
|
+
"Deletes, git push, sends, payments and changes outside those folders wait for your yes in CrewCortex. Press Ctrl+C to stop.",
|
|
131
|
+
], (api) => (pkg, sink, signal) => runComputer(api, pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS, signal }), "computer");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Both modes: check the device, then claim and run until Ctrl+C (or SIGTERM, or SIGBREAK on Windows).
|
|
135
|
+
async function serve(cfg: store.Stored, banner: string[], runnerFor: (api: RunnerApi) => Runner, mode: ClaimMode): Promise<number> {
|
|
136
|
+
const api = createApi(cfg);
|
|
137
|
+
if (!(await api.ping().catch(() => false))) {
|
|
138
|
+
console.error("The server did not accept this device. Run `npx crew-runner@latest login` again.");
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
for (const line of banner) console.log(line);
|
|
142
|
+
const ac = new AbortController();
|
|
143
|
+
process.once("SIGINT", () => ac.abort());
|
|
144
|
+
process.once("SIGTERM", () => ac.abort());
|
|
145
|
+
// Windows sends SIGBREAK (Ctrl+Break, closing the console window) instead of SIGTERM.
|
|
146
|
+
if (process.platform === "win32") process.once("SIGBREAK", () => ac.abort());
|
|
147
|
+
await loop(api, runnerFor(api), ac.signal, (m) => console.log(m), mode);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function status(): Promise<number> {
|
|
152
|
+
const cfg = await store.load();
|
|
153
|
+
if (!cfg) {
|
|
154
|
+
console.log("Not linked.");
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
const linked = await createApi(cfg).ping().catch(() => false);
|
|
158
|
+
console.log(`Server: ${cfg.appUrl}`);
|
|
159
|
+
console.log(`Device: ${linked ? "linked" : "not accepted (run login again)"}`);
|
|
160
|
+
let anyReady = false;
|
|
161
|
+
for (const name of CLI_NAMES) {
|
|
162
|
+
const check = await CLIS[name].check(CLIS[name].bin());
|
|
163
|
+
anyReady ||= check.ok;
|
|
164
|
+
console.log(`${CLIS[name].label}: ${check.ok ? `ready (${check.version})` : check.message}`);
|
|
165
|
+
}
|
|
166
|
+
return linked && anyReady ? 0 : 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function logout(): Promise<number> {
|
|
170
|
+
const cfg = await store.load();
|
|
171
|
+
if (cfg) await createApi(cfg).revoke().catch(() => false);
|
|
172
|
+
await store.clear();
|
|
173
|
+
console.log("Logged out. This device can no longer receive runs.");
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function main(argv: string[]): Promise<number> {
|
|
178
|
+
const [cmd, arg] = argv;
|
|
179
|
+
if (cmd === "studio") return (await import("./studio-cli.ts")).studioMain(argv.slice(1));
|
|
180
|
+
if (cmd === "login") return login(arg);
|
|
181
|
+
if (cmd === "start") return start(argv.slice(1));
|
|
182
|
+
if (cmd === "status") return status();
|
|
183
|
+
if (cmd === "logout") return logout();
|
|
184
|
+
console.log("Usage: crew-runner <login [app url] | start [--cli claude|codex] | start --computer | status | logout>");
|
|
185
|
+
return cmd ? 2 : 0;
|
|
186
|
+
}
|
package/src/codex.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import type { Claimed, RunEvent } from "./api.ts";
|
|
6
|
+
import { spawnBin } from "./spawn.ts";
|
|
7
|
+
import { parseVersion, promptFrom, run, type Check, type Outcome } from "./claude.ts";
|
|
8
|
+
|
|
9
|
+
// Drives the member's own `codex` app in exec mode. Like claude.ts we only run its public commands
|
|
10
|
+
// (--version, login status) and read the exit code. We never open its files or environment.
|
|
11
|
+
const MIN_CODEX_MAJOR = 0;
|
|
12
|
+
const MIN_CODEX_MINOR = 142;
|
|
13
|
+
const MAX_TEXT = 100_000;
|
|
14
|
+
const TOKEN_ENV = "CREW_RUN_TOKEN";
|
|
15
|
+
|
|
16
|
+
// Every enabled feature in `codex features list` that adds tool surface (checked on 0.142.5). Our MCP server is the only
|
|
17
|
+
// intended tool, so the shell, browser, computer, image, apps, plugin and sub-agent tools are all switched off.
|
|
18
|
+
export const DISABLED_FEATURES = [
|
|
19
|
+
"shell_tool",
|
|
20
|
+
"unified_exec",
|
|
21
|
+
"apps",
|
|
22
|
+
"browser_use",
|
|
23
|
+
"browser_use_external",
|
|
24
|
+
"browser_use_full_cdp_access",
|
|
25
|
+
"computer_use",
|
|
26
|
+
"in_app_browser",
|
|
27
|
+
"image_generation",
|
|
28
|
+
"multi_agent",
|
|
29
|
+
"plugins",
|
|
30
|
+
"tool_suggest",
|
|
31
|
+
"goals",
|
|
32
|
+
"hooks",
|
|
33
|
+
"skill_mcp_dependency_install",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export async function checkCodex(bin = "codex"): Promise<Check> {
|
|
37
|
+
const v = await run(bin, ["--version"]);
|
|
38
|
+
if (v.code !== 0) return { ok: false, message: "Codex is not installed. Install it, sign in with `codex login`, then run this again." };
|
|
39
|
+
const ver = parseVersion(v.out);
|
|
40
|
+
if (!ver) return { ok: false, message: "Could not read the Codex version." };
|
|
41
|
+
if (ver.major < MIN_CODEX_MAJOR || (ver.major === MIN_CODEX_MAJOR && ver.minor < MIN_CODEX_MINOR)) {
|
|
42
|
+
return { ok: false, message: `Codex ${ver.text} is too old (${MIN_CODEX_MAJOR}.${MIN_CODEX_MINOR}.x or later is needed). Update it.` };
|
|
43
|
+
}
|
|
44
|
+
const s = await run(bin, ["login", "status"]);
|
|
45
|
+
if (s.code !== 0) return { ok: false, message: "Codex is not signed in. Run `codex login`, then run this again." };
|
|
46
|
+
return { ok: true, version: ver.text };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The run token is NOT in these args: Codex reads it from the child env var named by bearer_token_env_var.
|
|
50
|
+
// Values go through JSON.stringify, which is a valid TOML basic string. The prompt is sent on stdin ("-").
|
|
51
|
+
export function buildCodexArgs(pkg: Pick<Claimed, "instructions" | "mcp_url">, cwd: string): string[] {
|
|
52
|
+
const args = [
|
|
53
|
+
"exec",
|
|
54
|
+
"--json",
|
|
55
|
+
"-s", "read-only",
|
|
56
|
+
"--ephemeral",
|
|
57
|
+
"--skip-git-repo-check",
|
|
58
|
+
"--ignore-user-config",
|
|
59
|
+
"--ignore-rules",
|
|
60
|
+
"-C", cwd,
|
|
61
|
+
];
|
|
62
|
+
for (const f of DISABLED_FEATURES) args.push("--disable", f);
|
|
63
|
+
const cfg: [string, string][] = [
|
|
64
|
+
["web_search", JSON.stringify("disabled")],
|
|
65
|
+
["approval_policy", JSON.stringify("never")],
|
|
66
|
+
["developer_instructions", JSON.stringify(pkg.instructions)],
|
|
67
|
+
["mcp_servers.crew.url", JSON.stringify(pkg.mcp_url)],
|
|
68
|
+
["mcp_servers.crew.bearer_token_env_var", JSON.stringify(TOKEN_ENV)],
|
|
69
|
+
["mcp_servers.crew.default_tools_approval_mode", JSON.stringify("approve")],
|
|
70
|
+
["mcp_servers.crew.required", "true"],
|
|
71
|
+
];
|
|
72
|
+
for (const [k, v] of cfg) args.push("-c", `${k}=${v}`);
|
|
73
|
+
args.push("-");
|
|
74
|
+
return args;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Item = {
|
|
78
|
+
type?: string;
|
|
79
|
+
text?: string;
|
|
80
|
+
server?: string;
|
|
81
|
+
tool?: string;
|
|
82
|
+
arguments?: unknown;
|
|
83
|
+
result?: { content?: unknown; structured_content?: unknown } | null;
|
|
84
|
+
error?: { message?: string } | null;
|
|
85
|
+
};
|
|
86
|
+
type Line = {
|
|
87
|
+
type?: string;
|
|
88
|
+
item?: Item;
|
|
89
|
+
usage?: { input_tokens?: number; output_tokens?: number };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// One `codex exec --json` line -> zero or more events for the server.
|
|
93
|
+
export function codexEventsFromLine(line: Line): RunEvent[] {
|
|
94
|
+
const item = line.item;
|
|
95
|
+
if (!item || item.type !== "mcp_tool_call") return [];
|
|
96
|
+
if (line.type === "item.started") {
|
|
97
|
+
return [{ type: "step", kind: "tool", name: `mcp__${item.server}__${item.tool}`, input: item.arguments }];
|
|
98
|
+
}
|
|
99
|
+
if (line.type === "item.completed") {
|
|
100
|
+
const output = item.error ? item.error.message : (item.result?.structured_content ?? item.result?.content);
|
|
101
|
+
return [{ type: "step", kind: "tool", output }];
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function runCodex(
|
|
107
|
+
pkg: Claimed,
|
|
108
|
+
sink: (events: RunEvent[]) => Promise<void>,
|
|
109
|
+
opts: { bin?: string; timeoutMs: number },
|
|
110
|
+
): Promise<Outcome> {
|
|
111
|
+
// Empty temp directory, as in claude.ts: there is nothing of the member's to touch.
|
|
112
|
+
const cwd = await mkdtemp(join(tmpdir(), "crew-run-"));
|
|
113
|
+
const env = { ...process.env, [TOKEN_ENV]: pkg.token };
|
|
114
|
+
const child = spawnBin(opts.bin ?? "codex", buildCodexArgs(pkg, cwd), { cwd, env, stdio: ["pipe", "pipe", "ignore"] });
|
|
115
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
|
|
116
|
+
child.stdin!.on("error", () => undefined);
|
|
117
|
+
child.stdin!.end(promptFrom(pkg.messages));
|
|
118
|
+
|
|
119
|
+
let text = "";
|
|
120
|
+
let usage: Line["usage"];
|
|
121
|
+
let completed = false;
|
|
122
|
+
let failed = false;
|
|
123
|
+
const pending: Promise<void>[] = [];
|
|
124
|
+
const rl = createInterface({ input: child.stdout! });
|
|
125
|
+
rl.on("line", (raw) => {
|
|
126
|
+
let line: Line;
|
|
127
|
+
try {
|
|
128
|
+
line = JSON.parse(raw) as Line;
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (line.type === "turn.completed") {
|
|
133
|
+
completed = true;
|
|
134
|
+
usage = line.usage;
|
|
135
|
+
}
|
|
136
|
+
if (line.type === "turn.failed") failed = true;
|
|
137
|
+
if (line.type === "item.completed" && line.item?.type === "agent_message") text = line.item.text ?? "";
|
|
138
|
+
const events = codexEventsFromLine(line);
|
|
139
|
+
if (events.length) pending.push(sink(events).catch(() => undefined));
|
|
140
|
+
});
|
|
141
|
+
const code = await new Promise<number>((resolve) => {
|
|
142
|
+
child.on("error", () => resolve(127));
|
|
143
|
+
child.on("close", (c) => resolve(c ?? 1));
|
|
144
|
+
});
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
await Promise.all(pending);
|
|
147
|
+
await rm(cwd, { recursive: true, force: true });
|
|
148
|
+
|
|
149
|
+
if (!completed || failed || code !== 0) {
|
|
150
|
+
return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
status: "completed",
|
|
154
|
+
text: text.slice(0, MAX_TEXT),
|
|
155
|
+
inputTokens: usage?.input_tokens ?? 0,
|
|
156
|
+
outputTokens: usage?.output_tokens ?? 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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, judge, prepareWorkspaceFolder, 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
|
+
export async function ownerSaysYes(api: Pick<RunnerApi, "askOwner" | "ownerAnswer">, runId: string, v: Extract<Verdict, { kind: "ask" }>, signal?: AbortSignal, pollMs = POLL_MS): Promise<boolean> {
|
|
23
|
+
const id = await api.askOwner(runId, v.category, v.detail).catch(() => null);
|
|
24
|
+
if (!id) return false;
|
|
25
|
+
while (!signal?.aborted) {
|
|
26
|
+
const answer = await api.ownerAnswer(runId, id).catch(() => null);
|
|
27
|
+
if (answer === "approved") return true;
|
|
28
|
+
if (answer !== "pending") return false;
|
|
29
|
+
await sleep(pollMs, signal);
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Claude Code's working folder for one run: new, empty and read-only, so Claude Code never finds anything there to read
|
|
35
|
+
// or run without asking the runner, and no approved step can leave a file or link there. Removed after the run.
|
|
36
|
+
export async function makeScratch(): Promise<string> {
|
|
37
|
+
const dir = await mkdtemp(join(tmpdir(), "crew-mac-"));
|
|
38
|
+
await chmod(dir, 0o500);
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ComputerRunOptions = {
|
|
43
|
+
timeoutMs: number; bin?: string; signal?: AbortSignal; home?: string; aliveMs?: number;
|
|
44
|
+
launch?: { command: string; args: string[]; env?: Record<string, string> };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Aborts `stop` once the server says the run is no longer running (the owner switched @mac off, or it was cancelled).
|
|
48
|
+
// A network error or an older server (no answer) is not a stop. Returns the function that ends the watch.
|
|
49
|
+
export function watchRun(api: Pick<RunnerApi, "alive">, runId: string, stop: AbortController, everyMs = ALIVE_MS): () => void {
|
|
50
|
+
let busy = false;
|
|
51
|
+
const timer = setInterval(() => {
|
|
52
|
+
if (busy || stop.signal.aborted) return;
|
|
53
|
+
busy = true;
|
|
54
|
+
api.alive(runId).catch(() => null).then((running) => {
|
|
55
|
+
busy = false;
|
|
56
|
+
if (running === false) stop.abort();
|
|
57
|
+
});
|
|
58
|
+
}, everyMs);
|
|
59
|
+
return () => clearInterval(timer);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// One @mac run: the workspace folder, Claude Code's own tools, every decision written to the run's action log.
|
|
63
|
+
export async function runComputer(api: Ask, pkg: Claimed, sink: (events: RunEvent[]) => Promise<void>, opts: ComputerRunOptions): Promise<Outcome> {
|
|
64
|
+
const slug = pkg.computer?.workspace_slug;
|
|
65
|
+
if (!slug) return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
|
|
66
|
+
let f;
|
|
67
|
+
let scratch;
|
|
68
|
+
try {
|
|
69
|
+
f = computerFolders(slug, opts.home);
|
|
70
|
+
await prepareWorkspaceFolder(f);
|
|
71
|
+
scratch = await makeScratch();
|
|
72
|
+
} catch {
|
|
73
|
+
return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
|
|
74
|
+
}
|
|
75
|
+
// One signal for Ctrl+C and for the owner's off switch.
|
|
76
|
+
const stop = new AbortController();
|
|
77
|
+
const onOuter = () => stop.abort();
|
|
78
|
+
if (opts.signal?.aborted) stop.abort();
|
|
79
|
+
else opts.signal?.addEventListener("abort", onOuter, { once: true });
|
|
80
|
+
const unwatch = watchRun(api, pkg.run_id, stop, opts.aliveMs);
|
|
81
|
+
// Every decision goes into the run's action log. A step is allowed only once its record is saved; a refusal is
|
|
82
|
+
// recorded as well as possible (refusing never needs the record to land).
|
|
83
|
+
const record = async (output: Record<string, unknown>): Promise<boolean> => {
|
|
84
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
85
|
+
try {
|
|
86
|
+
await sink([{ type: "step", kind: "tool", name: "permission", output }]);
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
if (stop.signal.aborted) return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
};
|
|
94
|
+
const decide = async (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>): Promise<boolean> => {
|
|
95
|
+
const v = judge(f, p, calls);
|
|
96
|
+
const why = v.kind === "allow" ? {} : v.kind === "deny" ? { reason: v.reason } : { asked: v.category, detail: v.detail };
|
|
97
|
+
const allowed = (v.kind === "allow" || (v.kind === "ask" && (await ownerSaysYes(api, pkg.run_id, v, stop.signal))))
|
|
98
|
+
&& (await record({ decision: "allowed", ...why }));
|
|
99
|
+
if (!allowed) await record({ decision: "refused", ...why });
|
|
100
|
+
return allowed;
|
|
101
|
+
};
|
|
102
|
+
const systemPrompt = `${computerPrompt(f, slug)}\n\nWorkspace guidance (from CrewCortex):\n${pkg.instructions}`;
|
|
103
|
+
try {
|
|
104
|
+
return await runAcp("claude", pkg, sink, {
|
|
105
|
+
timeoutMs: opts.timeoutMs,
|
|
106
|
+
bin: opts.bin,
|
|
107
|
+
signal: stop.signal,
|
|
108
|
+
launch: opts.launch,
|
|
109
|
+
// No additional directories: Claude Code then asks the runner about everything outside the scratch folder (the
|
|
110
|
+
// workspace folder, Downloads and dev projects included), instead of approving reads and read-only commands by itself.
|
|
111
|
+
computer: { cwd: scratch, additionalDirectories: [], systemPrompt, decide },
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
unwatch();
|
|
115
|
+
await chmod(scratch, 0o700).then(() => rm(scratch, { recursive: true, force: true })).catch(() => undefined);
|
|
116
|
+
opts.signal?.removeEventListener("abort", onOuter);
|
|
117
|
+
}
|
|
118
|
+
}
|