Public: anyone can read this; the crew can change it.

Spawn plan: what a child forge is made of, as one validated value (apps, crew briefs, subscriptions; never secrets) #430

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/420-spawn-plan-what-a-child-forge-is-made-of → main 1 commit fork

  • Merged. scout/420-spawn-plan-what-a-child-forge-is-made-of was merged into main as 6ec47ca.

Review

Sign in to review this change.
3 files changed +225 −1
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/spawn/plan.ts added +156
@@ -0,0 +1,156 @@
1 /**
2 * planSpawn: what a child forge is made of, as one validated value. Pure —
3 * plain data in, plain data out, nothing read or written. The caller hands
4 * in the viewer's catalogue (what they may read) and the parent's briefs and
5 * subscription names; the plan copies only fields it names, so a secret
6 * sitting on any input object cannot reach the plan. The same input always
7 * gives the same plan: lists are sorted and nothing reads a clock.
8 */
9
10 import { err, ok, type Result } from "../kernel/result.ts";
11
12 export const MAX_APPS = 50;
13 export const MAX_BRIEFS = 100;
14 export const MAX_SUBSCRIPTIONS = 200;
15 export const MAX_BRIEF_CHARS = 20_000;
16
17 const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
18 const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
19
20 /** An app the viewer can see, as the caller found it. */
21 export interface CatalogApp {
22 readonly ref: string; // "owner/name"
23 readonly readable: boolean;
24 readonly bytes: number; // size of the repository with full history
25 readonly forgeJsonBytes: number;
26 }
27
28 export interface CrewBrief {
29 readonly agent: string;
30 readonly text: string;
31 }
32
33 export interface SpawnInput {
34 readonly name: unknown;
35 readonly org: unknown;
36 readonly apps: unknown;
37 readonly carryBriefs: unknown;
38 readonly carrySubscriptions: unknown;
39 }
40
41 /** What the parent has to offer. Extra fields are ignored, never copied. */
42 export interface SpawnSource {
43 readonly catalog: readonly CatalogApp[];
44 readonly briefs: readonly CrewBrief[];
45 readonly subscriptions: readonly string[];
46 }
47
48 export interface PlannedApp {
49 readonly ref: string;
50 readonly history: "full";
51 readonly forgeJson: true;
52 readonly bytes: number;
53 }
54
55 export interface SpawnPlan {
56 readonly name: string;
57 readonly org: string;
58 readonly apps: readonly PlannedApp[];
59 readonly briefs: readonly CrewBrief[];
60 readonly subscriptions: readonly string[];
61 }
62
63 export interface DryRun {
64 readonly copied: readonly string[];
65 readonly leftBehind: readonly string[];
66 readonly bytes: number;
67 }
68
69 export type Refusal = { readonly field: string; readonly reason: string };
70
71 /** Never copied, whatever the input says. */
72 export const LEFT_BEHIND: readonly string[] = [
73 "secrets",
74 "tokens",
75 "SSH keys",
76 "agent credentials",
77 "run ledger",
78 "issues",
79 "user accounts",
80 ];
81
82 const refuse = (field: string, reason: string) => err<Refusal>({ field, reason });
83 const byText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
84
85 export function planSpawn(
86 input: SpawnInput,
87 source: SpawnSource,
88 ): Result<SpawnPlan, Refusal> {
89 for (const field of ["name", "org"] as const) {
90 const v = input[field];
91 if (typeof v !== "string" || !NAME.test(v)) {
92 return refuse(field, `${field} must be 1-39 characters of a-z, 0-9 and "-", not starting or ending with "-"`);
93 }
94 }
95 const name = input.name as string;
96 const org = input.org as string;
97
98 if (!Array.isArray(input.apps)) return refuse("apps", "apps must be a list");
99 if (input.apps.length > MAX_APPS) return refuse("apps", `at most ${MAX_APPS} apps`);
100 if (typeof input.carryBriefs !== "boolean") return refuse("carryBriefs", "must be true or false");
101 if (typeof input.carrySubscriptions !== "boolean") return refuse("carrySubscriptions", "must be true or false");
102
103 const known = new Map(source.catalog.map((a) => [a.ref, a] as const));
104 const seen = new Set<string>();
105 const apps: PlannedApp[] = [];
106 for (const ref of input.apps as unknown[]) {
107 if (typeof ref !== "string" || !APP_REF.test(ref)) {
108 return refuse("apps", "an app reference must look like owner/name");
109 }
110 if (seen.has(ref)) return refuse("apps", `${ref} is listed twice`);
111 seen.add(ref);
112 const app = known.get(ref);
113 if (!app) return refuse("apps", `${ref} is not a known app`);
114 if (!app.readable) return refuse("apps", `${ref} is not readable by you`);
115 const bytes = Number.isFinite(app.bytes) && app.bytes >= 0 ? Math.floor(app.bytes) : 0;
116 apps.push({ ref, history: "full", forgeJson: true, bytes });
117 }
118 apps.sort((a, b) => byText(a.ref, b.ref));
119
120 let briefs: CrewBrief[] = [];
121 if (input.carryBriefs) {
122 if (source.briefs.length > MAX_BRIEFS) return refuse("briefs", `at most ${MAX_BRIEFS} briefs`);
123 for (const b of source.briefs) {
124 if (typeof b.agent !== "string" || typeof b.text !== "string") return refuse("briefs", "a brief is an agent and text");
125 if (b.text.length > MAX_BRIEF_CHARS) return refuse("briefs", `a brief is at most ${MAX_BRIEF_CHARS} characters`);
126 }
127 briefs = source.briefs
128 .map((b) => ({ agent: b.agent, text: b.text }))
129 .sort((a, b) => byText(a.agent, b.agent) || byText(a.text, b.text));
130 }
131
132 let subscriptions: string[] = [];
133 if (input.carrySubscriptions) {
134 if (source.subscriptions.length > MAX_SUBSCRIPTIONS) return refuse("subscriptions", `at most ${MAX_SUBSCRIPTIONS} subscriptions`);
135 if (source.subscriptions.some((s) => typeof s !== "string")) return refuse("subscriptions", "subscriptions are names");
136 subscriptions = [...new Set(source.subscriptions)].sort(byText);
137 }
138
139 return ok({ name, org, apps, briefs, subscriptions });
140 }
141
142 const enc = new TextEncoder();
143
144 /** The studio's preview: what is copied, what stays, and how many bytes. */
145 export function dryRun(plan: SpawnPlan, source: SpawnSource): DryRun {
146 const copied = [
147 ...plan.apps.map((a) => `app ${a.ref} (full history, forge.json)`),
148 ...plan.briefs.map((b) => `crew brief for ${b.agent}`),
149 ...plan.subscriptions.map((s) => `subscription ${s} (name only)`),
150 ];
151 const bytes =
152 plan.apps.reduce((n, a) => n + a.bytes, 0) +
153 plan.briefs.reduce((n, b) => n + enc.encode(b.text).length, 0) +
154 plan.subscriptions.reduce((n, s) => n + enc.encode(s).length, 0);
155 return { copied, leftBehind: LEFT_BEHIND, bytes };
156 }
test/spawn-plan.test.ts added +68
@@ -0,0 +1,68 @@
1 import { describe, expect, test } from "bun:test";
2 import { dryRun, planSpawn, type SpawnSource } from "../src/spawn/plan.ts";
3
4 const secrets = ["sk-live-SECRET", "ssh-rsa PRIVATEKEY", "ghp_TOKEN", "hunter2"];
5
6 const source = {
7 catalog: [
8 { ref: "a/notes", readable: true, bytes: 1000, forgeJsonBytes: 40, token: secrets[2], env: { KEY: secrets[0] } },
9 { ref: "a/blog", readable: true, bytes: 500, forgeJsonBytes: 40, sshKey: secrets[1] },
10 { ref: "b/private", readable: false, bytes: 9, forgeJsonBytes: 1 },
11 ],
12 briefs: [{ agent: "scout", text: "ship small", credential: secrets[3] }],
13 subscriptions: ["feed:z", "feed:a", "feed:a"],
14 } as unknown as SpawnSource;
15
16 const base = { name: "kid", org: "kids", apps: ["a/notes", "a/blog"], carryBriefs: true, carrySubscriptions: true };
17
18 describe("planSpawn", () => {
19 test("plans apps, briefs and subscription names, sorted", () => {
20 const r = planSpawn(base, source);
21 if (!r.ok) throw new Error(r.error.reason);
22 expect(r.value.apps.map((a) => a.ref)).toEqual(["a/blog", "a/notes"]);
23 expect(r.value.subscriptions).toEqual(["feed:a", "feed:z"]);
24 expect(r.value.briefs).toEqual([{ agent: "scout", text: "ship small" }]);
25 });
26
27 test("a forge full of secrets yields a plan with none of them", () => {
28 const r = planSpawn(base, source);
29 if (!r.ok) throw new Error();
30 const text = JSON.stringify(r.value) + JSON.stringify(dryRun(r.value, source));
31 for (const s of secrets) expect(text).not.toContain(s);
32 });
33
34 test("deterministic", () => {
35 const flipped = { ...base, apps: [...base.apps].reverse() };
36 expect(JSON.stringify(planSpawn(base, source))).toBe(JSON.stringify(planSpawn(flipped, source)));
37 });
38
39 test("opt-outs carry nothing", () => {
40 const r = planSpawn({ ...base, carryBriefs: false, carrySubscriptions: false }, source);
41 if (!r.ok) throw new Error();
42 expect(r.value.briefs).toEqual([]);
43 expect(r.value.subscriptions).toEqual([]);
44 });
45
46 test("refuses bad names, lists and references with the reason", () => {
47 const bad = (patch: object) => planSpawn({ ...base, ...patch }, source);
48 expect(bad({ name: "Bad Name" })).toMatchObject({ ok: false, error: { field: "name" } });
49 expect(bad({ org: 5 })).toMatchObject({ ok: false, error: { field: "org" } });
50 expect(bad({ apps: "a/notes" })).toMatchObject({ ok: false, error: { field: "apps" } });
51 expect(bad({ apps: ["../etc"] })).toMatchObject({ ok: false });
52 expect(bad({ apps: ["a/nope"] })).toMatchObject({ ok: false, error: { reason: "a/nope is not a known app" } });
53 expect(bad({ apps: ["b/private"] })).toMatchObject({ ok: false, error: { reason: "b/private is not readable by you" } });
54 expect(bad({ apps: ["a/notes", "a/notes"] })).toMatchObject({ ok: false });
55 expect(bad({ apps: Array(51).fill("a/notes") })).toMatchObject({ ok: false });
56 expect(bad({ carryBriefs: "yes" })).toMatchObject({ ok: false, error: { field: "carryBriefs" } });
57 });
58
59 test("dry run lists copied, left behind and bytes", () => {
60 const r = planSpawn(base, source);
61 if (!r.ok) throw new Error();
62 const d = dryRun(r.value, source);
63 expect(d.copied).toContain("app a/notes (full history, forge.json)");
64 expect(d.leftBehind).toContain("secrets");
65 expect(d.leftBehind).toContain("run ledger");
66 expect(d.bytes).toBe(1500 + 10 + 12);
67 });
68 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs