crew-runner 0.2.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.
package/README.md CHANGED
@@ -77,7 +77,17 @@ Press Ctrl+C (or close the window) to stop the runner. Notes for Windows:
77
77
  auto-allows `git`, because a repository's own settings can run code. Leaving plan mode, steps too
78
78
  long to show in full on a card, and Claude Code's own settings and history are refused. Opening a web page or search, deleting, `git push`, sending (mail, `curl`, `ssh`, AppleScript and
79
79
  similar), paying, changing files outside those folders, and any command the runner cannot read with certainty (pipes,
80
- `$(...)`, redirects, quotes, unknown programs) wait for your yes on a card in CrewCortex. No answer within the run is a no.
80
+ `$(...)`, redirects, unknown programs) wait for your yes on a card in CrewCortex. No answer within the run is a no.
81
+ - Reading a public web page runs without a card when it is one plain `curl` that only downloads: a GET of one `http(s)`
82
+ address on a public name (no login, port, local or numeric address, no redirects), printed or saved with `-o` to a new
83
+ file inside `~/CrewCortex/<workspace>`, with no data, headers, uploads, cookies or config file (`~/.curlrc` makes every curl ask).
84
+ This holds only until the run has read files on this computer (other than pages it downloaded): after that a download
85
+ asks, because its address could carry what was read. The address still leaves your computer, as with any web page,
86
+ and a public name can point at a private address. `wget`, `-L`, pipes and other programs ask.
87
+ - On a card for a web request that sends only what its own command line shows, **Allow for this task** also lets later
88
+ requests of the same kind in the same run go ahead without a card: web searches, page reads from that one host, or curl
89
+ requests with that one method to that one host (with or without data or headers, and redirects, as on the card). It ends with the run, never covers uploads of local files, deletes,
90
+ pushes or anything else, and each step it covers is in the action log with the card that allowed it.
81
91
  - It never changes Claude Code settings (`.claude` folders, `~/.claude.json`), and refuses to start in a workspace folder that has them.
82
92
  - Claude Code's own files under `~/.claude` (settings, other sessions' history, task lists) and `~/.claude.json` are denied
83
93
  to it by permission rules that Claude Code checks before anything it would allow by itself, and every tool that reads,
package/STUDIO.md CHANGED
@@ -71,11 +71,11 @@ V2 source-audio admission is deliberately narrower than legacy: no audio, or **o
71
71
 
72
72
  Admission regressions prove refusal before the snapshot or video units for a real 120s/100s-gap source, the same-duration/sample-count gap-plus-overlap bypass, and ALAC (with a retained legacy ALAC render control). Real 8kHz mono and 96kHz/8-channel controls pass. Parser tests cover profile/channel/rate/clock/frame/count boundaries and cumulative gaps; owned-child tests cover empty scans, oversized lines/bytes, cancellation, consent expiry and lock cleanup.
73
73
 
74
- Bounds: source <=90 minutes, <=2 GiB, 1-120fps, <=3840x2160; each sequential render unit <=30 seconds, <=384 units; aggregate intermediate files <=512 MiB, final export <=512 MiB. Disk admission requires snapshot size + both 512 MiB budgets +256 MiB reserve. Snapshot uses a 1 MiB streaming buffer and hashes copied bytes; originals are not loaded wholesale into memory. Consent and the entire job share a <=15-minute deadline, source/final decode or final encoding <=10 minutes, individual segments <=90 seconds. Decoder/encoder/filter threads stay at 2. These are conservative admission limits, not a promise that every admitted 4K recording finishes within the deadline. Source metadata/decode must succeed before source-frame EOF display padding (at most one source-frame interval) is applied. FFmpeg is not an OS memory sandbox.
74
+ Bounds: source <=3 hours, <=6 GiB, 1-120fps, <=3840x2160; each sequential render unit <=30 seconds, <=576 units; aggregate intermediate files <=512 MiB, final export <=512 MiB. Disk admission requires snapshot size + both 512 MiB budgets +256 MiB reserve. Snapshot uses a 1 MiB streaming buffer and hashes copied bytes; originals are not loaded wholesale into memory. Consent and the entire job share a <=15-minute deadline, source/final decode or final encoding <=10 minutes, individual segments <=90 seconds. Decoder/encoder/filter threads stay at 2. These are conservative admission limits, not a promise that every admitted 4K recording finishes within the deadline. Source metadata/decode must succeed before source-frame EOF display padding (at most one source-frame interval) is applied. FFmpeg is not an OS memory sandbox.
75
75
 
76
76
  The existing project lock, cancellation and fail-closed crash recovery remain. Per-segment `progress.json` is a bounded checkpoint, not resumable authorization: process death can leave lock/private partial files; inspect owned processes before human cleanup, never automatically rerun a lost acknowledgement. Successful UUID versions have source/output SHA-256 plus plan/mapping, and earlier versions survive failed edits. Export files remain local. No automatic transfer, scanned upload, hosted version registration or publication exists here.
77
77
 
78
- Intro prepending: an optional `"intro": "brand/intro.mp4"` names ONE existing project MP4 that the member chose (for example a workspace's saved usual intro or a file they supplied with the request). The engine never picks, generates or scores an intro; choosing which file, and showing that choice for approval, belongs to the caller. The intro is snapshotted and hashed like the source, then admitted before the source with the same strict v2 decode and audio-clock rules (one AAC-LC stream or none, positive gaps <=2s), at most 120 seconds (checked from metadata before any full decode) and 100 MiB; its copy is disk-checked before it is written, and an edited programme plus intro over 90 minutes is refused before the source is opened. Its picture is letterboxed/pillarboxed into the export frame (never stretched or cropped; the source crop does not apply to it) and resampled to the 30fps grid; only its whole 30fps frames are used, in <=30s video-only units. Its audio is placed by PTS, converted to 48kHz stereo with FFmpeg's standard rematrix (mono goes to both channels at -3 dB, as for sources without an intro; multichannel is downmixed), padded with silence or trimmed to exactly 1600 samples per intro frame and joined once before the unchanged source chain; an intro with no audio contributes silence, never music. Source mapping keeps original-source times; `manifest.json.intro` records path, sha256, frames and output span, and every `mapping` outputStart is shifted by the intro's whole frames. Regression: a 1080p 25fps mono 44.1kHz intro and a 31s silent portrait intro both give exact frame counts, the intro picture to the last intro frame, source frame 5.000s as the first programme frame and 0-sample source lag after the seam. Legacy `supportingAsset` still appends and is not an intro. Ripple-audio deletion remains a separate slice. Actual source, hold duration/frame, crop and intro must be reviewed before editing private client footage.
78
+ Intro prepending: an optional `"intro": "brand/intro.mp4"` names ONE existing project MP4 that the member chose (for example a workspace's saved usual intro or a file they supplied with the request). The engine never picks, generates or scores an intro; choosing which file, and showing that choice for approval, belongs to the caller. The intro is snapshotted and hashed like the source, then admitted before the source with the same strict v2 decode and audio-clock rules (one AAC-LC stream or none, positive gaps <=2s), at most 120 seconds (checked from metadata before any full decode) and 100 MiB; its copy is disk-checked before it is written, and an edited programme plus intro over 3 hours is refused before the source is opened. Its picture is letterboxed/pillarboxed into the export frame (never stretched or cropped; the source crop does not apply to it) and resampled to the 30fps grid; only its whole 30fps frames are used, in <=30s video-only units. Its audio is placed by PTS, converted to 48kHz stereo with FFmpeg's standard rematrix (mono goes to both channels at -3 dB, as for sources without an intro; multichannel is downmixed), padded with silence or trimmed to exactly 1600 samples per intro frame and joined once before the unchanged source chain; an intro with no audio contributes silence, never music. Source mapping keeps original-source times; `manifest.json.intro` records path, sha256, frames and output span, and every `mapping` outputStart is shifted by the intro's whole frames. Regression: a 1080p 25fps mono 44.1kHz intro and a 31s silent portrait intro both give exact frame counts, the intro picture to the last intro frame, source frame 5.000s as the first programme frame and 0-sample source lag after the seam. Legacy `supportingAsset` still appends and is not an intro. Ripple-audio deletion remains a separate slice. Actual source, hold duration/frame, crop and intro must be reviewed before editing private client footage.
79
79
 
80
80
  Remote reproduction (owned Linux checkout only):
81
81
 
@@ -89,7 +89,7 @@ PATH=/owned/tools:$PATH node packages/runner/test/studio-source-pilot.mjs /owned
89
89
 
90
90
  The `crew-render` image also runs this engine for hosted webinar edits, one job per disposable Fly machine (`infra/render/studio.mjs`, chosen when the machine gets `RENDER_STUDIO_URL`). The image owns the FFmpeg 7.1 static digest pin, which `scripts/ci-ffmpeg.sh` reads from `infra/render/Dockerfile`; it is installed at `/opt/ffmpeg-7.1`, first on PATH for Studio mode only; the motion-video modes keep the distribution FFmpeg. The engine source is copied unchanged and run by Node's type stripping.
91
91
 
92
- The machine downloads the approved source and optional intro from short-lived URLs, refusing more bytes than the worker declared and checking Drive's md5 when one was given. It fills `sourceDuration` from ffprobe on the 30fps grid, so the approved edit lists (source seconds) are the only plan input. It calls `openStudio(grant, limits)` with hosted limits instead of `LOCAL_LIMITS`: a job deadline of at most `HOSTED_MAX_JOB_MS` (two hours, downloads included), longer per-unit and decode/final windows, more encoder threads, a 1080p export and larger intermediate budget, never above the 512 MiB export cap. The local CLI and MCP bridge always use `LOCAL_LIMITS` (15-minute job, 90s units, two threads, 720p), so their behaviour and pilot outputs are unchanged. It uploads the export and a JPEG still, then writes a status JSON (bytes, SHA-256, duration, size, render time, peak memory) or a failure reason.
92
+ The machine downloads the approved source and optional intro from short-lived URLs, refusing more bytes than the worker declared and checking Drive's md5 when one was given. It fills `sourceDuration` from ffprobe on the 30fps grid, so the approved edit lists (source seconds) are the only plan input. It calls `openStudio(grant, limits)` with hosted limits instead of `LOCAL_LIMITS`: a job deadline of at most `HOSTED_MAX_JOB_MS` (four hours, downloads included), longer per-unit and decode/final windows, more encoder threads, a 1080p export and intermediate budgets of up to `HOSTED_EXPORT_BYTES` (4 GiB) and `HOSTED_INTERMEDIATE_BYTES` (16 GiB). When the export budget could bind for the programme's length, the final encode gets a peak video rate (`-maxrate`, 2x `-bufsize`) sized so the whole export fits (90% of the budget less 200 kbit/s for audio and container); short and local exports keep their exact argv. A dropped or stalled download resumes with an HTTP Range request (up to five attempts, 90 s stall timeout) and the md5 covers the whole stream. Measured on performance-4x (4 dedicated CPUs, 8 GB): a 75-minute 1080p30 webinar-like source rendered in 37 minutes (about half real time), with a 1.42 GB export. The local CLI and MCP bridge always use `LOCAL_LIMITS` (15-minute job, 90s units, two threads, 720p), so their behaviour and pilot outputs are unchanged. It uploads the export and a JPEG still, then writes a status JSON (bytes, SHA-256, duration, size, render time, peak memory) or a failure reason.
93
93
 
94
94
  Real-media check, inside the built image: `node --test /app/studio.test.mjs` with `/opt/ffmpeg-7.1` first on PATH (synthetic 1080p 25fps source, 44.1kHz intro, a cut and a mute; md5 and size refusals; edit past the end refused).
95
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crew-runner",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Runs a CrewCortex assistant on your own computer using the Claude Code or Codex app you already signed in to.",
6
6
  "keywords": [
package/src/acp.ts CHANGED
@@ -241,6 +241,7 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
241
241
  const cwd = computer ? computer.cwd : await mkdtemp(join(tmpdir(), "crew-run-"));
242
242
  let child: ReturnType<typeof spawn> | undefined;
243
243
  let timer: NodeJS.Timeout | undefined;
244
+ let ended = false;
244
245
  let flushTimer: NodeJS.Timeout | undefined;
245
246
  let graceTimer: NodeJS.Timeout | undefined;
246
247
  let onAbort: (() => void) | undefined;
@@ -262,10 +263,34 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
262
263
  proc.on("close", () => reject(new Error("runner_failed")));
263
264
  });
264
265
  exited.catch(() => undefined);
266
+ // The run clock. It stops while a computer-mode request waits on `decide` (the owner's card, which expires on its own timer),
267
+ // so time the owner takes to answer never counts toward the run limit. Claude Code may ask about parallel tool calls at once,
268
+ // hence a count of pending decisions rather than a flag. Computer mode has no MCP server, so no run token can lapse meanwhile.
269
+ let left = opts.timeoutMs;
270
+ let since = 0;
271
+ let waiting = 0;
272
+ let expire: () => void = () => undefined;
265
273
  const timedOut = new Promise<never>((_, reject) => {
266
- timer = setTimeout(() => reject(new Error("runner_timeout")), opts.timeoutMs);
274
+ expire = () => reject(new Error("runner_timeout"));
267
275
  });
268
276
  timedOut.catch(() => undefined);
277
+ const resume = () => {
278
+ if (ended) return; // a decision that outlived the run
279
+ since = Date.now();
280
+ timer = setTimeout(expire, left);
281
+ };
282
+ const paused = async <T>(work: () => Promise<T>): Promise<T> => {
283
+ if (waiting++ === 0) {
284
+ clearTimeout(timer);
285
+ left -= Date.now() - since;
286
+ }
287
+ try {
288
+ return await work();
289
+ } finally {
290
+ if (--waiting === 0) resume();
291
+ }
292
+ };
293
+ resume();
269
294
 
270
295
  // Serialised sink, text coalesced so a token stream is not one HTTP call per chunk.
271
296
  let chain: Promise<void> = Promise.resolve();
@@ -320,7 +345,7 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
320
345
  const session = acp
321
346
  .client({ name: "crew-runner" })
322
347
  .onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
323
- const d = computer ? optionFor(ctx.params, await computer.decide(ctx.params, calls).catch(() => false)) : decide(spec, ctx.params, calls);
348
+ const d = computer ? optionFor(ctx.params, await paused(() => computer.decide(ctx.params, calls)).catch(() => false)) : decide(spec, ctx.params, calls);
324
349
  return { outcome: d.optionId ? { outcome: "selected" as const, optionId: d.optionId } : { outcome: "cancelled" as const } };
325
350
  })
326
351
  .connectWith(stream, async (ctx) => {
@@ -381,6 +406,7 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
381
406
  } catch {
382
407
  return failed("runner_failed");
383
408
  } finally {
409
+ ended = true;
384
410
  clearTimeout(timer);
385
411
  clearTimeout(flushTimer);
386
412
  clearTimeout(graceTimer);
package/src/api.ts CHANGED
@@ -12,7 +12,8 @@ export type Claimed = {
12
12
  };
13
13
 
14
14
  export type ClaimMode = "default" | "computer";
15
- export type CardAnswer = "pending" | "approved" | "rejected" | "expired";
15
+ // "approved_task": approved with "Allow for this task" (the server sends status approved, scope task).
16
+ export type CardAnswer = "pending" | "approved" | "approved_task" | "rejected" | "expired";
16
17
 
17
18
  export type RunEvent =
18
19
  | { type: "text"; text: string }
@@ -57,10 +58,11 @@ export function createApi(cfg: Stored, fetchImpl: typeof fetch = fetch) {
57
58
  await call("complete", body);
58
59
  },
59
60
  // Computer mode: a confirmation card for the owner. Null when it could not be asked (the step is then refused).
60
- async askOwner(runId: string, category: string, detail: string): Promise<string | null> {
61
+ // `grant`: the card may offer "Allow for this task" for later steps of the same kind in this run.
62
+ async askOwner(runId: string, category: string, detail: string, grant?: string): Promise<string | null> {
61
63
  // The policy refuses anything longer than a card shows; never cut what the owner approves.
62
64
  if (detail.length > CARD_DETAIL_MAX) return null;
63
- const res = await call("approval", { run_id: runId, category, detail });
65
+ const res = await call("approval", { run_id: runId, category, detail, ...(grant ? { grant } : {}) });
64
66
  if (!res.ok) return null;
65
67
  const body = (await res.json()) as { approval_id?: unknown };
66
68
  return typeof body.approval_id === "string" ? body.approval_id : null;
@@ -68,7 +70,8 @@ export function createApi(cfg: Stored, fetchImpl: typeof fetch = fetch) {
68
70
  async ownerAnswer(runId: string, approvalId: string): Promise<CardAnswer | null> {
69
71
  const res = await call("approval/status", { run_id: runId, approval_id: approvalId });
70
72
  if (!res.ok) return null;
71
- const body = (await res.json()) as { status?: unknown };
73
+ const body = (await res.json()) as { status?: unknown; scope?: unknown };
74
+ if (body.status === "approved" && body.scope === "task") return "approved_task";
72
75
  return body.status === "pending" || body.status === "approved" || body.status === "rejected" || body.status === "expired" ? body.status : null;
73
76
  },
74
77
  // Computer mode: is this run still running? false only when the server says so (the owner's off switch); null when
@@ -5,7 +5,7 @@ import type { Outcome } from "./claude.ts";
5
5
  import { chmod, mkdtemp, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
- import { computerFolders, computerPrompt, judge, prepareWorkspaceFolder, type Verdict } from "./computer.ts";
8
+ import { computerFolders, computerPrompt, judgeInRun, newRunMemory, noteAllowed, prepareWorkspaceFolder, type Folders, type Grant, type Verdict } from "./computer.ts";
9
9
 
10
10
  const POLL_MS = 3_000;
11
11
  // How often a computer run checks it is still running. The owner's off switch cancels it on the server.
@@ -19,18 +19,58 @@ const sleep = (ms: number, signal?: AbortSignal) =>
19
19
  type Ask = Pick<RunnerApi, "askOwner" | "ownerAnswer" | "alive">;
20
20
 
21
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;
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
25
  while (!signal?.aborted) {
26
- const answer = await api.ownerAnswer(runId, id).catch(() => null);
27
- if (answer === "approved") return true;
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";
28
29
  if (answer !== "pending") return false;
29
30
  await sleep(pollMs, signal);
30
31
  }
31
32
  return false;
32
33
  }
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
+
34
74
  // Claude Code's working folder for one run: new, empty and read-only, so Claude Code never finds anything there to read
35
75
  // or run without asking the runner, and no approved step can leave a file or link there. Removed after the run.
36
76
  export async function makeScratch(): Promise<string> {
@@ -91,14 +131,7 @@ export async function runComputer(api: Ask, pkg: Claimed, sink: (events: RunEven
91
131
  }
92
132
  return false;
93
133
  };
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
- };
134
+ const decide = stepDecider(api, pkg.run_id, f, record, stop.signal);
102
135
  const systemPrompt = `${computerPrompt(f, slug)}\n\nWorkspace guidance (from CrewCortex):\n${pkg.instructions}`;
103
136
  try {
104
137
  return await runAcp("claude", pkg, sink, {
@@ -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
+ }
package/src/computer.ts CHANGED
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, posix, relative, sep } from "node:path";
5
5
  import type * as acp from "@agentclientprotocol/sdk";
6
6
  import type { SeenCall } from "./acp.ts";
7
+ import { curlConfigFiles, curlRequest, publicHost } from "./computer-web.ts";
7
8
 
8
9
  // Computer mode (crew-runner start --computer): the owner's @mac agent works on this computer with Claude Code's own
9
10
  // tools, inside allowlisted folders. Every tool request Claude Code asks about comes here. Allowed work runs; a guarded
@@ -11,18 +12,27 @@ import type { SeenCall } from "./acp.ts";
11
12
  // policy cannot read with certainty asks the owner. It never picks an "always" option.
12
13
 
13
14
  // `caseless`: the file system ignores letter case (macOS and Windows by default), so ~/crewcortex is ~/CrewCortex.
14
- export type Folders = { home: string; crewRoot: string; workspaceRoot: string; allowed: string[]; caseless: boolean };
15
+ // `curlConfig`: files curl reads by itself; while one exists no web request runs or is covered without a card.
16
+ export type Folders = { home: string; crewRoot: string; workspaceRoot: string; allowed: string[]; caseless: boolean; curlConfig: string[] };
15
17
  export type Category = "delete" | "git_push" | "send" | "payment" | "outside_folder" | "other";
16
- export type Verdict = { kind: "allow" } | { kind: "deny"; reason: string } | { kind: "ask"; category: Category; detail: string };
18
+ // `grant`: the owner may answer this card with "Allow for this task", which then covers later steps with exactly the same
19
+ // grant in the same run. Only web requests that send nothing but what their own command line shows get one, keyed by
20
+ // kind and host (and method for curl): "search", "fetch:<host>", "curl:<METHOD>:<host>". A search never covers a curl.
21
+ export type Grant = string;
22
+ type Ask = { kind: "ask"; category: Category; detail: string; grant?: Grant };
23
+ type NotAllowed = { kind: "deny"; reason: string } | Ask;
24
+ // `download`: a public download that runs without a card only until the run has read anything on this computer; `card`
25
+ // is what it asks after that, and `file` the real path it saves to.
26
+ export type Verdict = { kind: "allow"; download?: { file: string | null; card: NotAllowed } } | NotAllowed;
17
27
 
18
28
  const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/;
19
29
 
20
30
  // The three folders Haydo approved: his dev projects, Downloads, and this workspace's own folder under ~/CrewCortex.
21
- export function computerFolders(slug: string, home = homedir(), caseless = process.platform !== "linux"): Folders {
31
+ export function computerFolders(slug: string, home = homedir(), caseless = process.platform !== "linux", env: Record<string, string | undefined> = process.env): Folders {
22
32
  if (!SLUG.test(slug)) throw new Error("bad workspace slug");
23
33
  const crewRoot = join(home, "CrewCortex");
24
34
  const workspaceRoot = join(crewRoot, slug);
25
- return { home, crewRoot, workspaceRoot, allowed: [join(home, ".cursor", "dev projects"), join(home, "Downloads"), workspaceRoot], caseless };
35
+ return { home, crewRoot, workspaceRoot, allowed: [join(home, ".cursor", "dev projects"), join(home, "Downloads"), workspaceRoot], caseless, curlConfig: curlConfigFiles(home, env) };
26
36
  }
27
37
 
28
38
  // Creates this workspace's folder (owner-only mode) and refuses one that is a link to somewhere else, or one holding
@@ -91,6 +101,18 @@ export function place(f: Folders, raw: string): Place {
91
101
  }) ? "allowed" : "outside";
92
102
  }
93
103
 
104
+ // A file a download may be saved to: an absolute or unquoted ~/ path inside this workspace's own folder (not Downloads or
105
+ // dev projects, and never a repository's .git or Claude Code's files).
106
+ function savesInWorkspace(f: Folders, raw: string, quoted: boolean): boolean {
107
+ if (!fixedPath(raw) || (quoted && raw.startsWith("~")) || place(f, raw) !== "allowed") return false;
108
+ const p = resolved(f, raw);
109
+ const own = real(f.workspaceRoot);
110
+ return p !== null && own !== null && inside(f, p, own) && p !== own;
111
+ }
112
+
113
+ // The real location of an absolute or ~/ path, or null.
114
+ const resolved = (f: Folders, raw: string): string | null => (fixedPath(raw) ? real(raw === "~" ? f.home : raw.startsWith("~/") ? `${f.home}${sep}${raw.slice(2)}` : raw) : null);
115
+
94
116
  const field = (v: unknown, key: string): unknown => (v && typeof v === "object" ? (v as Record<string, unknown>)[key] : undefined);
95
117
  const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
96
118
 
@@ -161,19 +183,32 @@ function categoryOf(command: string): Category {
161
183
  // A command's relative paths and its no-path form read the shell's current folder, which Claude Code can change by
162
184
  // itself (it runs some cd commands without asking), so they are never auto-allowed. Only absolute or ~/ paths that
163
185
  // resolve into the allowed folders run without asking.
164
- export function judgeCommand(f: Folders, command: string): Verdict {
165
- const v = commandVerdict(f, command);
186
+ // `plainInput`: the step carries nothing but its command, a description and a timeout (not a background run).
187
+ export function judgeCommand(f: Folders, command: string, plainInput = true): Verdict {
188
+ const v = commandVerdict(f, command, plainInput);
166
189
  return fits(v.kind === "ask" && mentionsOtherWorkspace(f, command) ? { kind: "deny", reason: OTHER_WORKSPACE } : v);
167
190
  }
168
191
 
169
192
  const fixedPath = (p: string) => isAbsolute(p) || p === "~" || p.startsWith("~/");
170
193
 
171
- function commandVerdict(f: Folders, command: string): Verdict {
172
- const ask = (): Verdict => ({ kind: "ask", category: categoryOf(command), detail: `${command} (the shell starts in an empty scratch folder)` });
194
+ function commandVerdict(f: Folders, command: string, plainInput: boolean): Verdict {
195
+ const ask = (): Ask => ({ kind: "ask", category: categoryOf(command), detail: `${command} (the shell starts in an empty scratch folder)` });
173
196
  const tokens = command.trim().split(/ +/).filter(Boolean);
174
197
  // Another workspace's folder and Claude Code's own files are never touched, whatever the command or argument.
175
198
  const refused = refusal(tokens.slice(1).filter((t) => !t.startsWith("-")).map((t) => place(f, t)));
176
199
  if (refused) return refused;
200
+ // A web request curl reads no config file for: a public download runs, one that sends its own text can be allowed for the
201
+ // task, for its method and host.
202
+ const web = f.curlConfig.some((file) => existsSync(file)) ? null : curlRequest(command, (p, quoted) => savesInWorkspace(f, p, quoted));
203
+ if (web) {
204
+ const card: Ask = { kind: "ask", category: "send", detail: ask().detail, grant: `curl:${web.method}${web.redirects ? "+L" : ""}${web.extras ? "+D" : ""}:${web.host}` };
205
+ // A download saves only to a new file: curl leaves an existing file as it was when the request fails, and the run
206
+ // would then read the owner's file as if it were a downloaded page.
207
+ const file = web.output ? resolved(f, web.output) : null;
208
+ const fresh = file === null ? web.output === null : !lstatSync(file, { throwIfNoEntry: false });
209
+ if (web.kind === "download" && plainInput && fresh) return { kind: "allow", download: { file, card: fits(card) as NotAllowed } };
210
+ return card;
211
+ }
177
212
  if (tokens.length === 0 || SHELL_SPECIAL.test(command) || /[^\x20-\x7e]/.test(command)) return ask();
178
213
  const [prog, ...args] = tokens as [string, ...string[]];
179
214
  if (NO_FILES.has(prog)) return { kind: "allow" };
@@ -201,6 +236,38 @@ export function judge(f: Folders, p: acp.RequestPermissionRequest, calls: Readon
201
236
  return fits(withInput(judgeRequest(f, p, calls), p, calls, f));
202
237
  }
203
238
 
239
+ // What one run has done so far that changes what may run without a card. Kept in that run's memory only.
240
+ export type RunMemory = { readLocal: boolean; downloaded: Set<string> };
241
+ export const newRunMemory = (): RunMemory => ({ readLocal: false, downloaded: new Set() });
242
+
243
+ // Once a run has read anything on this computer (other than pages it downloaded itself), a download's address could carry
244
+ // what it read out, so from then on a download gets a card like any other web request.
245
+ export function judgeInRun(f: Folders, p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>, mem: RunMemory): Verdict {
246
+ const v = judge(f, p, calls);
247
+ return v.kind === "allow" && v.download && mem.readLocal ? v.download.card : v;
248
+ }
249
+
250
+ // Records a step that was allowed (with or without a card). Planning, web requests, a page this run downloaded, writing a
251
+ // new whole file and reading a page this run downloaded read nothing of the computer; anything else counts as a local read.
252
+ export function noteAllowed(f: Folders, p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>, v: Verdict, mem: RunMemory): void {
253
+ if (v.kind === "allow" && v.download) {
254
+ if (v.download.file) mem.downloaded.add(v.download.file);
255
+ return;
256
+ }
257
+ // A web request's answer comes from the web, except one that followed redirects (it may have reached a local address).
258
+ if (v.kind === "ask" && v.grant && !v.grant.includes("+L")) return;
259
+ const raw = p.toolCall.rawInput ?? calls.get(p.toolCall.toolCallId)?.rawInput;
260
+ const kind = p.toolCall.kind;
261
+ if (kind === "think") return;
262
+ if (kind === "edit" && typeof field(raw, "content") === "string" && field(raw, "old_string") === undefined) return;
263
+ if (kind === "read") {
264
+ const paths = toolPaths(raw);
265
+ const at = paths.length === 1 ? resolved(f, paths[0]!) : null;
266
+ if (at !== null && mem.downloaded.has(at)) return;
267
+ }
268
+ mem.readLocal = true;
269
+ }
270
+
204
271
  // A card shows the whole request, not a summary: every input field the tool was given (a command's own text is already
205
272
  // the detail). With fits(), a request too large to show in full is refused instead of approved unseen.
206
273
  function withInput(v: Verdict, p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>, f: Folders): Verdict {
@@ -235,6 +302,8 @@ function mentionsOtherWorkspace(f: Folders, text: string): boolean {
235
302
  return false;
236
303
  }
237
304
 
305
+ const PLAIN_EXEC_KEYS = new Set(["command", "description", "timeout"]);
306
+
238
307
  // The only inputs a planning step may carry and still run without a card.
239
308
  const SUBAGENT_KEYS = new Set(["description", "prompt", "subagent_type", "model", "run_in_background"]);
240
309
  function thinkOnly(raw: unknown): boolean {
@@ -258,12 +327,19 @@ function judgeRequest(f: Folders, p: acp.RequestPermissionRequest, calls: Readon
258
327
  case "switch_mode":
259
328
  // Leaving plan mode could apply a mode Claude Code suggests (auto-accept or bypass). Computer mode never switches.
260
329
  return { kind: "deny", reason: "Computer mode does not change how steps are approved." };
261
- case "fetch":
262
- // A web address or search can carry data out, so the owner sees it first.
263
- return { kind: "ask", category: "send", detail: `Open on the web: ${str(field(raw, "url")) ?? str(field(raw, "query")) ?? title}` };
330
+ case "fetch": {
331
+ // A web address or search can carry data out, so the owner sees it first. A public address or a search can be
332
+ // allowed for the task; a local address never is.
333
+ const url = str(field(raw, "url"));
334
+ const query = str(field(raw, "query"));
335
+ const host = url ? publicHost(url) : null;
336
+ const grant = host ? { grant: `fetch:${host}` } : !url && query !== null ? { grant: "search" } : {};
337
+ return { kind: "ask", category: "send", detail: `Open on the web: ${url ?? query ?? title}`, ...grant };
338
+ }
264
339
  case "execute": {
265
340
  const command = str(field(raw, "command"));
266
- return command ? judgeCommand(f, command) : { kind: "ask", category: "other", detail: title };
341
+ const plainInput = typeof raw === "object" && raw !== null && Object.keys(raw as object).every((k) => PLAIN_EXEC_KEYS.has(k));
342
+ return command ? judgeCommand(f, command, plainInput) : { kind: "ask", category: "other", detail: title };
267
343
  }
268
344
  case "read":
269
345
  case "search": {
@@ -319,6 +395,9 @@ export function computerPrompt(f: Folders, slug: string): string {
319
395
  "Never open, read or copy another workspace's folder under ~/CrewCortex, and never put another workspace's information in your reply.",
320
396
  "Before deleting anything, git push, sending email or messages outside CrewCortex, buying or paying, or changing files outside the",
321
397
  "allowed folders, the runner asks your owner on CrewCortex and waits. If the answer is no, stop that step and say so.",
398
+ `To read a public web page, use one plain command with no pipes: curl -s "<https address>" or curl -s "<https address>" -o ${f.workspaceRoot}/<file>.`,
399
+ "That runs without asking until you have read files on this computer; download what you need first. Following redirects (-L),",
400
+ "sending data, logins, uploads or saving elsewhere asks first.",
322
401
  "Your final reply is posted in the channel you were tagged in. Keep it short, and never include passwords, keys or tokens.",
323
402
  ].join("\n");
324
403
  }
@@ -1,11 +1,14 @@
1
+ import { SOURCE_SECONDS } from "./studio-source.ts";
1
2
  // V2 only. Decode clocks, not NET duration/sample counts: overlaps must never
2
3
  // cancel a positive gap. AAC-LC has bounded decoded frames; other codecs need
3
4
  // their own reviewed frame contract before using timestamp-fill resampling.
4
5
  const RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200, 96000];
5
6
  export const AUDIO_CLOCK_HZ = 28_224_000; // LCM of admitted rates; exact sample durations.
6
7
  const MAX_SILENCE_SECONDS = 2;
7
- const MAX_FRAMES = 600_000; // >90min at 96kHz/1024, including priming/edit-list edges.
8
- export const AUDIO_SCAN_BYTES = 192 * 1024 ** 2;
8
+ // Three hours at 96kHz/1024 is 1,012,500 frames; the rest is priming/edit-list edges.
9
+ const MAX_FRAMES = 1_100_000;
10
+ // Streamed per line, never held: a byte counter on the decoder log (about 300 bytes a frame), not memory.
11
+ export const AUDIO_SCAN_BYTES = 384 * 1024 ** 2;
9
12
  type AudioStream = { codec_name?: string; profile?: string; sample_rate?: string; channels?: number };
10
13
 
11
14
  export function sourceAudioAdmission(streams: AudioStream[]) {
@@ -29,7 +32,7 @@ export function sourceAudioAdmission(streams: AudioStream[]) {
29
32
  if (records.length !== markers.length) throw new Error("Unsupported V2 source audio: invalid decoded clock");
30
33
  for (const m of records) {
31
34
  const n = Number(m[1]), pts = Number(m[2]), actualChannels = Number(m[4]), actualRate = Number(m[5]), samples = Number(m[6]);
32
- if (n !== count || !Number.isSafeInteger(pts) || Math.abs(pts) > 5401 * AUDIO_CLOCK_HZ || m[3] !== "fltp" || actualChannels !== channels || actualRate !== rate || !Number.isInteger(samples) || samples < 1 || samples > 1024 || ++count > MAX_FRAMES) {
35
+ if (n !== count || !Number.isSafeInteger(pts) || Math.abs(pts) > (SOURCE_SECONDS + 1) * AUDIO_CLOCK_HZ || m[3] !== "fltp" || actualChannels !== channels || actualRate !== rate || !Number.isInteger(samples) || samples < 1 || samples > 1024 || ++count > MAX_FRAMES) {
33
36
  throw new Error(`Unsupported V2 source audio: decoded frame/rate/channel/count limit (frame ${n}/${count}, PTS ${pts}, ${m[3]}, ${actualRate}Hz/${actualChannels}ch, ${samples} samples)`);
34
37
  }
35
38
  // Adjacent decoded frames, not max(end) or NET coverage. A backwards
@@ -10,11 +10,21 @@ export type SourcePlan = {
10
10
  };
11
11
  export type SourceSegment = SourceSpan & { outputStart: number; mute: boolean; frame?: number };
12
12
  export const SOURCE_FPS = 30;
13
- export const SOURCE_SECONDS = 5400;
14
- export const SOURCE_BYTES = 2 * 1024 ** 3;
13
+ // A recording of up to three hours and 6 GiB (a raw 1080p webinar is about 1 to 3 GB an hour). Only a hosted job's own
14
+ // deadline can afford that much work; the local limits stop long before.
15
+ export const SOURCE_SECONDS = 10_800;
16
+ export const SOURCE_BYTES = 6 * 1024 ** 3;
15
17
  // Every other media file the engine reads (an intro, a supporting asset).
16
18
  export const MEDIA_BYTES = 100 * 1024 * 1024;
17
19
  export const EXPORT_BYTES = 512 * 1024 ** 2;
20
+ // A hosted export of a long programme: under one object PUT (5 GiB), and the encoder's peak rate is sized to fit it.
21
+ export const HOSTED_EXPORT_BYTES = 4 * 1024 ** 3;
22
+ // A hosted job's clock and scratch budget (studio.ts checks hosted limits against them; the worker passes them).
23
+ export const HOSTED_MAX_JOB_MS = 4 * 3_600_000;
24
+ export const HOSTED_INTERMEDIATE_BYTES = 16 * 1024 ** 3;
25
+ // Every 30-second unit of a three-hour programme (360), one more for each of up to 193 kept intervals between edit
26
+ // boundaries (32 cuts, mutes and holds each), and an intro's units.
27
+ export const SEGMENT_BUDGET = 576;
18
28
  function fields(v: unknown, keys: string[], required = keys): Record<string, unknown> {
19
29
  if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("Invalid source plan object");
20
30
  const o = v as Record<string, unknown>;
@@ -22,7 +32,7 @@ function fields(v: unknown, keys: string[], required = keys): Record<string, unk
22
32
  return o;
23
33
  }
24
34
  function time(v: unknown): number {
25
- if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > SOURCE_SECONDS || Math.abs(v * SOURCE_FPS - Math.round(v * SOURCE_FPS)) > 0.000001) throw new Error("Source times must lie on the 30fps output grid within 90 minutes");
35
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > SOURCE_SECONDS || Math.abs(v * SOURCE_FPS - Math.round(v * SOURCE_FPS)) > 0.000001) throw new Error("Source times must lie on the 30fps output grid within 3 hours");
26
36
  return v;
27
37
  }
28
38
  export function validateSourcePlan(value: unknown, mediaName: (v: unknown) => string): SourcePlan {
@@ -72,6 +82,6 @@ export function compileSourcePlan(p: SourcePlan, introFrames = 0): SourceSegment
72
82
  outputFrames += Math.round((to-from)*30);
73
83
  }
74
84
  }
75
- if (result.length > 384) throw new Error("Source plan segment budget exceeded");
85
+ if (result.length > SEGMENT_BUDGET) throw new Error("Source plan segment budget exceeded");
76
86
  return result;
77
87
  }
package/src/studio.ts CHANGED
@@ -5,18 +5,21 @@ import { isAbsolute, join, resolve, relative } from "node:path";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { killTree } from "./spawn.ts";
7
7
  import { sourceAudioAdmission, AUDIO_CLOCK_HZ, AUDIO_SCAN_BYTES } from "./studio-audio-admission.ts";
8
- import { validateSourcePlan, compileSourcePlan, SOURCE_SECONDS, SOURCE_BYTES, EXPORT_BYTES, MEDIA_BYTES, type SourcePlan } from "./studio-source.ts";
8
+ import { validateSourcePlan, compileSourcePlan, SOURCE_SECONDS, SOURCE_BYTES, EXPORT_BYTES, HOSTED_EXPORT_BYTES, HOSTED_INTERMEDIATE_BYTES, HOSTED_MAX_JOB_MS, MEDIA_BYTES, SEGMENT_BUDGET, type SourcePlan } from "./studio-source.ts";
9
9
 
10
10
  export type StudioGrant = Readonly<{ root: string; enabled: boolean; expiresAt: number }>;
11
11
  // The local CLI and MCP bridge always use LOCAL_LIMITS. A hosted edit runs alone on a disposable machine that is
12
- // destroyed after the job, so it may pass larger limits, never beyond HOSTED_MAX_JOB_MS or the 512 MiB export cap.
12
+ // destroyed after the job, so it may pass larger limits, never beyond HOSTED_MAX_JOB_MS, HOSTED_EXPORT_BYTES or
13
+ // HOSTED_INTERMEDIATE_BYTES.
13
14
  export type StudioLimits = Readonly<{ jobMs: number; unitMs: number; threads: number; height: 720 | 1080; intermediateBytes: number; exportBytes: number }>;
14
15
  export const LOCAL_LIMITS: StudioLimits = Object.freeze({ jobMs: 900_000, unitMs: 90_000, threads: 2, height: 720, intermediateBytes: EXPORT_BYTES, exportBytes: EXPORT_BYTES });
15
- export const HOSTED_MAX_JOB_MS = 2 * 3_600_000;
16
+ // Sized from a measured hosted render (75 minutes of 1080p30 took 37 minutes, about half real time, on 4 dedicated CPUs): a
17
+ // three-hour recording plus its download fits with room for slower footage.
18
+ export { HOSTED_MAX_JOB_MS, HOSTED_INTERMEDIATE_BYTES };
16
19
  const int = (v: unknown, min: number, max: number) => typeof v === "number" && Number.isSafeInteger(v) && v >= min && v <= max;
17
20
  function checkLimits(l: StudioLimits): StudioLimits {
18
21
  if (!int(l.jobMs, 60_000, HOSTED_MAX_JOB_MS) || !int(l.unitMs, 10_000, 600_000) || !int(l.threads, 1, 8) || (l.height !== 720 && l.height !== 1080)
19
- || !int(l.intermediateBytes, 1, 4 * 1024 ** 3) || !int(l.exportBytes, 1, EXPORT_BYTES)) throw new Error("Invalid studio limits");
22
+ || !int(l.intermediateBytes, 1, HOSTED_INTERMEDIATE_BYTES) || !int(l.exportBytes, 1, HOSTED_EXPORT_BYTES)) throw new Error("Invalid studio limits");
20
23
  return Object.freeze({ jobMs: l.jobMs, unitMs: l.unitMs, threads: l.threads, height: l.height, intermediateBytes: l.intermediateBytes, exportBytes: l.exportBytes });
21
24
  }
22
25
  type Span = { start: number; end: number };
@@ -164,7 +167,7 @@ export async function openStudio(input: StudioGrant, limitsInput: StudioLimits =
164
167
  return safe(BASE, true);
165
168
  };
166
169
  const probeFile = async (path: string, signal?: AbortSignal, expected?: number, long = false, grant = g, expectedAudio = true): Promise<Media> => {
167
- const maxBytes = long ? (expected === undefined ? SOURCE_BYTES : EXPORT_BYTES) : MAX_BYTES;
170
+ const maxBytes = long ? (expected === undefined ? SOURCE_BYTES : Math.max(EXPORT_BYTES, limits.exportBytes)) : MAX_BYTES;
168
171
  const maxSeconds = long ? SOURCE_SECONDS : MAX_SECONDS;
169
172
  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
170
173
  try {
@@ -259,7 +262,7 @@ export async function openStudio(input: StudioGrant, limitsInput: StudioLimits =
259
262
  const media = await probeFile(join(dir, "intro.mp4"), options.signal, undefined, true, jobGrant);
260
263
  const frames = Math.floor(media.videoDuration * 30 + 1e-6);
261
264
  if (!frames) throw new Error("Intro has no whole output frame");
262
- if (mapping!.reduce((n, s) => n + s.end - s.start, 0) + frames / 30 > SOURCE_SECONDS) throw new Error("Edited programme plus intro exceeds 90 minutes");
265
+ if (mapping!.reduce((n, s) => n + s.end - s.start, 0) + frames / 30 > SOURCE_SECONDS) throw new Error("Edited programme plus intro exceeds 3 hours");
263
266
  intro = { path: sourcePlan.intro, sha256: sha(bytes), frames, media };
264
267
  }
265
268
  // Snapshot in bounded chunks, hash exactly the bytes the encoder will read.
@@ -309,7 +312,7 @@ export async function openStudio(input: StudioGrant, limitsInput: StudioLimits =
309
312
  }
310
313
  const introUnits = intro ? Array.from({ length: Math.ceil(intro.frames / 900) }, (_, i) => ({ start: i * 30, end: Math.min(intro!.frames, (i + 1) * 900) / 30, mute: false, frame: undefined, file: "intro.mp4", media: intro!.media, intro: true })) : [];
311
314
  const segments = [...introUnits, ...p.cuts.map((cut, i) => ({ ...cut, mute: mapping?.[i]?.mute ?? false, frame: mapping?.[i]?.frame, file: "source.mp4", media: sourceMedia, intro: false }))];
312
- if (segments.length > 384) throw new Error("Source plan segment budget exceeded");
315
+ if (segments.length > SEGMENT_BUDGET) throw new Error("Source plan segment budget exceeded");
313
316
  if (p.supportingAsset) segments.push({ start: p.supportingAsset.start, end: p.supportingAsset.end, mute: false, frame: undefined, file: "asset.mp4", media: await probeFile(join(dir, "asset.mp4"), options.signal), intro: false });
314
317
  const height = options.preview ? 360 : limits.height, width = height * 16 / 9;
315
318
  let intermediateBytes = 0;
@@ -375,7 +378,13 @@ export async function openStudio(input: StudioGrant, limitsInput: StudioLimits =
375
378
  audioInput.push(...(intro.media.hasAudio ? ["-threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-i", "intro.mp4"] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"]));
376
379
  graph = ["-filter_complex_threads", t, "-filter_complex", `[0:v]${filters.join(",")}[v];[2:a]aresample=48000:async=1:min_hard_comp=0.001:first_pts=0,${stereo},apad=whole_len=${samples},atrim=end_sample=${samples},asetpts=N/SR/TB[i];[1:a]${audioFilter},${stereo}[s];[i][s]concat=n=2:v=0:a=1[a]`, "-map", "[v]", "-map", "[a]"];
377
380
  }
378
- await command("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-n", "-threads", t, "-filter_threads", t, "-protocol_whitelist", "file", "-f", "concat", "-safe", "1", "-reinit_filter", "0", "-i", "concat.txt", ...audioInput, ...(sourcePlan ? [] : ["-t", String(timeline)]), ...graph, "-r", "30", "-fps_mode", "cfr", "-c:v", "libx264", "-threads", t, "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k", "-movflags", "+faststart", "-fs", String(exportBudget), "export.mp4"], dir, jobGrant, options.signal, sourcePlan ? longMs : TIMEOUT);
381
+ // A long v2 programme gets a peak video rate that keeps the whole export inside its budget (128k audio and container
382
+ // overhead set aside), so a dense hour cannot run into -fs and be refused at the end. Where the budget cannot bind at
383
+ // any rate CRF 20 reaches (short and local exports) the argv is unchanged.
384
+ const peak = sourcePlan ? Math.floor(exportBudget * 8 * 0.9 / timeline) - 200_000 : Infinity;
385
+ if (peak < 600_000) throw new Error("The export budget is too small for this programme length");
386
+ const peakRate = peak < 12_000_000 ? ["-maxrate", String(peak), "-bufsize", String(2 * peak)] : [];
387
+ await command("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-n", "-threads", t, "-filter_threads", t, "-protocol_whitelist", "file", "-f", "concat", "-safe", "1", "-reinit_filter", "0", "-i", "concat.txt", ...audioInput, ...(sourcePlan ? [] : ["-t", String(timeline)]), ...graph, "-r", "30", "-fps_mode", "cfr", "-c:v", "libx264", "-threads", t, "-preset", "veryfast", "-crf", "20", ...peakRate, "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k", "-movflags", "+faststart", "-fs", String(exportBudget), "export.mp4"], dir, jobGrant, options.signal, sourcePlan ? longMs : TIMEOUT);
379
388
  const output = await probeFile(join(dir, "export.mp4"), options.signal, timeline, !!sourcePlan, jobGrant);
380
389
  const outputFile = await open(join(dir, "export.mp4"), constants.O_RDONLY | constants.O_NOFOLLOW);
381
390
  const outputHash = createHash("sha256");