@frockbot/plugin-fly-sprite 0.3.23 → 0.3.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-fly-sprite",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,16 +25,16 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@cordisjs/plugin-webui": "0.8.2",
28
- "@frockbot/computer-core": "0.3.23",
29
- "@frockbot/computer-host-protocol": "0.3.23",
30
- "@frockbot/computer-host-runtime": "0.3.23",
31
- "@frockbot/kernel-contracts": "0.3.23",
32
- "@frockbot/plugin-computer": "0.3.23",
28
+ "@frockbot/computer-core": "0.3.24",
29
+ "@frockbot/computer-host-protocol": "0.3.24",
30
+ "@frockbot/computer-host-runtime": "0.3.24",
31
+ "@frockbot/kernel-contracts": "0.3.24",
32
+ "@frockbot/plugin-computer": "0.3.24",
33
33
  "cordis": "4.0.0-rc.8"
34
34
  },
35
35
  "devDependencies": {
36
- "@frockbot/plugin-testkit": "0.3.23",
37
- "@frockbot/workspace-store": "0.3.23",
36
+ "@frockbot/plugin-testkit": "0.3.24",
37
+ "@frockbot/workspace-store": "0.3.24",
38
38
  "@types/bun": "1.4.0",
39
39
  "@types/node": "26.2.0",
40
40
  "typescript": "^7.0.2"
@@ -1,6 +1,7 @@
1
1
  /// <reference types="bun" />
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
+ import { createHash } from "node:crypto";
4
5
  import type { ComputerConnectionProgressV1 } from "@frockbot/computer-core";
5
6
  import { COMPUTER_UNCONFIGURED_MESSAGE_V1 } from "@frockbot/computer-core";
6
7
  import { verifyPluginPackage } from "@frockbot/plugin-testkit";
@@ -63,15 +64,18 @@ function computerRunner(script: string): FakeComputerRunV1 {
63
64
  }
64
65
  if (script.includes('mv "$TMP" "$TARGET"'))
65
66
  return { stdout: "__WRITTEN__\n" };
66
- if (script.includes('base64 -w0 "$TARGET"')) {
67
- // meta, content hash, size, mtime, bytes — the Workspace file shape.
67
+ if (script.includes('"$TARGET" | base64 -w0')) {
68
+ // meta, content hash, size, mtime, first chunk — the Workspace file shape.
69
+ // The digest is the file's real one, because the read proves the chunks it
70
+ // stitched are one file against exactly this value.
71
+ const bytes = Buffer.from("remember");
68
72
  return {
69
73
  stdout: [
70
74
  "",
71
- "0".repeat(64),
72
- "8",
75
+ createHash("sha256").update(bytes).digest("hex"),
76
+ String(bytes.byteLength),
73
77
  "1700000000",
74
- Buffer.from("remember").toString("base64"),
78
+ bytes.toString("base64"),
75
79
  "",
76
80
  ].join("\n"),
77
81
  };
package/src/computer.ts CHANGED
@@ -53,7 +53,14 @@ import type {
53
53
  export { SLOT_IDLE_SECONDS, WORKSPACE_SYNC_SERVICE };
54
54
 
55
55
  const MAX_OUTPUT = 30_000;
56
- const MAX_STORAGE_OUTPUT = 500_000;
56
+ /**
57
+ * The most a Workspace or sync command may answer with.
58
+ *
59
+ * Exported because it is the ceiling those commands are written to stay under,
60
+ * and a test double that answered past it would prove a read works at a size
61
+ * the Computer would have refused.
62
+ */
63
+ export const MAX_STORAGE_OUTPUT = 500_000;
57
64
  const EXEC_EXIT_MARKER = "__FROCKBOT_EXIT__";
58
65
  /** Largest screenshot this provider will carry back off a Computer. */
59
66
  export const SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024;
package/src/sync.test.ts CHANGED
@@ -25,13 +25,15 @@ import {
25
25
  FlySpriteSyncSurface,
26
26
  isWorkspaceSyncIgnoredPathV1,
27
27
  WORKSPACE_SYNC_IGNORED_DIRECTORIES_V1,
28
- WORKSPACE_SYNC_CHUNK_BYTES_V1,
29
28
  WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1,
30
29
  WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1,
31
30
  WORKSPACE_SYNC_MAX_FILE_BYTES_V1,
32
31
  type WorkspaceSyncReportV1,
33
32
  } from "./sync.ts";
34
- import { WORKSPACE_EMPTY_SHA256 } from "./workspace.ts";
33
+ import {
34
+ WORKSPACE_CHUNK_BYTES_V1,
35
+ WORKSPACE_EMPTY_SHA256,
36
+ } from "./workspace.ts";
35
37
 
36
38
  const USER = "owner";
37
39
  const BOT = "health";
@@ -219,7 +221,7 @@ class FakeSyncSprite {
219
221
  if (bytes.byteLength > WORKSPACE_SYNC_MAX_FILE_BYTES_V1) {
220
222
  return "__TOO_LARGE__\n";
221
223
  }
222
- const head = bytes.subarray(0, WORKSPACE_SYNC_CHUNK_BYTES_V1);
224
+ const head = bytes.subarray(0, WORKSPACE_CHUNK_BYTES_V1);
223
225
  return `${bytes.byteLength}\t${sha256(bytes)}\n${Buffer.from(head).toString("base64")}\n`;
224
226
  }
225
227
 
package/src/sync.ts CHANGED
@@ -105,9 +105,12 @@ import {
105
105
  SYNC_CONFLICTS_DIR,
106
106
  SYNC_STAGING_DIR,
107
107
  SYNC_TOMBSTONES_DIR,
108
+ WORKSPACE_CHUNK_BYTES_V1,
108
109
  WORKSPACE_EMPTY_SHA256,
109
110
  WORKSPACE_GENERATIONS_DIR,
110
111
  WORKSPACE_SYNC_DIR,
112
+ workspaceChunkOffsetsV1,
113
+ workspaceStagingNameV1,
111
114
  } from "./workspace.js";
112
115
 
113
116
  /** Where the sync keeps notes that are not scoped to one root. */
@@ -146,17 +149,6 @@ export const WORKSPACE_SYNC_MANIFEST_MAX_BYTES_V1 = 400_000;
146
149
  /** Bounds work and rows even when unusually short paths fit under the bytes. */
147
150
  export const WORKSPACE_SYNC_MANIFEST_MAX_ENTRIES_V1 = 2_000;
148
151
 
149
- /**
150
- * The most file bytes one storage command carries, in either direction.
151
- *
152
- * A command's answer travels as base64, so a chunk this size comes back as
153
- * roughly 350 KB — inside the 500 KB an answer may be — and goes out inside a
154
- * script well inside the 1 MB a script may be. A file larger than one chunk
155
- * travels as several commands rather than as one command that would be refused
156
- * for its size, which is the whole reason the sync reads and writes in chunks.
157
- */
158
- export const WORKSPACE_SYNC_CHUNK_BYTES_V1 = 256 * 1024;
159
-
160
152
  /**
161
153
  * The largest durable-root file the sync carries between the Computer and the
162
154
  * store.
@@ -168,25 +160,6 @@ export const WORKSPACE_SYNC_CHUNK_BYTES_V1 = 256 * 1024;
168
160
  */
169
161
  export const WORKSPACE_SYNC_MAX_FILE_BYTES_V1 = 4 * 1024 * 1024;
170
162
 
171
- /** Every chunk offset after the first, in bytes, for a file of this size. */
172
- function chunkOffsets(size: number): number[] {
173
- const offsets: number[] = [];
174
- for (
175
- let offset = WORKSPACE_SYNC_CHUNK_BYTES_V1;
176
- offset < size;
177
- offset += WORKSPACE_SYNC_CHUNK_BYTES_V1
178
- ) {
179
- offsets.push(offset);
180
- }
181
- return offsets;
182
- }
183
-
184
- /** A staging file name that is one path segment whatever the id looks like. */
185
- function stagingName(generationId: string, kind: string): string {
186
- const safe = generationId.replaceAll(/[^A-Za-z0-9._-]/g, "-").slice(0, 128);
187
- return `${safe}.${kind}`;
188
- }
189
-
190
163
  const SHA256_HEX = /^[0-9a-f]{64}$/;
191
164
 
192
165
  const ignoredDirectoryNames = new Set<string>(
@@ -1205,7 +1178,7 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
1205
1178
  const head = await this.run(
1206
1179
  [
1207
1180
  ...prelude,
1208
- `CHUNK=${WORKSPACE_SYNC_CHUNK_BYTES_V1}`,
1181
+ `CHUNK=${WORKSPACE_CHUNK_BYTES_V1}`,
1209
1182
  'if [ ! -f "$FILE" ]; then echo __MISSING__; exit 0; fi',
1210
1183
  'SIZE=$(stat -c %s "$FILE")',
1211
1184
  `if [ "$SIZE" -gt ${WORKSPACE_SYNC_MAX_FILE_BYTES_V1} ]; then echo __TOO_LARGE__; exit 0; fi`,
@@ -1230,13 +1203,13 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
1230
1203
  return failure("unavailable", "Invalid Fly Workspace sync response");
1231
1204
  }
1232
1205
  const chunks = [Buffer.from(first.trim(), "base64")];
1233
- for (const offset of chunkOffsets(size)) {
1206
+ for (const offset of workspaceChunkOffsetsV1(size)) {
1234
1207
  const next = await this.run(
1235
1208
  [
1236
1209
  ...prelude,
1237
1210
  // `tail -c +N` counts from one, so the offset is one past the bytes
1238
1211
  // already carried.
1239
- `tail -c +${offset + 1} "$FILE" | head -c ${WORKSPACE_SYNC_CHUNK_BYTES_V1} | base64 -w0; echo`,
1212
+ `tail -c +${offset + 1} "$FILE" | head -c ${WORKSPACE_CHUNK_BYTES_V1} | base64 -w0; echo`,
1240
1213
  ].join("\n"),
1241
1214
  );
1242
1215
  if (typeof next !== "string") return next;
@@ -1271,17 +1244,14 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
1271
1244
  name: string,
1272
1245
  bytes: Uint8Array,
1273
1246
  ): Promise<string | WorkspaceFailureV1 | undefined> {
1274
- if (bytes.byteLength <= WORKSPACE_SYNC_CHUNK_BYTES_V1) return undefined;
1247
+ if (bytes.byteLength <= WORKSPACE_CHUNK_BYTES_V1) return undefined;
1275
1248
  const staged = `${mount}/${WORKSPACE_SYNC_DIR}/${SYNC_STAGING_DIR}/${name}`;
1276
1249
  for (
1277
1250
  let offset = 0;
1278
1251
  offset < bytes.byteLength;
1279
- offset += WORKSPACE_SYNC_CHUNK_BYTES_V1
1252
+ offset += WORKSPACE_CHUNK_BYTES_V1
1280
1253
  ) {
1281
- const chunk = bytes.subarray(
1282
- offset,
1283
- offset + WORKSPACE_SYNC_CHUNK_BYTES_V1,
1284
- );
1254
+ const chunk = bytes.subarray(offset, offset + WORKSPACE_CHUNK_BYTES_V1);
1285
1255
  const output = await this.run(
1286
1256
  [
1287
1257
  "set -eu",
@@ -1315,7 +1285,7 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
1315
1285
  if (oversized) return oversized;
1316
1286
  const staged = await this.stage(
1317
1287
  mount,
1318
- stagingName(generation.generationId, "pull"),
1288
+ workspaceStagingNameV1(generation.generationId, "pull"),
1319
1289
  bytes,
1320
1290
  );
1321
1291
  if (staged && typeof staged !== "string") return staged;
@@ -1462,7 +1432,7 @@ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
1462
1432
  if (oversized) return oversized;
1463
1433
  const staged = await this.stage(
1464
1434
  mount,
1465
- stagingName(generation.generationId, "conflict"),
1435
+ workspaceStagingNameV1(generation.generationId, "conflict"),
1466
1436
  bytes,
1467
1437
  );
1468
1438
  if (staged && typeof staged !== "string") return staged;
@@ -20,10 +20,11 @@ import {
20
20
  computerBotKey,
21
21
  type ComputerHostFactoryV1,
22
22
  FlySpriteComputer,
23
+ MAX_STORAGE_OUTPUT,
23
24
  } from "./computer.ts";
24
25
  import { FakeComputerHost, type FakeComputerRunV1 } from "./host-double.ts";
25
26
  import { FLY_WORKSPACE_LAYOUT, FlySpriteComputerProvider } from "./provider.ts";
26
- import { FlyComputerWorkspace } from "./workspace.ts";
27
+ import { FlyComputerWorkspace, WORKSPACE_CHUNK_BYTES_V1 } from "./workspace.ts";
27
28
 
28
29
  const USER = "owner";
29
30
  const BOT = "health";
@@ -69,6 +70,19 @@ function sha256(bytes: Uint8Array): string {
69
70
  return createHash("sha256").update(bytes).digest("hex");
70
71
  }
71
72
 
73
+ /**
74
+ * Bytes of an exact length whose every chunk differs from every other, so a
75
+ * chunk carried twice, dropped, or reordered changes the bytes rather than
76
+ * hiding inside a repeated pattern.
77
+ */
78
+ function largeBytes(length: number): Uint8Array {
79
+ let text = "";
80
+ for (let index = 0; text.length < length; index += 1) {
81
+ text += `${index}:${"abcdefghijklmnopqrstuvwxyz".repeat(3)}\n`;
82
+ }
83
+ return new TextEncoder().encode(text.slice(0, length));
84
+ }
85
+
72
86
  function quoted(shell: string, name: string): string | undefined {
73
87
  return new RegExp(`${name}='([^']*)'`).exec(shell)?.[1];
74
88
  }
@@ -83,28 +97,39 @@ class FakeWorkspaceDisk {
83
97
  offline = false;
84
98
  modifiedSeconds = 1_700_000_000;
85
99
 
100
+ /** Every script this disk was handed, in order. */
101
+ readonly scripts: string[] = [];
102
+ /** Rewrites the file at this path between two chunk commands of one read. */
103
+ midReadRewrite?: { path: string; bytes: Uint8Array };
104
+
86
105
  /** The runner the shared host double hands every script to. */
87
106
  readonly run = (script: string): FakeComputerRunV1 => {
107
+ this.scripts.push(script);
88
108
  if (this.offline) return { exitCode: 1, stderr: "Sprite is paused" };
109
+ // The real host's storage surface refuses an answer past this, so a double
110
+ // that returned one would let a suite prove a read works at a size the
111
+ // Computer would never have carried.
112
+ const bounded = (stdout: string): FakeComputerRunV1 =>
113
+ stdout.length > MAX_STORAGE_OUTPUT
114
+ ? { stdout: stdout.slice(0, MAX_STORAGE_OUTPUT), outputTruncated: true }
115
+ : { stdout };
116
+ if (script.includes("__STAGED__")) {
117
+ return bounded(this.stageChunk(script));
118
+ }
89
119
  const root = quoted(script, "ROOT");
90
120
  const relative = quoted(script, "REL");
91
121
  if (!root) return {};
92
122
  if (script.includes("__WRITTEN__") && relative) {
93
- return { stdout: this.write(`${root}/${relative}`, script) };
123
+ return bounded(this.write(`${root}/${relative}`, script));
94
124
  }
95
125
  if (script.includes("__DELETED__") && relative) {
96
- return { stdout: this.remove(`${root}/${relative}`, script) };
126
+ return bounded(this.remove(`${root}/${relative}`, script));
97
127
  }
98
128
  if (script.includes('find "$ROOT"')) {
99
- return { stdout: this.list(root, script) };
129
+ return bounded(this.list(root, script));
100
130
  }
101
131
  if (relative) {
102
- return {
103
- stdout: this.load(
104
- `${root}/${relative}`,
105
- script.includes('base64 -w0 "$TARGET"'),
106
- ),
107
- };
132
+ return bounded(this.load(`${root}/${relative}`, script));
108
133
  }
109
134
  return {};
110
135
  };
@@ -122,14 +147,47 @@ class FakeWorkspaceDisk {
122
147
  return /if \[ "\$CURRENT" != '([^']*)' \]/.exec(shell)?.[1] ?? "";
123
148
  }
124
149
 
125
- private write(path: string, shell: string): string {
126
- if (this.current(path) !== this.expected(shell)) return "__CONFLICT__\n";
127
- const bytes = /printf %s '([^']*)' \| base64 -d > "\$TMP"/.exec(shell)?.[1];
128
- const meta = /printf %s '([^']*)' \| base64 -d > "\$MTMP"/.exec(shell)?.[1];
150
+ /** One appended chunk of a staged file, as the write stages it. */
151
+ private stageChunk(shell: string): string {
152
+ const path = quoted(shell, "STAGE") ?? "";
153
+ const encoded =
154
+ /printf %s '([^']*)' \| base64 -d >> "\$STAGE"/.exec(shell)?.[1] ?? "";
155
+ const chunk = Buffer.from(encoded, "base64");
156
+ const held = shell.includes('rm -f "$STAGE"')
157
+ ? undefined
158
+ : this.files.get(path)?.bytes;
129
159
  this.files.set(path, {
130
- bytes: Uint8Array.from(Buffer.from(bytes ?? "", "base64")),
131
- meta,
160
+ bytes: Uint8Array.from(
161
+ held ? Buffer.concat([Buffer.from(held), chunk]) : chunk,
162
+ ),
132
163
  });
164
+ return "__STAGED__\n";
165
+ }
166
+
167
+ private write(path: string, shell: string): string {
168
+ const stage = quoted(shell, "STAGE");
169
+ if (this.current(path) !== this.expected(shell)) {
170
+ if (stage) this.files.delete(stage);
171
+ return "__CONFLICT__\n";
172
+ }
173
+ const meta = /printf %s '([^']*)' \| base64 -d > "\$MTMP"/.exec(shell)?.[1];
174
+ let bytes: Uint8Array;
175
+ if (stage) {
176
+ // The staged bytes are digest-checked exactly as the emitted
177
+ // `sha256sum` line checks them, so a torn staging file is __CORRUPT__
178
+ // here for the same reason it would be on the Sprite.
179
+ const staged = this.files.get(stage)?.bytes ?? new Uint8Array();
180
+ this.files.delete(stage);
181
+ const expected = /f1\)" != '([0-9a-f]{64})'/.exec(shell)?.[1];
182
+ if (expected && sha256(staged) !== expected) return "__CORRUPT__\n";
183
+ bytes = staged;
184
+ } else {
185
+ const inline = /printf %s '([^']*)' \| base64 -d > "\$TMP"/.exec(
186
+ shell,
187
+ )?.[1];
188
+ bytes = Uint8Array.from(Buffer.from(inline ?? "", "base64"));
189
+ }
190
+ this.files.set(path, { bytes, meta });
133
191
  return "__WRITTEN__\n";
134
192
  }
135
193
 
@@ -140,7 +198,28 @@ class FakeWorkspaceDisk {
140
198
  return "__DELETED__\n";
141
199
  }
142
200
 
143
- private load(path: string, withBytes: boolean): string {
201
+ /**
202
+ * The chunked file read: a header of sidecar, digest, size, and mtime with
203
+ * the first chunk, then one chunk per further command. `head -c` and
204
+ * `tail -c +N` are the coreutils the Workspace emits; the arithmetic is
205
+ * theirs, not an approximation.
206
+ */
207
+ private load(path: string, shell: string): string {
208
+ const chunk = /tail -c \+(\d+) "\$TARGET" \| head -c (\d+)/.exec(shell);
209
+ if (chunk) {
210
+ // A rewrite between two chunk commands is what makes a read report
211
+ // rather than stitch, so the double performs one where a test asks.
212
+ const rewrite = this.midReadRewrite;
213
+ if (rewrite) {
214
+ this.midReadRewrite = undefined;
215
+ this.files.set(rewrite.path, { bytes: rewrite.bytes });
216
+ }
217
+ const offset = Number(chunk[1]) - 1;
218
+ const limit = Number(chunk[2]);
219
+ const bytes = this.files.get(path)?.bytes;
220
+ const slice = bytes?.subarray(offset, offset + limit) ?? new Uint8Array();
221
+ return `${Buffer.from(slice).toString("base64")}\n`;
222
+ }
144
223
  const entry = this.files.get(path);
145
224
  if (!entry) return "__MISSING__\n";
146
225
  if (entry.bytes.byteLength > WORKSPACE_MAX_FILE_BYTES) {
@@ -152,7 +231,14 @@ class FakeWorkspaceDisk {
152
231
  String(entry.bytes.byteLength),
153
232
  String(this.modifiedSeconds),
154
233
  ];
155
- if (withBytes) lines.push(Buffer.from(entry.bytes).toString("base64"));
234
+ const head = /head -c (\d+) "\$TARGET" \| base64 -w0/.exec(shell);
235
+ if (head) {
236
+ lines.push(
237
+ Buffer.from(entry.bytes.subarray(0, Number(head[1]))).toString(
238
+ "base64",
239
+ ),
240
+ );
241
+ }
156
242
  return `${lines.join("\n")}\n`;
157
243
  }
158
244
 
@@ -163,6 +249,9 @@ class FakeWorkspaceDisk {
163
249
  const rows = [...this.files.entries()]
164
250
  .filter(([path]) => path.startsWith(`${root}/`))
165
251
  .map(([path, entry]) => [path.slice(root.length + 1), entry] as const)
252
+ // The emitted `find` prunes the lock, generation, and sync directories,
253
+ // so a listing never shows a staging file mid-write.
254
+ .filter(([relative]) => !relative.startsWith(".frockbot-"))
166
255
  .filter(
167
256
  ([relative]) =>
168
257
  !prefix || relative === prefix || relative.startsWith(`${prefix}/`),
@@ -949,3 +1038,171 @@ describe("the Computer's sidecar is a hint; the Durable Object is the authority"
949
1038
  expect(read.file.generation.writer).toEqual({ kind: "unattributed" });
950
1039
  });
951
1040
  });
1041
+
1042
+ /**
1043
+ * The Workspace's own per-file rule is what bounds a file; the shape of the
1044
+ * commands that carry it never is.
1045
+ *
1046
+ * A Bot's file tools read and write through this surface, and both directions
1047
+ * used to be one command carrying the whole file as base64: a 470 KB built
1048
+ * Applet page is 627 KB of base64, past what a storage command may answer, so
1049
+ * a Bot could not read a file it had just built. These prove the transport is
1050
+ * no longer the constraint, in both directions, at sizes the Workspace admits.
1051
+ */
1052
+ describe("Fly Workspace files past one storage command", () => {
1053
+ const mount = workspaceMountPathV1(
1054
+ FLY_WORKSPACE_LAYOUT,
1055
+ packageRoot,
1056
+ computerBotKey,
1057
+ );
1058
+
1059
+ test("reads a file far larger than one storage command can answer, byte for byte", async () => {
1060
+ const disk = new FakeWorkspaceDisk();
1061
+ const bytes = largeBytes(900 * 1024);
1062
+ disk.files.set(`${mount}/dist/ui.html`, { bytes });
1063
+ const { workspace } = await openWorkspace(BOT, disk);
1064
+
1065
+ const read = await workspace.read({
1066
+ root: packageRoot,
1067
+ path: "dist/ui.html",
1068
+ });
1069
+
1070
+ if (read.status !== "ok") throw new Error(read.reason);
1071
+ expect(read.file.bytes).toEqual(bytes);
1072
+ expect(read.file.generation.contentHash).toBe(sha256(bytes));
1073
+ // The first command answers the size, the digest, and one chunk; the rest
1074
+ // of the file is one command per further chunk.
1075
+ expect(
1076
+ disk.scripts.filter((script) => script.includes("$TARGET")).length,
1077
+ ).toBe(Math.ceil(bytes.byteLength / WORKSPACE_CHUNK_BYTES_V1));
1078
+ });
1079
+
1080
+ test("writes a file far larger than one storage command can carry, byte for byte", async () => {
1081
+ const disk = new FakeWorkspaceDisk();
1082
+ const { workspace } = await openWorkspace(BOT, disk);
1083
+ const bytes = largeBytes(700 * 1024);
1084
+
1085
+ const written = await workspace.write({
1086
+ path: { root: packageRoot, path: "dist/bundle.js" },
1087
+ bytes,
1088
+ writer: BOT_WRITER,
1089
+ expectedGenerationId: null,
1090
+ });
1091
+
1092
+ if (written.status !== "ok") throw new Error(written.reason);
1093
+ expect(written.generation.contentHash).toBe(sha256(bytes));
1094
+ expect(disk.files.get(`${mount}/dist/bundle.js`)?.bytes).toEqual(bytes);
1095
+ // The staging file is gone the moment its bytes are in place, so nothing
1096
+ // half-written is left under a durable root.
1097
+ expect(
1098
+ [...disk.files.keys()].filter((path) => path.includes("/staging/")),
1099
+ ).toEqual([]);
1100
+ const read = await workspace.read({
1101
+ root: packageRoot,
1102
+ path: "dist/bundle.js",
1103
+ });
1104
+ if (read.status !== "ok") throw new Error(read.reason);
1105
+ expect(read.file.bytes).toEqual(bytes);
1106
+ });
1107
+
1108
+ test("a file that fits in one command still takes one command, in each direction", async () => {
1109
+ const disk = new FakeWorkspaceDisk();
1110
+ const { workspace } = await openWorkspace(BOT, disk);
1111
+
1112
+ const written = await workspace.write({
1113
+ path: { root: packageRoot, path: "note.md" },
1114
+ bytes: new TextEncoder().encode("small"),
1115
+ writer: BOT_WRITER,
1116
+ expectedGenerationId: null,
1117
+ });
1118
+ if (written.status !== "ok") throw new Error(written.reason);
1119
+ expect(disk.scripts.filter((script) => script.includes("$STAGE"))).toEqual(
1120
+ [],
1121
+ );
1122
+ expect(
1123
+ disk.scripts.filter((script) => script.includes("__WRITTEN__")),
1124
+ ).toHaveLength(1);
1125
+
1126
+ disk.scripts.length = 0;
1127
+ const read = await workspace.read({ root: packageRoot, path: "note.md" });
1128
+ if (read.status !== "ok") throw new Error(read.reason);
1129
+ expect(new TextDecoder().decode(read.file.bytes)).toBe("small");
1130
+ expect(disk.scripts).toHaveLength(1);
1131
+ });
1132
+
1133
+ test("reports a rewrite between two chunks rather than stitching two versions", async () => {
1134
+ const disk = new FakeWorkspaceDisk();
1135
+ const path = `${mount}/dist/ui.html`;
1136
+ disk.files.set(path, { bytes: largeBytes(900 * 1024) });
1137
+ // A shell on the Computer replaces the file after its size and digest were
1138
+ // answered but before the last chunk arrives.
1139
+ disk.midReadRewrite = { path, bytes: largeBytes(900 * 1024).reverse() };
1140
+ const { workspace } = await openWorkspace(BOT, disk);
1141
+
1142
+ expect(
1143
+ await workspace.read({ root: packageRoot, path: "dist/ui.html" }),
1144
+ ).toMatchObject({
1145
+ status: "unavailable",
1146
+ reason: `"dist/ui.html" changed on the Computer while it was being read`,
1147
+ });
1148
+ });
1149
+
1150
+ // The write used to hold the lock across the whole script, which was fine
1151
+ // while the whole file arrived inside it. Chunks arrive in commands of their
1152
+ // own, and holding the lock across all of them would keep one writer's file
1153
+ // open for as long as its bytes take to travel.
1154
+ test("stages the chunks unlocked and holds the lock only around the final verify-and-rename", async () => {
1155
+ const disk = new FakeWorkspaceDisk();
1156
+ const { workspace } = await openWorkspace(BOT, disk);
1157
+
1158
+ const written = await workspace.write({
1159
+ path: { root: packageRoot, path: "dist/bundle.js" },
1160
+ bytes: largeBytes(700 * 1024),
1161
+ writer: BOT_WRITER,
1162
+ expectedGenerationId: null,
1163
+ });
1164
+
1165
+ if (written.status !== "ok") throw new Error(written.reason);
1166
+ const staging = disk.scripts.filter((script) =>
1167
+ script.includes("__STAGED__"),
1168
+ );
1169
+ expect(staging.length).toBeGreaterThan(1);
1170
+ for (const script of staging) expect(script).not.toContain("flock");
1171
+ const commit = disk.scripts.filter((script) =>
1172
+ script.includes("__WRITTEN__"),
1173
+ );
1174
+ expect(commit).toHaveLength(1);
1175
+ expect(commit[0]).toContain("flock -x 9");
1176
+ // Under the lock: the expected generation, the staged bytes' digest, and
1177
+ // the rename. No file bytes travel inside it.
1178
+ expect(commit[0]).toContain('mv "$STAGE" "$TMP"');
1179
+ expect(commit[0]).not.toContain('base64 -d > "$TMP"');
1180
+ expect(commit[0]!.length).toBeLessThan(WORKSPACE_CHUNK_BYTES_V1);
1181
+ });
1182
+
1183
+ test("refuses a file past the Workspace's own limit in plain words, in each direction", async () => {
1184
+ const disk = new FakeWorkspaceDisk();
1185
+ disk.files.set(`${mount}/huge.bin`, {
1186
+ bytes: new Uint8Array(WORKSPACE_MAX_FILE_BYTES + 1),
1187
+ });
1188
+ const { workspace } = await openWorkspace(BOT, disk);
1189
+
1190
+ expect(
1191
+ await workspace.read({ root: packageRoot, path: "huge.bin" }),
1192
+ ).toMatchObject({
1193
+ status: "refused",
1194
+ reason: `"huge.bin" is past the 1 MB limit on a Workspace file`,
1195
+ });
1196
+ expect(
1197
+ await workspace.write({
1198
+ path: { root: packageRoot, path: "other.bin" },
1199
+ bytes: new Uint8Array(WORKSPACE_MAX_FILE_BYTES + 1),
1200
+ writer: BOT_WRITER,
1201
+ expectedGenerationId: null,
1202
+ }),
1203
+ ).toMatchObject({
1204
+ status: "refused",
1205
+ reason: `"other.bin" is past the 1 MB limit on a Workspace file`,
1206
+ });
1207
+ });
1208
+ });
package/src/workspace.ts CHANGED
@@ -76,6 +76,47 @@ export const SYNC_CONFLICTS_DIR = "conflicts";
76
76
  * is moved into place, under `WORKSPACE_SYNC_DIR`.
77
77
  */
78
78
  export const SYNC_STAGING_DIR = "staging";
79
+ /**
80
+ * The most file bytes one storage command carries, in either direction.
81
+ *
82
+ * A command's answer travels as base64, so a chunk this size comes back as
83
+ * roughly 350 KB — inside the half megabyte an answer may be — and goes out
84
+ * inside a script well inside the megabyte a script may be. A file larger than
85
+ * one chunk travels as several commands rather than as one command that would
86
+ * be refused for its size, so the Workspace's own per-file rule is what bounds
87
+ * a file and the shape of the commands never is.
88
+ */
89
+ export const WORKSPACE_CHUNK_BYTES_V1 = 256 * 1024;
90
+
91
+ /** Every chunk offset after the first, in bytes, for a file of this size. */
92
+ export function workspaceChunkOffsetsV1(size: number): number[] {
93
+ const offsets: number[] = [];
94
+ for (
95
+ let offset = WORKSPACE_CHUNK_BYTES_V1;
96
+ offset < size;
97
+ offset += WORKSPACE_CHUNK_BYTES_V1
98
+ ) {
99
+ offsets.push(offset);
100
+ }
101
+ return offsets;
102
+ }
103
+
104
+ /** A staging file name that is one path segment whatever the id looks like. */
105
+ export function workspaceStagingNameV1(
106
+ generationId: string,
107
+ kind: string,
108
+ ): string {
109
+ const safe = generationId.replaceAll(/[^A-Za-z0-9._-]/g, "-").slice(0, 128);
110
+ return `${safe}.${kind}`;
111
+ }
112
+
113
+ /**
114
+ * The Workspace's per-file limit as a person reads it. Derived, so it cannot
115
+ * drift from the rule it describes, and stated in the refusal instead of a
116
+ * byte count nobody outside this code recognizes.
117
+ */
118
+ const MAX_FILE_LABEL = `${Math.round(WORKSPACE_MAX_FILE_BYTES / (1024 * 1024))} MB`;
119
+
79
120
  const GENERATIONS_DIR = WORKSPACE_GENERATIONS_DIR;
80
121
  const LOCKS_DIR = ".frockbot-locks";
81
122
  /** The sha-256 of no bytes; a deletion tombstone's content address. */
@@ -402,10 +443,13 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
402
443
  const relative = this.path(path);
403
444
  if (typeof relative !== "string") return relative;
404
445
  const mount = this.mount(path.root);
405
- const script = [
446
+ const prelude = [
406
447
  `ROOT=${shellQuote(mount)}`,
407
448
  `REL=${shellQuote(relative)}`,
408
449
  'TARGET="$ROOT/$REL"',
450
+ ];
451
+ const script = [
452
+ ...prelude,
409
453
  `META="$ROOT/${GENERATIONS_DIR}/$REL"`,
410
454
  'if [ ! -f "$TARGET" ]; then echo __MISSING__; exit 0; fi',
411
455
  'SIZE=$(stat -c %s "$TARGET")',
@@ -414,7 +458,15 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
414
458
  'sha256sum "$TARGET" | cut -d" " -f1',
415
459
  'printf "%s\\n" "$SIZE"',
416
460
  'stat -c %Y "$TARGET"',
417
- withBytes ? 'base64 -w0 "$TARGET"; echo' : "",
461
+ // The size and the digest come back before any bytes do, so the rest of
462
+ // the file can be asked for one bounded chunk at a time and the pieces
463
+ // proven to be one file. A whole file as one base64 answer stopped
464
+ // working long before a file got large: half a megabyte of built Applet
465
+ // page is two thirds of a megabyte of answer, which the storage surface
466
+ // refuses outright rather than truncating.
467
+ withBytes
468
+ ? `head -c ${WORKSPACE_CHUNK_BYTES_V1} "$TARGET" | base64 -w0; echo`
469
+ : "",
418
470
  ]
419
471
  .filter(Boolean)
420
472
  .join("\n");
@@ -425,10 +477,7 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
425
477
  return failure("not-found", `No such Workspace file: ${relative}`);
426
478
  }
427
479
  if (lines[0]?.trim() === "__TOO_LARGE__") {
428
- return failure(
429
- "refused",
430
- `Workspace file exceeds ${WORKSPACE_MAX_FILE_BYTES} bytes`,
431
- );
480
+ return this.tooLarge(relative);
432
481
  }
433
482
  const [
434
483
  meta = "",
@@ -446,15 +495,50 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
446
495
  size: Number(size.trim()),
447
496
  modifiedSeconds: Number(modified.trim()),
448
497
  };
449
- const generation = await this.generationOf(path.root, relative, raw);
498
+ if (!Number.isSafeInteger(raw.size) || raw.size < 0) {
499
+ return failure("unavailable", "Invalid Fly Workspace file response");
500
+ }
501
+ if (!withBytes) {
502
+ return { generation: await this.generationOf(path.root, relative, raw) };
503
+ }
504
+ const chunks = [Buffer.from(encoded.trim(), "base64")];
505
+ for (const offset of workspaceChunkOffsetsV1(raw.size)) {
506
+ const next = await this.run(
507
+ [
508
+ ...prelude,
509
+ // `tail -c +N` counts from one, so the offset is one past the bytes
510
+ // already carried.
511
+ `tail -c +${offset + 1} "$TARGET" | head -c ${WORKSPACE_CHUNK_BYTES_V1} | base64 -w0; echo`,
512
+ ].join("\n"),
513
+ signal,
514
+ );
515
+ if (typeof next !== "string") return next;
516
+ chunks.push(Buffer.from(next.trim(), "base64"));
517
+ }
518
+ const bytes = Buffer.concat(chunks);
519
+ // The digest is the proof the chunks are one file. A file rewritten
520
+ // between two commands is reported, never stitched together out of two
521
+ // different versions of itself.
522
+ if (bytes.byteLength !== raw.size || digest(bytes) !== raw.contentHash) {
523
+ return failure(
524
+ "unavailable",
525
+ `"${relative}" changed on the Computer while it was being read`,
526
+ );
527
+ }
450
528
  return {
451
- generation,
452
- ...(withBytes
453
- ? { bytes: Uint8Array.from(Buffer.from(encoded.trim(), "base64")) }
454
- : {}),
529
+ generation: await this.generationOf(path.root, relative, raw),
530
+ bytes: Uint8Array.from(bytes),
455
531
  };
456
532
  }
457
533
 
534
+ /** The one refusal for a file past the Workspace's own per-file rule. */
535
+ private tooLarge(relative: string): WorkspaceFailureV1 {
536
+ return failure(
537
+ "refused",
538
+ `"${relative}" is past the ${MAX_FILE_LABEL} limit on a Workspace file`,
539
+ );
540
+ }
541
+
458
542
  async read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1> {
459
543
  const loaded = await this.load(path, true);
460
544
  if ("status" in loaded) return loaded;
@@ -559,6 +643,50 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
559
643
  };
560
644
  }
561
645
 
646
+ /**
647
+ * Puts bytes past one command on the Computer under a staging file, chunk by
648
+ * chunk, and answers where they landed.
649
+ *
650
+ * Bytes that fit in one command take no staging file at all — the caller
651
+ * writes them inline, which is one command and the overwhelmingly common
652
+ * case. Anything larger is appended chunk by chunk with no lock held, and
653
+ * the caller's own locked command moves it into place, so a file only ever
654
+ * appears at its real path complete.
655
+ */
656
+ private async stage(
657
+ mount: string,
658
+ name: string,
659
+ bytes: Uint8Array,
660
+ ): Promise<string | WorkspaceFailureV1 | undefined> {
661
+ if (bytes.byteLength <= WORKSPACE_CHUNK_BYTES_V1) return undefined;
662
+ const staged = `${mount}/${WORKSPACE_SYNC_DIR}/${SYNC_STAGING_DIR}/${name}`;
663
+ for (
664
+ let offset = 0;
665
+ offset < bytes.byteLength;
666
+ offset += WORKSPACE_CHUNK_BYTES_V1
667
+ ) {
668
+ const chunk = bytes.subarray(offset, offset + WORKSPACE_CHUNK_BYTES_V1);
669
+ const output = await this.run(
670
+ [
671
+ "set -eu",
672
+ `STAGE=${shellQuote(staged)}`,
673
+ 'mkdir -p "$(dirname "$STAGE")"',
674
+ // A staging name is this write's own, so a leftover file under it is
675
+ // a previous attempt's and never another writer's.
676
+ ...(offset === 0 ? ['rm -f "$STAGE"'] : []),
677
+ `printf %s ${shellQuote(Buffer.from(chunk).toString("base64"))} | base64 -d >> "$STAGE"`,
678
+ 'chmod 600 "$STAGE"',
679
+ "echo __STAGED__",
680
+ ].join("\n"),
681
+ );
682
+ if (typeof output !== "string") return output;
683
+ if (!output.includes("__STAGED__")) {
684
+ return failure("unavailable", "Invalid Fly Workspace write response");
685
+ }
686
+ }
687
+ return staged;
688
+ }
689
+
562
690
  async write(
563
691
  request: WorkspaceWriteRequestV1,
564
692
  ): Promise<WorkspaceWriteOutcomeV1> {
@@ -567,10 +695,7 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
567
695
  const relative = this.path(request.path);
568
696
  if (typeof relative !== "string") return relative;
569
697
  if (request.bytes.byteLength > WORKSPACE_MAX_FILE_BYTES) {
570
- return failure(
571
- "refused",
572
- `Workspace file exceeds ${WORKSPACE_MAX_FILE_BYTES} bytes`,
573
- );
698
+ return this.tooLarge(relative);
574
699
  }
575
700
  const writtenAt = new Date();
576
701
  // The ledger mints when there is one, so the id this write records is the
@@ -595,12 +720,26 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
595
720
  writtenAt: writtenAt.toISOString(),
596
721
  };
597
722
  const mount = this.mount(request.path.root);
723
+ // Bytes past one command are staged first, outside the lock: the file
724
+ // takes several commands to carry, and holding the lock across all of them
725
+ // would keep one writer's file open for as long as its bytes take to
726
+ // arrive. The staging name is this write's own minted generation, so two
727
+ // writers of the same path never share a staging file, and the lock is
728
+ // taken only for the last command — the one that checks the generation the
729
+ // writer expected, proves the staged bytes, and renames them into place.
730
+ const staged = await this.stage(
731
+ mount,
732
+ workspaceStagingNameV1(generationId, "write"),
733
+ request.bytes,
734
+ );
735
+ if (staged !== undefined && typeof staged !== "string") return staged;
598
736
  const script = [
599
737
  "set -eu",
600
738
  `ROOT=${shellQuote(mount)}`,
601
739
  `REL=${shellQuote(relative)}`,
602
740
  'TARGET="$ROOT/$REL"',
603
741
  `META="$ROOT/${GENERATIONS_DIR}/$REL"`,
742
+ ...(staged ? [`STAGE=${shellQuote(staged)}`] : []),
604
743
  `mkdir -p "$(dirname "$TARGET")" "$(dirname "$META")" "$ROOT/${LOCKS_DIR}"`,
605
744
  'LOCK=$(printf %s "$REL" | sha256sum | cut -d" " -f1)',
606
745
  `exec 9>"$ROOT/${LOCKS_DIR}/$LOCK"`,
@@ -608,9 +747,20 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
608
747
  "CURRENT=",
609
748
  'if [ -f "$TARGET" ] && [ -f "$META" ]; then CURRENT=$(sed -n 1p "$META"); fi',
610
749
  'if [ -f "$TARGET" ] && [ ! -f "$META" ]; then CURRENT=__UNRECORDED__; fi',
611
- `if [ "$CURRENT" != ${shellQuote(request.expectedGenerationId ?? "")} ]; then echo __CONFLICT__; exit 0; fi`,
750
+ `if [ "$CURRENT" != ${shellQuote(request.expectedGenerationId ?? "")} ]; then ${staged ? 'rm -f "$STAGE"; ' : ""}echo __CONFLICT__; exit 0; fi`,
751
+ // A half-written staging file must never become a durable-root file, so
752
+ // the staged bytes answer for themselves before anything is renamed.
753
+ ...(staged
754
+ ? [
755
+ `if [ "$(sha256sum "$STAGE" | cut -d" " -f1)" != ${shellQuote(generation.contentHash)} ]; then rm -f "$STAGE"; echo __CORRUPT__; exit 0; fi`,
756
+ ]
757
+ : []),
612
758
  'TMP=$(mktemp "${TARGET}.XXXXXX")',
613
- `printf %s ${shellQuote(Buffer.from(request.bytes).toString("base64"))} | base64 -d > "$TMP"`,
759
+ ...(staged
760
+ ? ['mv "$STAGE" "$TMP"']
761
+ : [
762
+ `printf %s ${shellQuote(Buffer.from(request.bytes).toString("base64"))} | base64 -d > "$TMP"`,
763
+ ]),
614
764
  'chmod 600 "$TMP"',
615
765
  'mv "$TMP" "$TARGET"',
616
766
  'MTMP=$(mktemp "${META}.XXXXXX")',
@@ -627,6 +777,12 @@ export class FlyWorkspaceFiles implements WorkspaceFilesV1 {
627
777
  `Workspace file changed since the writer last saw it: ${relative}`,
628
778
  );
629
779
  }
780
+ if (output.includes("__CORRUPT__")) {
781
+ return failure(
782
+ "unavailable",
783
+ `"${relative}" did not reach the Computer intact`,
784
+ );
785
+ }
630
786
  if (!output.includes("__WRITTEN__")) {
631
787
  return failure("unavailable", "Invalid Fly Workspace write response");
632
788
  }