functionalscript 0.35.1 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/fs/base64/module.f.js +6 -5
  2. package/fs/base64/proof.f.d.ts +1 -0
  3. package/fs/base64/proof.f.js +10 -1
  4. package/fs/cas/mcp/module.f.d.ts +8 -4
  5. package/fs/cas/mcp/module.f.js +33 -21
  6. package/fs/cas/mcp/proof.f.d.ts +0 -7
  7. package/fs/cas/mcp/proof.f.js +47 -139
  8. package/fs/cas/module.f.js +3 -10
  9. package/fs/ci/config/module.f.d.ts +7 -7
  10. package/fs/ci/config/module.f.js +8 -8
  11. package/fs/crypto/sha2/module.f.js +20 -19
  12. package/fs/crypto/sha2/proof.f.d.ts +1 -0
  13. package/fs/crypto/sha2/proof.f.js +18 -0
  14. package/fs/djs/parser/proof.f.js +12 -0
  15. package/fs/effects/module.f.d.ts +15 -0
  16. package/fs/effects/module.f.js +14 -0
  17. package/fs/effects/node/module.f.js +5 -15
  18. package/fs/effects/node/module.js +3 -1
  19. package/fs/effects/node/proof.f.d.ts +3 -0
  20. package/fs/effects/node/proof.f.js +19 -1
  21. package/fs/effects/node/virtual/module.f.js +1 -2
  22. package/fs/effects/proof.f.d.ts +5 -0
  23. package/fs/effects/proof.f.js +22 -1
  24. package/fs/fjs/proof.f.d.ts +1 -0
  25. package/fs/fjs/proof.f.js +6 -0
  26. package/fs/fsc/module.f.d.ts +1 -0
  27. package/fs/fsc/module.f.js +5 -1
  28. package/fs/js/tokenizer/module.f.d.ts +1 -1
  29. package/fs/js/tokenizer/module.f.js +7 -1
  30. package/fs/js/tokenizer/proof.f.js +59 -0
  31. package/fs/json/parser/proof.f.js +18 -0
  32. package/fs/path/module.f.d.ts +6 -0
  33. package/fs/path/module.f.js +6 -0
  34. package/fs/path/proof.f.d.ts +1 -0
  35. package/fs/path/proof.f.js +40 -2
  36. package/fs/text/utf8/module.f.d.ts +6 -0
  37. package/fs/text/utf8/module.f.js +15 -2
  38. package/fs/text/utf8/proof.f.js +53 -0
  39. package/fs/types/result/module.f.d.ts +4 -0
  40. package/fs/types/result/module.f.js +4 -0
  41. package/fs/types/result/proof.f.d.ts +1 -0
  42. package/fs/types/result/proof.f.js +12 -2
  43. package/package.json +1 -1
@@ -6,7 +6,7 @@
6
6
  import { msb, length, vec, empty } from "../types/bit_vec/module.f.js";
7
7
  import { baseN } from "../base_n/module.f.js";
8
8
  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
9
- const { popFront, concat } = msb;
9
+ const { popFront } = msb;
10
10
  const { vecToString, stringToVec } = baseN(6n, alphabet);
11
11
  export const encode = (input) => {
12
12
  const len = length(input);
@@ -14,10 +14,11 @@ export const encode = (input) => {
14
14
  if (len % 8n !== 0n) {
15
15
  return null;
16
16
  }
17
- const rem = len % 24n;
18
- const padBits = rem === 0n ? 0n : 6n - rem % 6n;
19
- const v = padBits > 0n ? concat(input)(vec(padBits)(0n)) : input;
20
- let result = vecToString(v);
17
+ // `vecToString` (via `baseN`'s `chunkList`) already left-pads a trailing
18
+ // partial 6-bit chunk with zeros, so `input` needs no explicit padding
19
+ // building one would risk pushing an intermediate `Vec` past `maxLength`
20
+ // for input already at or near that limit, for no benefit.
21
+ let result = vecToString(input);
21
22
  // Append `=` padding to make total length a multiple of 4.
22
23
  while (result.length % 4 !== 0) {
23
24
  result += '=';
@@ -9,5 +9,6 @@ export declare const proof: {
9
9
  validPadding: () => void;
10
10
  knownVectors: () => void;
11
11
  decodeOverflow: () => void;
12
+ encodeAtMaxLengthSucceeds: () => void;
12
13
  encodeLargeVecIsSlow: () => void;
13
14
  };
@@ -1,5 +1,5 @@
1
1
  import { assertEq } from "../asserts/module.f.js";
2
- import { empty, vec, repeat, vec8 } from "../types/bit_vec/module.f.js";
2
+ import { empty, vec, repeat, vec8, maxLength } from "../types/bit_vec/module.f.js";
3
3
  import { encode, decode } from "./module.f.js";
4
4
  const check = (s, v) => {
5
5
  assertEq(encode(v), s);
@@ -83,6 +83,15 @@ export const proof = {
83
83
  const oversized = 'A'.repeat(174_764);
84
84
  assertEq(decode(oversized), null);
85
85
  },
86
+ encodeAtMaxLengthSucceeds: () => {
87
+ // A `maxLength`-sized vector's trailing partial 6-bit chunk is
88
+ // left-padded by `vecToString` itself (see `baseN`), so `encode`
89
+ // never needs to build an over-`maxLength` intermediate and must not
90
+ // reject this boundary input. (`decode` of the resulting string
91
+ // still fails on this exact boundary — a separate, still-open bug;
92
+ // see `fs/base64/todo/decode-rejects-max-size-input.md`.)
93
+ assertEq(encode(vec(maxLength)(0n)), 'A'.repeat(174_763) + '=');
94
+ },
86
95
  // Regression guard: `encode` used to be quadratic in the input size, not
87
96
  // linear. `baseN`'s `vecToString` (`fs/base_n/module.f.ts`) popped one
88
97
  // 6-bit chunk off the front at a time via `popFront`, and each `popFront`
@@ -1,12 +1,12 @@
1
1
  import { type Effect } from '../../effects/module.f.ts';
2
2
  import { type MemOp } from '../../effects/memory/module.f.ts';
3
- import { type Read, type Rm, type Write } from '../../effects/node/module.f.ts';
3
+ import { type Read, type Write } from '../../effects/node/module.f.ts';
4
4
  import { type McpConfig, type McpHandlers } from '../../mcp/module.f.ts';
5
5
  import { type FileCasOperation } from '../module.f.ts';
6
6
  /** Arguments for `cas_add`: content to store, with optional encoding type. */
7
7
  export declare const casAddArgs: {
8
8
  readonly content: import("../../types/rtti/module.f.ts").String;
9
- readonly type: import("../../types/rtti/module.f.ts").Or<["text", "base64", "url", undefined]>;
9
+ readonly type: import("../../types/rtti/module.f.ts").Or<["text", "base64", undefined]>;
10
10
  };
11
11
  /** Arguments for `cas_get`: the cBase32 hash to look up; optionally request inline content. */
12
12
  export declare const casGetArgs: {
@@ -18,7 +18,7 @@ export declare const casListArgs: {};
18
18
  /**
19
19
  * MCP handlers for `FileCas`.
20
20
  */
21
- export declare const casMcpHandlers: (home: string) => McpHandlers<FileCasOperation | Rm>;
21
+ export declare const casMcpHandlers: (home: string) => McpHandlers<FileCasOperation>;
22
22
  /**
23
23
  * Static MCP configuration for the CAS server: advertises the `tools`
24
24
  * capability, identifies the server, and pins the protocol version.
@@ -29,4 +29,8 @@ export declare const casConfig: McpConfig;
29
29
  * the `mcpStep` for `c`, and drives the read → parse → dispatch → write loop
30
30
  * until stdin EOF.
31
31
  */
32
- export declare const casMcpServer: (home: string) => Effect<Read | Write | MemOp | FileCasOperation | Rm, void>;
32
+ export declare const casMcpServer: (home: string) => Effect<Read | Write | MemOp | FileCasOperation, void>;
33
+ export declare const proof: {
34
+ casMcpServer: () => void;
35
+ collectReadOverflow: () => void;
36
+ };
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * | Tool | args | action | result |
13
13
  * |------------|-------------------------------|------------------|-------------------------------------|
14
- * | `cas_add` | `{ content, type? }` | write/upload | hash (cBase32) |
14
+ * | `cas_add` | `{ content, type? }` | `c.write(...)` | hash (cBase32) |
15
15
  * | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mime_type,type[,content]}` |
16
16
  * | `cas_list` | `{}` | `c.list()` | hashes, one per line |
17
17
  *
@@ -23,9 +23,11 @@
23
23
  * without any encoding step.
24
24
  * - `'base64'`: `content` is RFC 4648 base64, decoded to bytes before storage.
25
25
  * Use this for pre-encoded binary payloads.
26
- * - `'url'`: `content` is a filesystem path within `$HOME/cas_upload/`; the server
27
- * moves the file via the streaming move-hash-move pipeline (no size limit).
28
- * Paths outside `$HOME/cas_upload/` or containing `..` are rejected for security.
26
+ *
27
+ * Inline content (either encoding) is capped at 128 KiB (`maxLength`). There is
28
+ * no MCP route for larger content the server never opens a local path named by
29
+ * the client (see the invariant in `fs/cas/mcp/README.md`); large files go
30
+ * through the `cas` CLI (`cas add <path>`) instead, run directly by the user.
29
31
  *
30
32
  * ## `cas_get` output
31
33
  *
@@ -80,18 +82,18 @@
80
82
  */
81
83
  import { string, option, or, boolean } from "../../types/rtti/module.f.js";
82
84
  import { stringify } from "../../json/module.f.js";
83
- import { pure } from "../../effects/module.f.js";
85
+ import { pure, decode } from "../../effects/module.f.js";
84
86
  import { create } from "../../effects/memory/module.f.js";
85
87
  import { cBase32ToVec, vecToCBase32 } from "../../cbase32/module.f.js";
86
88
  import { decode as base64Decode, encode as base64Encode } from "../../base64/module.f.js";
87
89
  import { tryUtf8 } from "../../text/module.f.js";
88
90
  import { detectStream } from "../../mime/module.f.js";
89
- import { empty, length as bitVecLength, maxLength, maxLengthBytes, msb } from "../../types/bit_vec/module.f.js";
91
+ import { empty, length as bitVecLength, maxLength, maxLengthBytes, msb, vec } from "../../types/bit_vec/module.f.js";
90
92
  import { ok, error } from "../../types/result/module.f.js";
91
- import { rm } from "../../effects/node/module.f.js";
93
+ import {} from "../../effects/node/module.f.js";
92
94
  import { stdioTransport } from "../../mcp/stdio/module.f.js";
93
95
  import { mcpStep, uninitializedState, toolEntry, fromRegistry, errorResult, } from "../../mcp/module.f.js";
94
- import { casAddFile, fileCas } from "../module.f.js";
96
+ import { fileCas } from "../module.f.js";
95
97
  import { fromVec } from "../../text/utf8/module.f.js";
96
98
  import { identity } from "../../types/function/module.f.js";
97
99
  import { sha256 } from "../../crypto/sha2/module.f.js";
@@ -100,7 +102,7 @@ import { nonEmpty, empty as elEmpty } from "../../effects/list/module.f.js";
100
102
  /** Arguments for `cas_add`: content to store, with optional encoding type. */
101
103
  export const casAddArgs = {
102
104
  content: string,
103
- type: or('text', 'base64', 'url', undefined)
105
+ type: or('text', 'base64', undefined)
104
106
  };
105
107
  /** Arguments for `cas_get`: the cBase32 hash to look up; optionally request inline content. */
106
108
  export const casGetArgs = {
@@ -142,24 +144,14 @@ const toJson = stringify(identity);
142
144
  /** Registry of all CAS tools. */
143
145
  const casToolRegistry = (home) => {
144
146
  const c = fileCas(sha256)(home);
145
- const casUploadDir = `${home}/cas_upload`;
146
147
  return [
147
- toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; omit or pass type:"text" for UTF-8 text (default). Inline content (text/base64) is capped at 128 KiB (131072 bytes) — larger content is rejected. For larger content use type:"url" to stream a file from $HOME/cas_upload/ (no size limit).', casAddArgs, ({ type, content }) => {
148
- // type:'url' — stream the file into cas, then delete the source on success
149
- if (type === 'url') {
150
- if (!content.startsWith(`${casUploadDir}/`) || content.includes('..')) {
151
- return pure(errorResult(`cas_add type:url paths must be within ${casUploadDir}/ — got: ${content}`));
152
- }
153
- return casAddFile(c)(content).step(([t, v]) => t === 'error'
154
- ? pure(errorResult(`upload failed: ${v}`))
155
- : rm(content).step(() => pure(okResult(vecToCBase32(v)))));
156
- }
148
+ toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; omit or pass type:"text" for UTF-8 text (default). Inline content is capped at 128 KiB (131072 bytes) — larger content is rejected. For larger content, store the file with the `cas` CLI instead: run `npx functionalscript cas add <path>` yourself if you have shell access, or give the user that exact command to run it prints the resulting hash on stdout.', casAddArgs, ({ type, content }) => {
157
149
  // type:'text' or 'base64' — resolve content to Vec, store via c.write()
158
150
  let x = type === 'base64'
159
151
  ? base64Decode(content)
160
152
  : tryUtf8(content);
161
153
  return x === null
162
- ? pure(errorResult('too large or malformed — use type:"url" for large content'))
154
+ ? pure(errorResult('too large or malformed — for large content, run `npx functionalscript cas add <path>` (or have the user run it) instead'))
163
155
  // The resolved content fits in one chunk; feed it as a single-item stream.
164
156
  : c.write(nonEmpty(ok(x), elEmpty())).step(([tag, hash]) => pure(tag === 'error'
165
157
  ? errorResult('write')
@@ -235,3 +227,23 @@ export const casConfig = {
235
227
  * until stdin EOF.
236
228
  */
237
229
  export const casMcpServer = (home) => create(uninitializedState).step(key => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home))(key)));
230
+ // ── Tests ────────────────────────────────────────────────────────────────────
231
+ export const proof = {
232
+ // casMcpServer is never called in integration tests because it drives a
233
+ // real stdio server; call it here to cover its Effect-building body.
234
+ casMcpServer: () => { casMcpServer('/'); },
235
+ // The overflow guard in collectRead (lines 125-126) is only reached when
236
+ // the running total of two stream chunks would exceed maxLength. Feed a
237
+ // pure stream whose second chunk pushes it just over the limit so the
238
+ // error branch executes without any real I/O.
239
+ collectReadOverflow: () => {
240
+ const half = maxLength / 2n;
241
+ const v1 = vec(half)(0n);
242
+ const v2 = vec(half + 1n)(0n);
243
+ const stream = nonEmpty(ok(v1), nonEmpty(ok(v2), elEmpty()));
244
+ const d = decode(collectRead(stream));
245
+ if (!d.done || d.result[0] !== 'error') {
246
+ throw 'expected overflow error';
247
+ }
248
+ },
249
+ };
@@ -30,18 +30,11 @@ export declare const proof: {
30
30
  getMissingHashIsError: () => void;
31
31
  unknownToolIsError: () => void;
32
32
  toolErrorIsNotJsonRpcError: () => void;
33
- addUrlStoresFileAndReturnsHash: () => void;
34
- addUrlRoundTrips: () => void;
35
- addUrlMissingFileIsError: () => void;
36
33
  getMetaReturnsLengthAndMimeType: () => void;
37
34
  getMetaBinaryBlob: () => void;
38
35
  getMetaOctetStreamForUnknownBinary: () => void;
39
36
  getMetaOctetStreamForNulBlob: () => void;
40
37
  getMetaMissingHashIsError: () => void;
41
38
  getMetaInvalidHashIsError: () => void;
42
- addUrlFromSubdirectorySucceeds: () => void;
43
- addUrlFromApprovedDirectorySucceeds: () => void;
44
- addUrlFromRandomDirectoryIsRejected: () => void;
45
- addUrlWithPathTraversalIsRejected: () => void;
46
39
  getOctetStreamWithContentIncludesBase64: () => void;
47
40
  };
@@ -6,7 +6,9 @@ import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from "../../ty
6
6
  import { vecToCBase32 } from "../../cbase32/module.f.js";
7
7
  import { encode as base64Encode } from "../../base64/module.f.js";
8
8
  import { utf8 } from "../../text/module.f.js";
9
- import {} from "../module.f.js";
9
+ import { fileCas } from "../module.f.js";
10
+ import { sha256 } from "../../crypto/sha2/module.f.js";
11
+ import { nonEmpty, empty as elEmpty } from "../../effects/list/module.f.js";
10
12
  import { mcpStep, uninitializedState, } from "../../mcp/module.f.js";
11
13
  import { emptyState, virtual } from "../../effects/node/virtual/module.f.js";
12
14
  import { casConfig, casMcpHandlers } from "./module.f.js";
@@ -59,6 +61,19 @@ const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
59
61
  });
60
62
  return virtual({ ...emptyState, root })(effect)[1];
61
63
  };
64
+ // Seeds a blob directly into the virtual store via `c.write`, bypassing the
65
+ // MCP `cas_add` tool entirely — used by tests that need a stored blob larger
66
+ // than `cas_add`'s inline limit, now that the removed `type:'url'` path is no
67
+ // longer available to stage one through MCP. Returns the resulting root
68
+ // `Dir` (with the new shard written) and the blob's cBase32 hash so a
69
+ // subsequent `runSessionVirtual` call can read it back via `cas_get`.
70
+ const seedBlob = (root, home = '/home/user') => (chunks) => {
71
+ const c = fileCas(sha256)(home);
72
+ const stream = chunks.reduceRight((tail, chunk) => nonEmpty(resultOk(chunk), tail), elEmpty());
73
+ const [state, result] = virtual({ ...emptyState, root })(c.write(stream));
74
+ assert(result[0] === 'ok', result);
75
+ return [state.root, vecToCBase32(result[1])];
76
+ };
62
77
  // ── Messages ────────────────────────────────────────────────────────────────────
63
78
  const init = { jsonrpc: '2.0', method: 'initialize', id: 1,
64
79
  params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'c', version: '0' } } };
@@ -95,8 +110,9 @@ const binarySample = base64Encode(vec8(0x2an));
95
110
  // `cas_get` detects its type and returns base64 with mime_type image/png.
96
111
  const pngSample = base64Encode(u8ListToVec(msb)([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]));
97
112
  // Returns the RFC 4648 base64 encoding of `n` zero bytes, computed directly
98
- // without bigint arithmetic — safe near maxLengthBytes where base64Encode on a
99
- // padded Vec would trigger the encode-padding-overflow bug. Handles every n%3
113
+ // without bigint arithmetic — independent of `base64.encode` so these tests
114
+ // don't rely on the correctness of the function they're indirectly exercising
115
+ // (`cas_add` decodes the string via `base64.decode`). Handles every n%3
100
116
  // residue: rem=0 → no padding, rem=1 → 'AA==', rem=2 → 'AAA='.
101
117
  const base64OfA = (n) => {
102
118
  const groups = Number(n / 3n);
@@ -117,17 +133,10 @@ const base64OfA = (n) => {
117
133
  // exactly `maxLengthBytes` (the largest single `Vec` the runtime allows), so the
118
134
  // two-chunk blob spans two read chunks without any one `Vec` exceeding the cap.
119
135
  const largeMultiChunkBlobMeta = (chunk0, chunk1, expectedType, expectedMime) => () => {
120
- const root = { 'home': { 'user': { 'cas_upload': { 'big': [chunk0, chunk1] } } } };
121
- const [addResp] = runSessionVirtual(root)([
122
- init, initialized,
123
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
124
- ]).slice(2);
125
- assert(!resultOf(addResp).isError);
126
- const hash = textOf(addResp);
127
- const [, metaResp] = runSessionVirtual(root)([
136
+ const [root, hash] = seedBlob({})([chunk0, chunk1]);
137
+ const [metaResp] = runSessionVirtual(root)([
128
138
  init, initialized,
129
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
130
- call(3, 'cas_get', { hash }),
139
+ call(2, 'cas_get', { hash }),
131
140
  ]).slice(2);
132
141
  assert(!resultOf(metaResp).isError);
133
142
  const meta = JSON.parse(textOf(metaResp));
@@ -145,8 +154,7 @@ const binaryChunk = repeat(maxLengthBytes)(vec8(0xffn));
145
154
  // 100,000 raw bytes of 0xFF: comfortably under `maxLengthBytes` (so `cas_get`'s
146
155
  // own `length > maxLengthBytes` guard does not fire), but base64 inflates it to
147
156
  // ceil(100_000/3)*4 = 133,336 chars — already past `maxLength` (131,072 bytes)
148
- // before the JSON-RPC envelope adds anything. Small enough to stay clear of the
149
- // separate `base64Encode`-near-`maxLength` padding bug (see `base64OfA` above).
157
+ // before the JSON-RPC envelope adds anything.
150
158
  const oversizedBase64Chunk = repeat(100000n)(vec8(0xffn));
151
159
  // 90,000 raw bytes: base64 inflates to 120,000 chars, leaving ~11 KiB of margin
152
160
  // under `maxLength` once the envelope is added — the paired "still succeeds" case.
@@ -170,16 +178,10 @@ export const proof = {
170
178
  // "too large" error, not "no such hash" — the blob exists, it just can't be
171
179
  // buffered inline. The metadata-only path (above) still returns its size/type.
172
180
  getContentLargeBlobTooLargeError: () => {
173
- const root = { 'home': { 'user': { 'cas_upload': { 'big': [asciiChunk, asciiChunk] } } } };
174
- const [addResp] = runSessionVirtual(root)([
175
- init, initialized,
176
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
177
- ]).slice(2);
178
- const hash = textOf(addResp);
179
- const [, getResp] = runSessionVirtual(root)([
181
+ const [root, hash] = seedBlob({})([asciiChunk, asciiChunk]);
182
+ const [getResp] = runSessionVirtual(root)([
180
183
  init, initialized,
181
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
182
- call(3, 'cas_get', { hash, content: true }),
184
+ call(2, 'cas_get', { hash, content: true }),
183
185
  ]).slice(2);
184
186
  assertEq(resultOf(getResp).isError, true);
185
187
  const text = textOf(getResp);
@@ -208,31 +210,21 @@ export const proof = {
208
210
  // `fs/base64/proof.f.ts` `encodeLargeVecIsSlow`). Now well under budget on
209
211
  // both engines.
210
212
  getContentBase64InflationOverflowWritesInternalError: () => {
211
- const root = { 'home': { 'user': { 'cas_upload': { 'big': [oversizedBase64Chunk] } } } };
212
- const [addResp] = runStdio(root)([
213
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
214
- ]);
215
- const hash = textOf(addResp);
216
- const [, getResp] = runStdio(root)([
217
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
218
- call(3, 'cas_get', { hash, content: true }),
213
+ const [root, hash] = seedBlob({})([oversizedBase64Chunk]);
214
+ const [getResp] = runStdio(root)([
215
+ call(2, 'cas_get', { hash, content: true }),
219
216
  ]);
220
217
  const err = getResp;
221
218
  assertEq(err.error?.code, -32603);
222
- assertEq(err.id, 3);
219
+ assertEq(err.id, 2);
223
220
  },
224
221
  // The paired boundary case: a blob whose base64 inflation leaves enough
225
222
  // margin under `maxLength` for the envelope — confirms the fallback above
226
223
  // only triggers on genuine overflow, not on every `content:true` binary read.
227
224
  getContentBase64NearBoundarySucceeds: () => {
228
- const root = { 'home': { 'user': { 'cas_upload': { 'big': [boundaryBase64Chunk] } } } };
229
- const [addResp] = runStdio(root)([
230
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
231
- ]);
232
- const hash = textOf(addResp);
233
- const [, getResp] = runStdio(root)([
234
- call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
235
- call(3, 'cas_get', { hash, content: true }),
225
+ const [root, hash] = seedBlob({})([boundaryBase64Chunk]);
226
+ const [getResp] = runStdio(root)([
227
+ call(2, 'cas_get', { hash, content: true }),
236
228
  ]);
237
229
  assert(!resultOf(getResp).isError);
238
230
  const result = JSON.parse(textOf(getResp));
@@ -243,25 +235,21 @@ export const proof = {
243
235
  // (33,000 bytes) well under `maxLengthBytes`, but each quote costs 2 bytes on
244
236
  // the inner CAS-JSON escape and 4 bytes once the outer JSON-RPC envelope
245
237
  // re-escapes that backslash-quote pair — no `cas_get`-level size guard could
246
- // see this coming, only the transport's real encode attempt can. Uploaded via
247
- // `type: 'url'` (a file, not an inlined JSON string) so the test exercises
248
- // `cas_get`'s response-side escaping without also paying for parsing a huge
249
- // inline `content` argument on the request side. Confirms `writeResponse`'s
238
+ // see this coming, only the transport's real encode attempt can. Seeded
239
+ // directly into the store via `seedBlob` (not an inlined `cas_add` JSON
240
+ // string) so the test exercises `cas_get`'s response-side escaping without
241
+ // also paying for parsing a huge inline `content` argument on the request
242
+ // side. Confirms `writeResponse`'s
250
243
  // `tryUtf8` fallback (not a crash) handles double-escaping overflow, again
251
244
  // preserving the request's `id`.
252
245
  getContentDoubleEscapedOverflowWritesInternalError: () => {
253
- const root = { 'home': { 'user': { 'cas_upload': { 'q': [quotesChunk] } } } };
254
- const [addResp] = runStdio(root)([
255
- call(2, 'cas_add', { content: '/home/user/cas_upload/q', type: 'url' }),
256
- ]);
257
- const hash = textOf(addResp);
258
- const [, getResp] = runStdio(root)([
259
- call(2, 'cas_add', { content: '/home/user/cas_upload/q', type: 'url' }),
260
- call(3, 'cas_get', { hash, content: true }),
246
+ const [root, hash] = seedBlob({})([quotesChunk]);
247
+ const [getResp] = runStdio(root)([
248
+ call(2, 'cas_get', { hash, content: true }),
261
249
  ]);
262
250
  const err = getResp;
263
251
  assertEq(err.error?.code, -32603);
264
- assertEq(err.id, 3);
252
+ assertEq(err.id, 2);
265
253
  },
266
254
  toolsListAdvertisesThreeTools: () => {
267
255
  const [resp] = runSessionVirtual({})([init, initialized, list(2)]).slice(2);
@@ -370,6 +358,7 @@ export const proof = {
370
358
  const [resp] = session(call(2, 'cas_add', { content: 'A'.repeat(174_764), type: 'base64' }));
371
359
  assertEq(resultOf(resp).isError, true);
372
360
  assert(textOf(resp).includes('too large or malformed'));
361
+ assert(textOf(resp).includes('cas add'));
373
362
  },
374
363
  // One ASCII byte past maxLengthBytes — tryUtf8 returns null and cas_add
375
364
  // surfaces an error.
@@ -377,6 +366,7 @@ export const proof = {
377
366
  const [resp] = session(call(2, 'cas_add', { content: 'a'.repeat(Number(maxLengthBytes) + 1) }));
378
367
  assertEq(resultOf(resp).isError, true);
379
368
  assert(textOf(resp).includes('too large or malformed'));
369
+ assert(textOf(resp).includes('cas add'));
380
370
  },
381
371
  // Exactly maxLengthBytes (131,072) ASCII bytes — tryUtf8 returns a non-null
382
372
  // Vec (len === maxLength, not over), so cas_add stores cleanly.
@@ -394,6 +384,7 @@ export const proof = {
394
384
  const [resp] = session(call(2, 'cas_add', { content: base64OfA(maxLengthBytes), type: 'base64' }));
395
385
  assertEq(resultOf(resp).isError, true);
396
386
  assert(textOf(resp).includes('too large or malformed'));
387
+ assert(textOf(resp).includes('cas add'));
397
388
  },
398
389
  getUnterminatedHashIsError: () => {
399
390
  const [resp] = session(call(2, 'cas_get', { hash: '0' }));
@@ -425,60 +416,11 @@ export const proof = {
425
416
  assert(!('error' in resp));
426
417
  assert('result' in resp);
427
418
  },
428
- // cas_add with type:'url' streams a file from /home/user/cas_upload/ into CAS.
429
- addUrlStoresFileAndReturnsHash: () => {
430
- const fileContent = utf8('hello from file');
431
- const root = { 'home': { 'user': { 'cas_upload': { 'hello.txt': [fileContent] } } } };
432
- const [addUrlResp] = runSessionVirtual(root)([
433
- init, initialized,
434
- call(2, 'cas_add', { content: '/home/user/cas_upload/hello.txt', type: 'url' }),
435
- ]).slice(2);
436
- assert(!resultOf(addUrlResp).isError);
437
- assert(textOf(addUrlResp).length > 0);
438
- },
439
- addUrlRoundTrips: () => {
440
- const fileContent = utf8('round-trip content');
441
- const root = { 'home': { 'user': { 'cas_upload': { 'rt.txt': [fileContent] } } } };
442
- // First pass: add to get the hash (deterministic for same content).
443
- const [addResp] = runSessionVirtual(root)([
444
- init, initialized,
445
- call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
446
- ]).slice(2);
447
- const hash = textOf(addResp);
448
- // Second pass: add again + get in one session (file re-present in fresh virtual state).
449
- const [, getResp] = runSessionVirtual(root)([
450
- init, initialized,
451
- call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
452
- call(3, 'cas_get', { hash, content: true }),
453
- ]).slice(2);
454
- assert(!resultOf(getResp).isError);
455
- const result = JSON.parse(textOf(getResp));
456
- assertEq(result.type, 'text');
457
- assertEq(result.content, 'round-trip content');
458
- },
459
- addUrlMissingFileIsError: () => {
460
- const [resp] = runSessionVirtual({})([
461
- init, initialized,
462
- call(2, 'cas_add', { content: '/home/user/cas_upload/nonexistent.txt', type: 'url' }),
463
- ]).slice(2);
464
- assertEq(resultOf(resp).isError, true);
465
- },
466
419
  // cas_get without content:true returns only metadata.
467
420
  getMetaReturnsLengthAndMimeType: () => {
468
- const fileContent = utf8('text content');
469
- const root = { 'home': { 'user': { 'cas_upload': { 'f': [fileContent] } } } };
470
- // First pass: add to get the hash.
471
- const [addResp] = runSessionVirtual(root)([
472
- init, initialized,
473
- call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
474
- ]).slice(2);
421
+ const [addResp] = session(call(2, 'cas_add', { content: 'text content' }));
475
422
  const hash = textOf(addResp);
476
- // Second pass: add + get metadata.
477
- const [, metaResp] = runSessionVirtual(root)([
478
- init, initialized,
479
- call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
480
- call(3, 'cas_get', { hash }),
481
- ]).slice(2);
423
+ const [, metaResp] = session(call(2, 'cas_add', { content: 'text content' }), call(3, 'cas_get', { hash }));
482
424
  assert(!resultOf(metaResp).isError);
483
425
  const meta = JSON.parse(textOf(metaResp));
484
426
  assertEq(meta.mime_type, 'text/plain');
@@ -531,40 +473,6 @@ export const proof = {
531
473
  const [resp] = session(call(2, 'cas_get', { hash: 'bad!' }));
532
474
  assertEq(resultOf(resp).isError, true);
533
475
  },
534
- // cas_add type:'url' with a subdirectory path flattens slashes to '-' in staging.
535
- addUrlFromSubdirectorySucceeds: () => {
536
- const fileContent = utf8('nested file content');
537
- const root = { 'home': { 'user': { 'cas_upload': { 'subdir': { 'file.txt': [fileContent] } } } } };
538
- const [resp] = runSessionVirtual(root)([
539
- init, initialized,
540
- call(2, 'cas_add', { content: '/home/user/cas_upload/subdir/file.txt', type: 'url' }),
541
- ]).slice(2);
542
- assert(!resultOf(resp).isError);
543
- assert(textOf(resp).length > 0);
544
- },
545
- // cas_add with type:'url' accepts paths within /home/user/cas_upload/
546
- addUrlFromApprovedDirectorySucceeds: () => {
547
- const fileContent = utf8('approved file');
548
- const root = { 'home': { 'user': { 'cas_upload': { 'test.txt': [fileContent] } } } };
549
- const [resp] = runSessionVirtual(root)([
550
- init, initialized,
551
- call(2, 'cas_add', { content: '/home/user/cas_upload/test.txt', type: 'url' }),
552
- ]).slice(2);
553
- assert(!resultOf(resp).isError);
554
- assert(textOf(resp).length > 0);
555
- },
556
- // cas_add with type:'url' rejects paths outside /home/user/cas_upload/
557
- addUrlFromRandomDirectoryIsRejected: () => {
558
- const [resp] = session(call(2, 'cas_add', { content: '/tmp/secret.txt', type: 'url' }));
559
- assert(resultOf(resp).isError === true);
560
- assert(textOf(resp).includes('/home/user/cas_upload/'));
561
- },
562
- // cas_add with type:'url' rejects path traversal attempts with ..
563
- addUrlWithPathTraversalIsRejected: () => {
564
- const [resp] = session(call(2, 'cas_add', { content: '/home/user/cas_upload/../../etc/passwd', type: 'url' }));
565
- assert(resultOf(resp).isError === true);
566
- assert(textOf(resp).includes('/home/user/cas_upload/'));
567
- },
568
476
  // cas_get with content:true on octet-stream (no magic bytes, not UTF-8) returns inline base64.
569
477
  getOctetStreamWithContentIncludesBase64: () => {
570
478
  const binaryContent = u8ListToVec(msb)([0xFF, 0xFE, 0x00, 0x01]);
@@ -7,7 +7,7 @@ import { sha256 } from "../crypto/sha2/module.f.js";
7
7
  import { join, normalize, parse } from "../path/module.f.js";
8
8
  import { empty, length, maxLengthBytes, msb, vec } from "../types/bit_vec/module.f.js";
9
9
  import { cBase32ToVec, vecToCBase32 } from "../cbase32/module.f.js";
10
- import { foldStep, forEachStep, pure } from "../effects/module.f.js";
10
+ import { foldStep, forEachStep, okStep, pure } from "../effects/module.f.js";
11
11
  import { access, createExclusive, isNotFound, mkdir, now, randomInt, readBytes, readdir, rename, rm, stat, writeBytes, } from "../effects/node/module.f.js";
12
12
  import { toOption } from "../types/nullable/module.f.js";
13
13
  import { error, ok, unwrap } from "../types/result/module.f.js";
@@ -141,9 +141,7 @@ export const fileCas = (sha2) => (path) => {
141
141
  .step(t0 => {
142
142
  const path0 = join(stageDir, stageName(t0 + leaseDelta, rndStr));
143
143
  return createExclusive(path0)
144
- .step(([c, e]) => c === 'error'
145
- ? pure(error(e))
146
- : loop(sha2.init, 0, path0)(payload));
144
+ .step(okStep(() => loop(sha2.init, 0, path0)(payload)));
147
145
  }));
148
146
  }));
149
147
  },
@@ -200,10 +198,5 @@ cas.write(streamFile(path));
200
198
  export const casUpload = (home) => (fileName) => {
201
199
  const src = join(home, 'cas_upload', fileName);
202
200
  const c = fileCas(sha256)(home);
203
- return casAddFile(c)(src).step((result) => {
204
- if (result[0] === 'error') {
205
- return pure(result);
206
- }
207
- return rm(src).step(() => pure(result));
208
- });
201
+ return casAddFile(c)(src).step(okStep(v => rm(src).step(() => pure(ok(v)))));
209
202
  };
@@ -19,9 +19,9 @@ export declare const images: {
19
19
  readonly arm: "windows-11-arm";
20
20
  };
21
21
  };
22
- export declare const functionalscript: "0.33.0";
22
+ export declare const functionalscript: "0.35.2";
23
23
  export declare const bun = "1.3.14";
24
- export declare const deno = "2.9.0";
24
+ export declare const deno = "2.9.1";
25
25
  export declare const playwright = "1.61.1";
26
26
  export declare const node: {
27
27
  readonly default: "26.4.0";
@@ -29,15 +29,15 @@ export declare const node: {
29
29
  readonly node24: "24.18.0";
30
30
  };
31
31
  export declare const wasmtime = "46.0.1";
32
- export declare const wasmer = "7.1.0";
33
- export declare const tsgo = "7.0.0-dev.20260627.2";
32
+ export declare const wasmer = "7.2.0";
33
+ export declare const tsgo = "7.0.0-dev.20260707.2";
34
34
  export declare const actions: {
35
35
  readonly 'actions/checkout': "v7";
36
36
  readonly 'actions/setup-node': "v6.4.0";
37
37
  readonly 'actions/cache': "v6.1.0";
38
- readonly 'denoland/setup-deno': "v2.0.4";
38
+ readonly 'denoland/setup-deno': "v2.0.5";
39
39
  readonly 'oven-sh/setup-bun': "v2.2.0";
40
- readonly 'bytecodealliance/actions/wasmtime/setup': "v1";
40
+ readonly 'bytecodealliance/actions/wasmtime/setup': "v1.1.3";
41
41
  readonly 'wasmerio/setup-wasmer': "v3.1";
42
- readonly 'dtolnay/rust-toolchain': "1.96.0";
42
+ readonly 'dtolnay/rust-toolchain': "1.96.1";
43
43
  };
@@ -22,13 +22,13 @@ export const images = {
22
22
  };
23
23
  // Bootstrap package version used by generated smoke tests. Keep this on a
24
24
  // published FunctionalScript release; do not tie it to package.json's current
25
- // in-repo version. A separate maintenance job advances this pin.
25
+ // in-repo version.
26
26
  // https://www.npmjs.com/package/functionalscript
27
- export const functionalscript = '0.33.0';
27
+ export const functionalscript = '0.35.2';
28
28
  // https://bun.sh/
29
29
  export const bun = '1.3.14';
30
30
  // https://deno.com/
31
- export const deno = '2.9.0';
31
+ export const deno = '2.9.1';
32
32
  // https://www.npmjs.com/package/playwright
33
33
  export const playwright = '1.61.1';
34
34
  // https://nodejs.org/en/download
@@ -40,9 +40,9 @@ export const node = {
40
40
  // https://github.com/bytecodealliance/wasmtime/releases
41
41
  export const wasmtime = '46.0.1';
42
42
  // https://github.com/wasmerio/wasmer/releases
43
- export const wasmer = '7.1.0';
43
+ export const wasmer = '7.2.0';
44
44
  // https://www.npmjs.com/package/@typescript/native-preview?activeTab=versions
45
- export const tsgo = '7.0.0-dev.20260627.2';
45
+ export const tsgo = '7.0.0-dev.20260707.2';
46
46
  // GitHub Action versions used by CI step builders. The key is the action
47
47
  // `owner/name`; call sites compose the full ref as
48
48
  // `` `${name}@${actions[name]}` ``.
@@ -55,13 +55,13 @@ export const actions = {
55
55
  // https://github.com/marketplace/actions/cache
56
56
  'actions/cache': 'v6.1.0',
57
57
  // https://github.com/marketplace/actions/setup-deno
58
- 'denoland/setup-deno': 'v2.0.4',
58
+ 'denoland/setup-deno': 'v2.0.5',
59
59
  // https://github.com/marketplace/actions/setup-bun
60
60
  'oven-sh/setup-bun': 'v2.2.0',
61
61
  // https://github.com/bytecodealliance/actions
62
- 'bytecodealliance/actions/wasmtime/setup': 'v1',
62
+ 'bytecodealliance/actions/wasmtime/setup': 'v1.1.3',
63
63
  // https://github.com/wasmerio/setup-wasmer
64
64
  'wasmerio/setup-wasmer': 'v3.1',
65
65
  // https://rust-lang.org/ - value is Rust version, not action version
66
- 'dtolnay/rust-toolchain': '1.96.0',
66
+ 'dtolnay/rust-toolchain': '1.96.1',
67
67
  };