@fungi.computer/bonsai 0.0.0-stage → 0.0.1

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +835 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/read-copy-calls.test.ts +691 -0
  90. package/test/read-copy-composer.test.ts +388 -0
  91. package/test/read-copy-lifetimes.test.ts +214 -0
  92. package/test/read-copy-rpc.config.jsonc +12 -0
  93. package/test/read-copy-rpc.native.mjs +47 -0
  94. package/test/read-copy-rpc.worker.ts +92 -0
  95. package/test/recent-activity.test.ts +62 -0
  96. package/test/repository-deletion.test.ts +500 -0
  97. package/test/seed-resume.test.mjs +91 -0
  98. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  99. package/test/storyboard-save-template.test.mjs +545 -0
  100. package/test/ui-errors.test.ts +976 -0
  101. package/test/workflow-store.test.ts +115 -0
  102. package/tsconfig.json +36 -0
  103. package/turbo.jsonc +25 -0
  104. package/vite.config.ts +19 -0
  105. package/vitest.config.ts +5 -0
  106. package/web/api.ts +224 -0
  107. package/web/app-build-panel.tsx +171 -0
  108. package/web/board.tsx +259 -0
  109. package/web/bonsai.css +642 -0
  110. package/web/change-page.tsx +715 -0
  111. package/web/change-rows.tsx +135 -0
  112. package/web/code-tab.tsx +267 -0
  113. package/web/commit-tree.tsx +152 -0
  114. package/web/format.ts +54 -0
  115. package/web/home-page.tsx +274 -0
  116. package/web/index.html +17 -0
  117. package/web/insight-data.ts +76 -0
  118. package/web/insight-tiles.tsx +534 -0
  119. package/web/insights.tsx +250 -0
  120. package/web/issues-panel.tsx +305 -0
  121. package/web/main.tsx +72 -0
  122. package/web/network-graph.tsx +647 -0
  123. package/web/overview.tsx +339 -0
  124. package/web/pages.tsx +469 -0
  125. package/web/parts.tsx +381 -0
  126. package/web/public/favicon.svg +1 -0
  127. package/web/repo-navigation.tsx +256 -0
  128. package/web/repo-settings.tsx +145 -0
  129. package/web/router.tsx +151 -0
  130. package/web/transport.ts +91 -0
  131. package/web/views.tsx +151 -0
  132. package/web/working-now.tsx +98 -0
@@ -0,0 +1,774 @@
1
+ // Consume a host-captured, unchanged Shed Release on the isolated native fixture.
2
+ // Usage: test:shed-terminal <shed-directory> <shed-revision> <tourtois-directory> <tourtois-revision>
3
+ // Run SECOND, only after the fixture custodian's handoff, under the bounded
4
+ // proof runner and HEAVY_CLASS=prepush with four-core affinity.
5
+ // CELLD_PLATFORM_CELLD + wrangler-on-celld.mjs selects celld; otherwise workerd.
6
+ import assert from "node:assert/strict";
7
+ import { Buffer } from "node:buffer";
8
+ import { execFileSync, spawn } from "node:child_process";
9
+ import { randomBytes } from "node:crypto";
10
+ import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
11
+ import { join, resolve } from "node:path";
12
+ import process from "node:process";
13
+ import { URL, fileURLToPath } from "node:url";
14
+ import { parseEnv } from "node:util";
15
+ import { setTimeout as delay } from "node:timers/promises";
16
+ import * as Schema from "effect/Schema";
17
+ import * as Redacted from "effect/Redacted";
18
+ import { chromium, expect } from "playwright/test";
19
+ import * as z from "zod";
20
+ import { installRelease } from "@fungi.computer/oyster/pack";
21
+ import { parseSourceRevision } from "@fungi.computer/puffball";
22
+ import { releaseSchema } from "@fungi.computer/puffball/effect";
23
+ import { workbenchDirectorySchema } from "@fungi.computer/oyster/shell/workbench-directory";
24
+
25
+ const [packedDirectory, expectedRevision, tourtoisDirectory, tourtoisRevision] =
26
+ z
27
+ .tuple([
28
+ z.string().min(1),
29
+ z.string().min(1),
30
+ z.string().min(1),
31
+ z.string().min(1),
32
+ ])
33
+ .parse(process.argv.slice(2));
34
+ const directory = resolve(packedDirectory);
35
+ const release = Schema.decodeUnknownSync(Schema.fromJsonString(releaseSchema))(
36
+ await readFile(join(directory, "release.json"), "utf8"),
37
+ );
38
+ assert.equal(release.appId, "app_shed");
39
+ assert.equal(
40
+ release.artifact.provenance.sourceRevision,
41
+ parseSourceRevision(expectedRevision),
42
+ "Install the exact immutable Shed capture released by its owner",
43
+ );
44
+
45
+ const tourtois = Schema.decodeUnknownSync(Schema.fromJsonString(releaseSchema))(
46
+ await readFile(join(resolve(tourtoisDirectory), "release.json"), "utf8"),
47
+ );
48
+ assert.equal(tourtois.appId, "app_tourtois");
49
+ assert.equal(
50
+ tourtois.artifact.provenance.sourceRevision,
51
+ parseSourceRevision(tourtoisRevision),
52
+ "Install the exact immutable Tourtois capture released by its owner",
53
+ );
54
+
55
+ const fixturePath = process.env.OYSTER_PROOF_ENV_FILE;
56
+ assert.ok(
57
+ fixturePath,
58
+ "OYSTER_PROOF_ENV_FILE must name the released private fixture",
59
+ );
60
+ assert.equal((await stat(fixturePath)).mode & 0o777, 0o600);
61
+ const fixture = z
62
+ .object({
63
+ COMPUTER_PROOF_PORT: z.coerce.number().int().min(1).max(65535),
64
+ COMPUTER_VM_ORIGIN: z.url(),
65
+ COMPUTER_VM_DEFAULT_HOST: z.string().min(1),
66
+ COMPUTER_VM_HOSTS: z.string().min(1),
67
+ COMPUTER_VM_SIGNING_KEY: z.string().min(1),
68
+ })
69
+ .safeParse(parseEnv(await readFile(fixturePath, "utf8")));
70
+ assert.ok(fixture.success, "Fixture configuration is invalid");
71
+ const { COMPUTER_PROOF_PORT: port, ...computerVars } = fixture.data;
72
+ const root = fileURLToPath(new URL("../", import.meta.url));
73
+ const repository = fileURLToPath(new URL("../../../", import.meta.url));
74
+ const runtime = process.env.CELLD_PLATFORM_CELLD ? "celld" : "workerd";
75
+ await mkdir(`${root}.botanical`, { recursive: true });
76
+ const work = await mkdtemp(`${root}.botanical/shed-terminal-${runtime}-`);
77
+ const revision = execFileSync("git", ["rev-parse", "HEAD"], {
78
+ cwd: repository,
79
+ encoding: "utf8",
80
+ }).trim();
81
+ const key = randomBytes(24).toString("hex");
82
+ const origin = `http://127.0.0.1:${port}`;
83
+ // The standalone worker's own tenancy admits only an HTTPS origin with no
84
+ // path/credentials (packages/oyster/src/standalone/tenancy.ts's composeTenancy,
85
+ // packages/tenancy/src/registry-record.ts's checkedOrigin) — a bare
86
+ // http://127.0.0.1 origin throws "Tenant origin must be HTTPS...".
87
+ // --local-upstream/--upstream-protocol make wrangler's local dev server
88
+ // declare that exact origin to the Worker while still listening on plain
89
+ // loopback HTTP, the same sentinel convention every other native/browser
90
+ // proof of this standalone worker already uses (edit-in-inkcap-live,
91
+ // tourtois-home, landed-push, workflows, agents-sandbox).
92
+ //
93
+ // Opus-PT's reviewed fix for the Ports tray additionally calls for
94
+ // OYSTER_PREVIEW_ORIGIN (packages/oyster/src/standalone/preview.ts's
95
+ // previewOrigins; packages/oyster/src/standalone/computer.ts:625-626 returns
96
+ // port_previews_not_configured without it) and dropping --local-upstream
97
+ // for --local-protocol https, since --local-upstream rewrites every
98
+ // request's Host to the same single value and the preview popup can never
99
+ // reach a second, distinct tunnel origin through it. Tried exactly that
100
+ // (OYSTER_PREVIEW_ORIGIN=https://127.0.0.1:<port> alongside
101
+ // OYSTER_ORIGIN=https://localhost:<port>, --local-protocol https,
102
+ // NODE_TLS_REJECT_UNAUTHORIZED=0 for this script's own Node-side fetches,
103
+ // ignoreHTTPSErrors on the browser context) and it broke something more
104
+ // fundamental: the scratch vmd fixture's own --worker callback is a fixed
105
+ // plain-HTTP URL set at vmd's own launch (http://127.0.0.1:<this port>,
106
+ // from s5-vm-fixture.sh), and workerd's now-HTTPS-only local listener
107
+ // refuses that plaintext callback outright — confirmed directly in
108
+ // workerd's own log: "error accepting tls connection ... OpenSSL error;
109
+ // message = error:1000009c:SSL routines:OPENSSL_internal:HTTP_REQUEST".
110
+ // Every Computer-backed call then retried twice at 30s and failed (the
111
+ // same two-attempts-at-30s shape Sonnet-SG found for Workspace push).
112
+ // Fixing that needs vmd restarted with a matching https callback — not
113
+ // something to do unilaterally to a VM shared with Sonnet-SG's S9 work
114
+ // mid-session ("keep the VM up until S9 is green"). Reverted to the
115
+ // known-good --local-upstream transport for now; OYSTER_PREVIEW_ORIGIN
116
+ // stays set (harmless, satisfies half the fix) but the Ports tray itself
117
+ // is not expected to pass until this is resolved — see the READY/STATUS
118
+ // report for the exact finding and the open question.
119
+ const tenantHost = "oyster.test";
120
+ const previewHost = "oyster-preview.test";
121
+ const vars = {
122
+ ...computerVars,
123
+ OYSTER_OWNER_KEY: key,
124
+ OYSTER_ORIGIN: `https://${tenantHost}`,
125
+ OYSTER_PREVIEW_ORIGIN: `https://${previewHost}`,
126
+ OYSTER_HOME_INSTALLATION: "tourtois",
127
+ };
128
+ const worker = spawn(
129
+ `${repository}node_modules/.bin/wrangler`,
130
+ [
131
+ "dev",
132
+ "--config",
133
+ `${root}wrangler.celld.jsonc`,
134
+ "--local",
135
+ "--ip",
136
+ "127.0.0.1",
137
+ "--port",
138
+ String(port),
139
+ "--persist-to",
140
+ `${work}/state`,
141
+ "--local-upstream",
142
+ tenantHost,
143
+ "--upstream-protocol",
144
+ "https",
145
+ ...Object.entries(vars).flatMap(([name, value]) => [
146
+ "--var",
147
+ `${name}:${value}`,
148
+ ]),
149
+ ],
150
+ // Wrangler may report --var values; captured, not inherited, and only
151
+ // written out redacted (below) so secrets never reach this process's own
152
+ // stdout.
153
+ { cwd: root, stdio: ["ignore", "pipe", "pipe"], env: process.env },
154
+ );
155
+ let workerOutput = "";
156
+ worker.stdout.on("data", (chunk) => {
157
+ workerOutput += chunk;
158
+ });
159
+ worker.stderr.on("data", (chunk) => {
160
+ workerOutput += chunk;
161
+ });
162
+ let startupFailed = false;
163
+ const stopped = new Promise((resolve) => {
164
+ worker.once("exit", resolve);
165
+ worker.once("error", () => {
166
+ startupFailed = true;
167
+ resolve();
168
+ });
169
+ });
170
+ let browser;
171
+ const screenshots = [];
172
+ const viewports = [];
173
+ const redact = (text) =>
174
+ Object.values(vars).reduce(
175
+ (message, value) => message.replaceAll(value, "<redacted>"),
176
+ String(text),
177
+ );
178
+ try {
179
+ for (let attempt = 0; ; attempt += 1) {
180
+ const ready = await globalThis
181
+ .fetch(`${origin}/login`, {
182
+ signal: globalThis.AbortSignal.timeout(1000),
183
+ })
184
+ .then(
185
+ (response) => response.ok,
186
+ () => false,
187
+ );
188
+ if (ready) break;
189
+ if (attempt >= 120 || worker.exitCode !== null || startupFailed)
190
+ throw new Error(
191
+ "Standalone Oyster did not start; terminal proof did not run",
192
+ );
193
+ await delay(500);
194
+ }
195
+ const home = await installRelease({
196
+ directory: resolve(tourtoisDirectory),
197
+ url: origin,
198
+ key,
199
+ installationId: "tourtois",
200
+ });
201
+ assert.equal(home.status, 201, "The unchanged Tourtois Release installs");
202
+ const installed = await installRelease({
203
+ directory,
204
+ url: origin,
205
+ key,
206
+ installationId: "shed",
207
+ });
208
+ assert.equal(installed.status, 201, "The unchanged Shed Release installs");
209
+
210
+ const command = await globalThis.fetch(
211
+ `${origin}/apps/shed/methods/runCommand`,
212
+ {
213
+ method: "POST",
214
+ headers: {
215
+ Authorization: `Bearer ${key}`,
216
+ "Content-Type": "application/json",
217
+ },
218
+ body: JSON.stringify({
219
+ command: "printf standalone-shed-terminal",
220
+ timeoutMs: 10000,
221
+ }),
222
+ },
223
+ );
224
+ assert.equal(
225
+ command.status,
226
+ 200,
227
+ "The unchanged Shed backend runs a native command",
228
+ );
229
+ const result = await command.json();
230
+ assert.equal(result.status, "succeeded");
231
+ assert.equal(result.output.status, "completed");
232
+ assert.equal(result.output.stdout, "standalone-shed-terminal");
233
+ assert.equal(result.output.stderr, "");
234
+ assert.equal(result.output.exitCode, 0);
235
+
236
+ const directoryResponse = await globalThis.fetch(
237
+ `${origin}/api/workbench/directory`,
238
+ {
239
+ headers: { Authorization: `Bearer ${key}` },
240
+ },
241
+ );
242
+ assert.equal(directoryResponse.status, 200);
243
+ const { apps } = workbenchDirectorySchema.parse(
244
+ await directoryResponse.json(),
245
+ );
246
+ const installedShed = apps.filter((app) => app.installationId === "shed");
247
+ assert.equal(installedShed.length, 1);
248
+ const [shedApp] = installedShed;
249
+ assert.equal(shedApp.appId, release.appId);
250
+ assert.equal(shedApp.releaseId, release.releaseId);
251
+ assert.equal(shedApp.availability, "available");
252
+ assert.ok(shedApp.roles.includes("terminal"));
253
+ assert.equal(shedApp.displayName, "Shed");
254
+
255
+ browser = await chromium.launch();
256
+ for (const width of [1440, 390]) {
257
+ const page = await browser.newPage({
258
+ viewport: { width, height: width === 390 ? 844 : 900 },
259
+ reducedMotion: "reduce",
260
+ });
261
+ page.setDefaultTimeout(30_000);
262
+ const terminalCloseKey = `terminal-close-${randomBytes(8).toString("hex")}`;
263
+ // Navigation discards both Playwright and CDP socket-close notifications.
264
+ // Persist the shared owner's actual native close call across that navigation.
265
+ await page.addInitScript((key) => {
266
+ const close = globalThis.WebSocket.prototype.close;
267
+ globalThis.WebSocket.prototype.close = function (...args) {
268
+ const result = close.apply(this, args);
269
+ const url = new globalThis.URL(this.url);
270
+ if (
271
+ url.pathname === "/computer/terminal" &&
272
+ url.searchParams.has("session")
273
+ )
274
+ globalThis.sessionStorage.setItem(key, this.url);
275
+ return result;
276
+ };
277
+ }, terminalCloseKey);
278
+ const received = [];
279
+ const sent = [];
280
+ const diagnostics = {
281
+ errors: [],
282
+ sockets: [],
283
+ requests: [],
284
+ handshake: [],
285
+ };
286
+ page.on("pageerror", (error) =>
287
+ diagnostics.errors.push(redact(error.message)),
288
+ );
289
+ page.on("console", (message) => {
290
+ if (message.type() === "error")
291
+ diagnostics.errors.push(redact(message.text()));
292
+ });
293
+ page.on("requestfailed", (request) => {
294
+ diagnostics.requests.push({
295
+ path: new URL(request.url()).pathname,
296
+ error: redact(request.failure()?.errorText ?? "Request failed"),
297
+ });
298
+ });
299
+ await page.exposeFunction("recordAppHandshake", (type) => {
300
+ if (type === "fungi.app.ready" || type === "fungi.app.connect")
301
+ diagnostics.handshake.push(type);
302
+ });
303
+ await page.addInitScript(() => {
304
+ globalThis.addEventListener("message", (event) => {
305
+ if (
306
+ event.data?.type === "fungi.app.ready" ||
307
+ event.data?.type === "fungi.app.connect"
308
+ )
309
+ void globalThis.recordAppHandshake(event.data.type);
310
+ });
311
+ });
312
+ let terminalSocket;
313
+ page.on("websocket", (socket) => {
314
+ const url = new URL(socket.url());
315
+ if (!url.searchParams.has("session") || !url.searchParams.has("cols"))
316
+ return;
317
+ terminalSocket = socket;
318
+ diagnostics.sockets.push({ path: url.pathname, event: "created" });
319
+ socket.on("framereceived", ({ payload }) => received.push(payload));
320
+ socket.on("framesent", ({ payload }) => sent.push(payload));
321
+ socket.on("socketerror", (error) =>
322
+ diagnostics.errors.push(redact(error)),
323
+ );
324
+ socket.on("close", () => {
325
+ diagnostics.sockets.push({ path: url.pathname, event: "closed" });
326
+ });
327
+ });
328
+ try {
329
+ await page.goto(`${origin}/login`);
330
+ await page.getByLabel("Owner key").fill(key);
331
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
332
+ await page.goto(origin);
333
+ await expect(page).toHaveURL(`${origin}/apps/tourtois/`);
334
+ const desktop = page.locator("#oyster-shell [data-tourtois-shell]");
335
+ await expect(desktop).toBeVisible();
336
+ const before = `before-tourtois-${width}.png`;
337
+ await page.screenshot({ path: join(work, before), fullPage: true });
338
+ screenshots.push(before);
339
+ const [wasmResponse] = await Promise.all([
340
+ page.waitForResponse((response) => {
341
+ const url = new URL(response.url());
342
+ return (
343
+ url.origin === origin &&
344
+ url.pathname.startsWith("/ui/shed/") &&
345
+ url.pathname.endsWith(".wasm") &&
346
+ response.status() === 200
347
+ );
348
+ }),
349
+ desktop.getByRole("button", { name: "Shed", exact: true }).click(),
350
+ ]);
351
+ assert.match(
352
+ wasmResponse.headers()["content-type"],
353
+ /^application\/wasm/,
354
+ );
355
+ const shed = page
356
+ .locator('.tourtois-app-pane[aria-label="Shed"]')
357
+ .frameLocator(".tourtois-pane-body iframe");
358
+ await expect(shed.locator("#shed-root")).toBeVisible();
359
+ await expect(
360
+ desktop.getByText("Terminal open", { exact: true }),
361
+ ).toBeVisible({ timeout: 30_000 });
362
+ assert.ok(
363
+ terminalSocket,
364
+ "Shed opened the existing Computer PTY transport",
365
+ );
366
+ const ready = received
367
+ .filter((frame) => !Buffer.isBuffer(frame))
368
+ .map((frame) => JSON.parse(frame))
369
+ .find((frame) => frame.type === "ready");
370
+ assert.ok(
371
+ ready?.information?.computerName,
372
+ "The PTY supplies terminal metadata",
373
+ );
374
+ assert.equal(
375
+ ready.information.computerName,
376
+ "workspace",
377
+ "The VM Computer fixture's sole Computer is named workspace",
378
+ );
379
+ const nonce = randomBytes(8).toString("hex");
380
+ // Shed's light prompt is the default surface; `full` escalates one
381
+ // typed line to the real PTY. Once escalated, ShellPanel unmounts
382
+ // LightShellPanel — only a PTY exit (never triggered in this proof)
383
+ // remounts it — so every light-mode check below runs BEFORE the
384
+ // first `full`, and every later command is typed straight into the
385
+ // now-visible terminal instead.
386
+ const prompt = shed.getByRole("textbox", {
387
+ name: "Shell command",
388
+ exact: true,
389
+ });
390
+ const receivedBytes = () =>
391
+ Buffer.concat(received.filter((frame) => Buffer.isBuffer(frame)));
392
+ const sentBytesLength = () =>
393
+ sent.filter((frame) => Buffer.isBuffer(frame)).length;
394
+
395
+ // Row 50: a light-routed command (no `full`) runs through Shed's own
396
+ // runCommand App method, not the PTY. "Light mode" never requires the
397
+ // PTY socket to be absent (ShellPanel keeps it mounted for the
398
+ // banner/session facts); what must hold is that a light command's own
399
+ // bytes never reach the PTY input — checked here, before any
400
+ // escalation, while the light prompt is still the mounted surface.
401
+ const runCommandCalls = [];
402
+ page.on("request", (request) => {
403
+ if (request.url() === `${origin}/apps/shed/methods/runCommand`)
404
+ runCommandCalls.push(request.postDataJSON());
405
+ });
406
+ const beforeLightInput = sentBytesLength();
407
+ await prompt.click();
408
+ await prompt.fill("pwd");
409
+ await prompt.press("Enter");
410
+ await expect
411
+ .poll(() => runCommandCalls.some((call) => call.command === "pwd"), {
412
+ message: "A light command calls Shed's runCommand method",
413
+ timeout: 30_000,
414
+ })
415
+ .toBe(true);
416
+ assert.equal(
417
+ sentBytesLength(),
418
+ beforeLightInput,
419
+ "A light-routed command never writes bytes to the PTY input",
420
+ );
421
+
422
+ const marker = `oyster-${nonce}-command`;
423
+ await prompt.click();
424
+ await prompt.fill(`full printf 'oyster-%s-command\\n' '${nonce}'`);
425
+ await prompt.press("Enter");
426
+ await expect
427
+ .poll(() => receivedBytes().includes(marker), {
428
+ message: "A real shell command returns through Shed's terminal",
429
+ timeout: 30_000,
430
+ })
431
+ .toBe(true);
432
+ const after = `after-shed-terminal-${width}.png`;
433
+ await page.screenshot({ path: join(work, after), fullPage: true });
434
+ screenshots.push(after);
435
+
436
+ // Row 49: pwd/ls/git log -1 --oneline, typed straight into the now
437
+ // visible real terminal (we are already escalated). Asserted on the
438
+ // raw PTY byte stream this proof already observes over the
439
+ // websocket — not a rendered grid: Ghostty's own cell buffer isn't
440
+ // introspectable from this packed Release outside a Vite dev server
441
+ // (unlike terminal-resize.browser.mjs's recording-ghostty trick).
442
+ // ghostty-web's open() (dist/ghostty-web.js) gives its mount element
443
+ // itself role="textbox" aria-label="Terminal input" (contenteditable,
444
+ // for the real visible terminal region) AND appends a second, 1px
445
+ // clipped <textarea> with the SAME role and label as its internal
446
+ // key-capture target — so that accessible name is not unique and
447
+ // getByRole(...) for it resolves ambiguously. Ghostty's own supported
448
+ // input path is: click its <canvas> (the visible rendered region,
449
+ // already styled by shed-pane.css's ".terminal-host canvas" rule),
450
+ // which focuses the hidden textarea internally; type and press keys
451
+ // against the page, never against the vendor's hidden input directly,
452
+ // and never touch shed's product DOM to disambiguate it.
453
+ const terminalRegion = shed.locator(".terminal-host canvas");
454
+ /**
455
+ * Type one line into the real terminal and wait for ITS OWN
456
+ * split-marker output — proof the command actually completed, never
457
+ * satisfied by the terminal's own echo of the typed line. The
458
+ * negative control is explicit: once the terminal has echoed enough
459
+ * bytes to show typing landed, the marker must still be absent,
460
+ * before Enter is even pressed (a bare terminal echoes keystrokes
461
+ * before a shell can act on them). No fixed delay anywhere in this
462
+ * helper.
463
+ *
464
+ * CONFIRMED against a real bounded terminal: a long typed line (the
465
+ * SETUP command below, chained and well over the row width) does NOT
466
+ * echo back as one flat contiguous copy of `line` — bash's own
467
+ * readline redraws past the viewport width with its own cursor
468
+ * movement instead, so `sinceStart().includes(line)` can time out
469
+ * forever even though the terminal is echoing correctly (observed
470
+ * directly in a failed run's screenshot: a horizontally-scrolled tail
471
+ * of the command, not line-wrapped, not the literal substring). The
472
+ * echo check below only waits for AT LEAST as many new bytes as were
473
+ * typed — proof that real echo happened — never for the exact text,
474
+ * so it holds regardless of wrap/redraw width. The actual "this
475
+ * command, not its echo" proof stays on the marker alone, which is
476
+ * short enough to always render intact.
477
+ */
478
+ async function runFull(command, markerPrefix) {
479
+ const start = receivedBytes().length;
480
+ const sinceStart = () =>
481
+ receivedBytes().subarray(start).toString("utf8");
482
+ const markerNonce = randomBytes(8).toString("hex");
483
+ const lineMarker = `${markerPrefix}-${markerNonce}`;
484
+ const line = `${command}; printf '%s%s\\n' '${markerPrefix}-' '${markerNonce}'`;
485
+ assert.ok(
486
+ !line.includes(lineMarker),
487
+ "the typed line itself never contains the concatenated marker",
488
+ );
489
+ await terminalRegion.click();
490
+ await page.keyboard.type(line);
491
+ await expect
492
+ .poll(() => sinceStart().length >= line.length, {
493
+ message: `The terminal echoes the typed line for: ${command}`,
494
+ timeout: 10_000,
495
+ })
496
+ .toBe(true);
497
+ assert.ok(
498
+ !sinceStart().includes(lineMarker),
499
+ "echoing the typed line alone never satisfies the split marker " +
500
+ "(the negative control pr1-launch-smoke-apps.test.mjs also proves)",
501
+ );
502
+ await page.keyboard.press("Enter");
503
+ await expect
504
+ .poll(() => sinceStart().includes(lineMarker), {
505
+ message: `The PTY completes: ${command}`,
506
+ timeout: 30_000,
507
+ })
508
+ .toBe(true);
509
+ return sinceStart();
510
+ }
511
+ const probeDir = `/workspace/e2e-shed-proof-${nonce}`;
512
+ const knownFile = "known.txt";
513
+ // The VM's own git auto-detects an invalid email from its hostname
514
+ // ("fungi@computer.(none)") and refuses to commit without one
515
+ // explicitly configured — the same thing a real first-time user
516
+ // would hit, worked around here like any CI script would, with a
517
+ // disposable local identity (git config without --global, scoped to
518
+ // this probe repo alone).
519
+ await runFull(
520
+ `mkdir -p ${probeDir} && cd ${probeDir} && printf probe > ${knownFile} && git init -q && git config user.email proof@example.com && git config user.name "E2E proof" && git commit -q --allow-empty -m e2e-proof-commit`,
521
+ "SETUP",
522
+ );
523
+ // pwd's own output comes only from $(pwd) command substitution,
524
+ // evaluated at run time — the typed/echoed line never contains the
525
+ // real path, so any occurrence in this command's own output slice
526
+ // can only be the shell's real answer.
527
+ const pwdOutput = await runFull(`printf '%s' "$(pwd)"`, "PWD");
528
+ assert.ok(
529
+ pwdOutput.includes(probeDir),
530
+ "pwd's command-substitution output renders the real working directory",
531
+ );
532
+ const lsOutput = await runFull("ls", "LS");
533
+ assert.ok(
534
+ lsOutput.includes(knownFile),
535
+ "ls's own output lists the known seeded file",
536
+ );
537
+ // --color=never: git auto-colorizes --oneline with ANSI SGR codes
538
+ // when it detects a real PTY (confirmed: the hash and ref decoration
539
+ // render in distinct colors against the live terminal), which breaks
540
+ // a plain hash/subject regex on the raw byte stream.
541
+ const logOutput = await runFull(
542
+ "git log -1 --oneline --color=never",
543
+ "LOG",
544
+ );
545
+ assert.match(
546
+ logOutput,
547
+ // `--oneline` decorates the first commit of a fresh repo with its
548
+ // ref ("(HEAD -> master)") between the hash and the subject — real
549
+ // git behavior, not optional here.
550
+ /[0-9a-f]{7,}(?: \([^)]+\))? e2e-proof-commit/,
551
+ "git log -1 --oneline renders a real commit hash and subject",
552
+ );
553
+ assert.ok(
554
+ !logOutput.includes("�"),
555
+ "no U+FFFD replacement character in the raw PTY byte stream",
556
+ );
557
+ const ptyShot = `terminal-pty-bytes-${width}.png`;
558
+ await page.screenshot({ path: join(work, ptyShot), fullPage: true });
559
+ screenshots.push(ptyShot);
560
+
561
+ // Row 51: start a background server, expose its port, confirm the
562
+ // preview affordance renders, then stop the process. The popup's own
563
+ // content (fence asserts preview-popup.browser.mjs:340-456 already
564
+ // cover: no opener, not an embedded iframe, shows the real served
565
+ // page) is proven at the live demo, not here — see the comment
566
+ // below the "Open preview" visibility check for why this fixture
567
+ // itself cannot reach it. The Processes and Ports trays are
568
+ // Tourtois window chrome, not Shed's own light/full prompt, so they
569
+ // work the same escalated or not.
570
+ // Port 8099, not 8080: 8080 is computerDaemonPort, computerd's own
571
+ // reserved control-plane port (packages/computer/src/
572
+ // operation-contract.ts); starting a demo process there throws
573
+ // ComputerPortReservedError, confirmed against the real VM.
574
+ //
575
+ // Not python3 -m http.server: packages/computer/Dockerfile only
576
+ // installs python3 in its discarded intermediate "terminal" build
577
+ // stage (to compile node-pty's native addon); the shipped final image
578
+ // installs fuse3/libfuse2t64/ca-certificates/curl/git/tini and NOT
579
+ // python3 — confirmed directly against the real VM (`find /` finds no
580
+ // python3 anywhere, exit 127 "command not found"). node IS guaranteed
581
+ // present (it's the image's own base), so a one-line node http server
582
+ // replaces it, serving the same "Directory listing for /" heading
583
+ // python's stdlib server would (this fixture no longer opens the
584
+ // popup to check it, see below, but a real served page is still
585
+ // what's actually running behind the exposed port).
586
+ // The tray button's own accessible name now includes a running-count
587
+ // badge (e.g. "Processes 1"), so this matches the leading word only.
588
+ await desktop.getByRole("button", { name: /^Processes/ }).click();
589
+ const startForm = shed.getByRole("form", { name: "Start a process" });
590
+ await expect(startForm).toBeVisible();
591
+ const processCommand =
592
+ 'node -e \'require("http").createServer((req,res)=>{res.writeHead(200,{"Content-Type":"text/html"});res.end("<h1>Directory listing for /</h1>")}).listen(8099)\'';
593
+ await startForm
594
+ .getByRole("textbox", { name: "Command" })
595
+ .fill(processCommand);
596
+ await startForm
597
+ .getByRole("spinbutton", { name: "Port (optional)" })
598
+ .fill("8099");
599
+ await startForm
600
+ .getByRole("button", { name: "Start", exact: true })
601
+ .click();
602
+ const processRow = shed.locator('[data-process-state="running"]');
603
+ await expect(processRow).toContainText(processCommand, {
604
+ timeout: 30_000,
605
+ });
606
+ // Same count-badge reason as Processes above ("Ports 1").
607
+ await desktop.getByRole("button", { name: /^Ports/ }).click();
608
+ const exposeForm = shed.getByRole("form", { name: "Expose a port" });
609
+ await expect(exposeForm).toBeVisible();
610
+ await exposeForm
611
+ .getByRole("spinbutton", { name: "Port", exact: true })
612
+ .fill("8099");
613
+ await exposeForm
614
+ .getByRole("button", { name: "Expose", exact: true })
615
+ .click();
616
+ const openPreview = shed.getByRole("button", {
617
+ name: "Open preview of port 8099",
618
+ exact: true,
619
+ });
620
+ await expect(openPreview).toBeVisible({ timeout: 30_000 });
621
+ // Stop here, by CTO decision: this VM fixture's wrangler runs under
622
+ // --local-upstream, which rewrites every request's Host to one fixed
623
+ // value, so the popup itself can never reach a second, distinct
624
+ // preview-tunnel origin through it (confirmed directly: the popup
625
+ // opens but its own "Directory listing" heading never arrives,
626
+ // because the popup's request never reaches the real backend). The
627
+ // real fix (a distinct HTTPS preview origin) needs --local-protocol
628
+ // https, which this VM fixture's vmd cannot yet speak back to
629
+ // (tooling/celld-platform/computer-vm/vmd.mjs's proxyHandlers leaks
630
+ // TLS socket listeners on its https branch — a separate, logged
631
+ // bug). The live demo runs on Cloudflare containers with a real
632
+ // certificate and a real preview origin, not this fixture, so its
633
+ // own rehearsal is the popup's real end-to-end proof; this proof
634
+ // asserts everything the fixture CAN reach for real: the process
635
+ // starts, Ports lists it, expose succeeds, and the preview
636
+ // affordance renders with the right port — then moves straight to
637
+ // Stop.
638
+ await desktop.getByRole("button", { name: /^Processes/ }).click();
639
+ // A HoldButton: stopping a process is a held, not a single, click.
640
+ await processRow
641
+ .getByRole("button", {
642
+ name: `Stop process ${processCommand}`,
643
+ exact: true,
644
+ })
645
+ .click({ delay: 1000 });
646
+ await expect(processRow).toHaveCount(0, { timeout: 30_000 });
647
+
648
+ const beforeResize = sent.length;
649
+ await page.setViewportSize({ width: width - 20, height: 800 });
650
+ await expect
651
+ .poll(
652
+ () =>
653
+ sent
654
+ .slice(beforeResize)
655
+ .filter((frame) => !Buffer.isBuffer(frame))
656
+ .map((frame) => JSON.parse(frame))
657
+ .some((frame) => frame.type === "resize"),
658
+ {
659
+ message: "Shed resizes the PTY using the existing resize frame",
660
+ timeout: 10_000,
661
+ },
662
+ )
663
+ .toBe(true);
664
+ // A reconnect's earlier close cannot satisfy navigation disposal.
665
+ await page.evaluate(
666
+ (key) => globalThis.sessionStorage.removeItem(key),
667
+ terminalCloseKey,
668
+ );
669
+ const terminalUrl = terminalSocket.url();
670
+ await page.goto(`${origin}/login`);
671
+ await expect
672
+ .poll(
673
+ () =>
674
+ page.evaluate(
675
+ (key) => globalThis.sessionStorage.getItem(key),
676
+ terminalCloseKey,
677
+ ),
678
+ {
679
+ message: "Leaving Tourtois closes the Shed terminal transport",
680
+ timeout: 10_000,
681
+ },
682
+ )
683
+ .toBe(terminalUrl);
684
+ diagnostics.sockets.push({
685
+ path: new URL(terminalUrl).pathname,
686
+ event: "disposed-on-navigation",
687
+ });
688
+ viewports.push(width);
689
+ } catch (cause) {
690
+ await page.screenshot({
691
+ path: join(work, `failure-${width}.png`),
692
+ fullPage: true,
693
+ });
694
+ diagnostics.failure = redact(
695
+ cause instanceof Error ? cause.message : "Proof failed",
696
+ );
697
+ diagnostics.parentError = await page
698
+ .locator("body")
699
+ .getAttribute("data-oyster-error");
700
+ diagnostics.frames = await Promise.all(
701
+ page.frames().map(async (frame) => ({
702
+ path: new URL(frame.url(), origin).pathname,
703
+ text: redact(
704
+ await frame
705
+ .locator("body")
706
+ .innerText()
707
+ .catch((cause) =>
708
+ cause instanceof Error
709
+ ? `Frame unavailable: ${cause.message}`
710
+ : "Frame unavailable: non-Error failure",
711
+ ),
712
+ ),
713
+ })),
714
+ );
715
+ const failureOptions = { cause: Redacted.make(cause) };
716
+ throw new Error(
717
+ `Shed ${runtime} proof failed at ${width}; private diagnostics: ${work}/diagnostics-${width}.json`,
718
+ failureOptions,
719
+ );
720
+ } finally {
721
+ await writeFile(
722
+ join(work, `diagnostics-${width}.json`),
723
+ JSON.stringify(diagnostics, null, 2),
724
+ { mode: 0o600 },
725
+ );
726
+ await page.close();
727
+ }
728
+ }
729
+ } finally {
730
+ try {
731
+ await browser?.close();
732
+ } finally {
733
+ if (
734
+ !startupFailed &&
735
+ worker.exitCode === null &&
736
+ worker.signalCode === null
737
+ )
738
+ worker.kill("SIGTERM");
739
+ await stopped;
740
+ await writeFile(join(work, "worker.log"), redact(workerOutput), {
741
+ mode: 0o600,
742
+ });
743
+ }
744
+ }
745
+ await writeFile(
746
+ join(work, "receipt.json"),
747
+ JSON.stringify(
748
+ {
749
+ passed: true,
750
+ runtime,
751
+ rootRevision: revision,
752
+ sourceRevision: release.artifact.provenance.sourceRevision,
753
+ tourtoisRevision: tourtois.artifact.provenance.sourceRevision,
754
+ viewports,
755
+ releaseId: release.releaseId,
756
+ checks: [
757
+ "backend-command",
758
+ "terminal-metadata",
759
+ "light-command-runCommand-not-pty",
760
+ "full-pty-pwd-ls-gitlog-raw-bytes",
761
+ "terminal-command",
762
+ "process-start-expose-preview-stop",
763
+ "resize",
764
+ "close",
765
+ ],
766
+ screenshots,
767
+ },
768
+ null,
769
+ 2,
770
+ ),
771
+ );
772
+ globalThis.console.log(
773
+ `PASS unchanged Shed on ${runtime}: backend command, metadata, light runCommand vs full PTY, pwd/ls/git log against the raw PTY byte stream (not a rendered grid), process start/expose/preview/stop, resize, close; ${work}/receipt.json`,
774
+ );