@gobing-ai/ts-runtime 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,6 +63,8 @@ classDiagram
63
63
  +stat(path) FileStat~|~null~|~Promise~FileStat|~
64
64
  +resolve(...segments) string
65
65
  +getProjectRoot() string
66
+ +readFileStream(path) AsyncIterable<string>▀optional (ADR-021)
67
+ +realPath(path) string▀optional (ADR-022)
66
68
  }
67
69
 
68
70
  class createNodeFileSystem {
@@ -72,6 +74,8 @@ classDiagram
72
74
  +ensureDir(path) void
73
75
  +stat(path) FileStat | null
74
76
  +getProjectRoot() string
77
+ +readFileStream(path) AsyncIterable<string>
78
+ +realPath(path) string
75
79
  }
76
80
 
77
81
  class createCfFileSystem {
@@ -346,6 +350,24 @@ assertRelativeExtensionPath('/etc/evil.ts'); // throws: must be relative
346
350
  assertRelativeExtensionPath('../escape.ts'); // throws: must not contain ".."
347
351
  ```
348
352
 
353
+ The string-level guard does not resolve symlinks — a symlink inside `baseDir` that points outside passes
354
+ the check. For symlink-safe confinement (ADR-022), supply a `realPath` canonicalizer to `LoadExtensionsOptions`:
355
+
356
+ ```ts
357
+ import { loadExtensionModules } from '@gobing-ai/ts-runtime/extension';
358
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
359
+
360
+ const fs = createNodeFileSystem('/abs/path');
361
+ await loadExtensionModules(refs, {
362
+ allowExtensions: true,
363
+ moduleLoader: (p) => import(p),
364
+ realPath: fs.realPath, // enables symlink confinement check
365
+ }, register);
366
+ ```
367
+
368
+ When `realPath` is provided, the loader canonicalizes both the resolved path and `baseDir`, rejecting
369
+ symlinks that escape the declaring directory. When absent (e.g. CF Workers), the check is skipped.
370
+
349
371
  ### 7. File system abstraction
350
372
 
351
373
  Use `createNodeFileSystem()` for a real `node:fs`-backed filesystem (Node/Bun) or
@@ -366,6 +388,26 @@ cffs.getProjectRoot(); // '/bundle'
366
388
  cffs.readFile('/x'); // throws: "use D1, KV, or R2"
367
389
  ```
368
390
 
391
+ Optional streaming and symlink-safe operations:
392
+
393
+ ```ts
394
+ // Stream large files line-by-line (ADR-021) — Node/Bun only.
395
+ // The JSONL importer uses this to avoid buffering multi-GB files.
396
+ if (fs.readFileStream) {
397
+ for await (const line of fs.readFileStream('data/huge.jsonl')) {
398
+ // process one line at a time
399
+ }
400
+ }
401
+
402
+ // Canonical path resolution for symlink-safe extension confinement (ADR-022).
403
+ // The extension loader uses this to verify that a symlink hasn't escaped baseDir.
404
+ if (fs.realPath) {
405
+ const real = fs.realPath('/base/extensions/linked.ts');
406
+ }
407
+ ```
408
+
409
+ Both are optional — CF Workers stubs omit them. The JSONL importer falls back to `readFile` + split when `readFileStream` is absent; the extension loader skips the symlink check when `realPath` is absent.
410
+
369
411
  The old `getFs()` / `setFileSystem` global swap and `SyncFileSystem` are marked `@deprecated` —
370
412
  use `createNodeFileSystem()` or `ctx.require('fileSystem')` instead.
371
413
 
@@ -37,6 +37,18 @@ export interface LoadExtensionsOptions {
37
37
  * means the shared core has no ambient code-loading capability of its own.
38
38
  */
39
39
  readonly moduleLoader: (absPath: string) => Promise<Record<string, unknown>>;
40
+ /**
41
+ * Optional canonical-path resolver for symlink-safe confinement (ADR-022).
42
+ *
43
+ * When provided, the loader resolves the **real** path of the extension module
44
+ * (following symlinks) and re-checks that it remains within `baseDir`'s real
45
+ * root. This closes the symlink-escape vector: an authored path like
46
+ * `extensions/legit.ts` can pass the string-level `..` guard but still point
47
+ * outside `baseDir` via a symlink. Callers that serve extension modules from a
48
+ * real filesystem (e.g. `createNodeFileSystem().realPath`) should supply this.
49
+ * Stub filesystems without symlinks (e.g. CF Workers) may omit it.
50
+ */
51
+ readonly realPath?: (absPath: string) => string;
40
52
  }
41
53
  /** A validated extension module export: an object carrying at least a string `name`. */
42
54
  export interface LoadedExtension {
@@ -1 +1 @@
1
- {"version":3,"file":"extension-loader.d.ts","sourceRoot":"","sources":["../../src/extension/extension-loader.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY,CAAC,cAAc,SAAS,MAAM,GAAG,MAAM;IAChE,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,kEAAkE;IAClE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC/B;AAED,oDAAoD;AACpD,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;IACtD;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAChF;AAED,wFAAwF;AACxF,MAAM,WAAW,eAAe;IAC5B,wDAAwD;IACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,oBAAoB,CAAC,cAAc,SAAS,MAAM,EACpE,IAAI,EAAE,SAAS,YAAY,CAAC,cAAc,CAAC,EAAE,EAC7C,OAAO,EAAE,qBAAqB,EAC9B,QAAQ,EAAE,CAAC,GAAG,EAAE,YAAY,CAAC,cAAc,CAAC,EAAE,SAAS,EAAE,eAAe,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAClG,OAAO,CAAC,IAAI,CAAC,CAgCf"}
1
+ {"version":3,"file":"extension-loader.d.ts","sourceRoot":"","sources":["../../src/extension/extension-loader.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY,CAAC,cAAc,SAAS,MAAM,GAAG,MAAM;IAChE,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,kEAAkE;IAClE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC/B;AAED,oDAAoD;AACpD,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;IACtD;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC7E;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CACnD;AAED,wFAAwF;AACxF,MAAM,WAAW,eAAe;IAC5B,wDAAwD;IACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,oBAAoB,CAAC,cAAc,SAAS,MAAM,EACpE,IAAI,EAAE,SAAS,YAAY,CAAC,cAAc,CAAC,EAAE,EAC7C,OAAO,EAAE,qBAAqB,EAC9B,QAAQ,EAAE,CAAC,GAAG,EAAE,YAAY,CAAC,cAAc,CAAC,EAAE,SAAS,EAAE,eAAe,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAClG,OAAO,CAAC,IAAI,CAAC,CAsDf"}
@@ -35,6 +35,27 @@ export async function loadExtensionModules(refs, options, register) {
35
35
  // imports a caller-supplied absolute path it did not resolve itself.
36
36
  assertRelativeExtensionPath(ref.path, { sourceName: ref.sourceName });
37
37
  const absPath = resolve(ref.baseDir, ref.path);
38
+ // ADR-022: when realPath is available, canonicalize and re-check confinement
39
+ // to close the symlink-escape vector — an authored path with no ".." can still
40
+ // resolve outside baseDir via a symlink.
41
+ if (options.realPath) {
42
+ let realAbs;
43
+ let realBase;
44
+ try {
45
+ realAbs = options.realPath(absPath);
46
+ realBase = options.realPath(ref.baseDir);
47
+ }
48
+ catch (error) {
49
+ // Canonicalizers surface raw filesystem errors (e.g. ENOENT for a
50
+ // missing module) — rewrap with the declaring ref so the failing
51
+ // extension is identifiable from the message alone.
52
+ const reason = error instanceof Error ? error.message : String(error);
53
+ throw new Error(`"${ref.sourceName}" extension "${ref.path}" cannot be canonicalized: ${reason}`);
54
+ }
55
+ if (realAbs !== realBase && !realAbs.startsWith(`${realBase}/`) && !realAbs.startsWith(`${realBase}\\`)) {
56
+ throw new Error(`"${ref.sourceName}" extension "${ref.path}" resolves outside baseDir via symlink (real: "${realAbs}", base: "${realBase}") — refusing to load`);
57
+ }
58
+ }
38
59
  const moduleExports = await options.moduleLoader(absPath);
39
60
  const candidate = moduleExports.default ?? moduleExports.extension;
40
61
  if (candidate === null ||
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Assert that an extension module path is relative and does not escape its
3
- * declaring directory.
3
+ * declaring directory via string-level traversal.
4
4
  *
5
5
  * Extension declarations are data, and a path that is absolute or escapes via `..`
6
6
  * is a trust-boundary violation even when extension loading is explicitly allowed.
7
7
  * This is a standalone validator (not a schema refinement) so the loader can enforce
8
8
  * it at load time, independent of any engine's config schema — defense in depth.
9
9
  *
10
+ * This guard is string-level only: it rejects `..` segments and absolute paths in
11
+ * the declaration but does NOT resolve symlinks. A symlink inside `baseDir` that
12
+ * points outside it passes this check. For symlink-safe confinement, supply a
13
+ * `realPath` canonicalizer to `LoadExtensionsOptions` (ADR-022); the loader
14
+ * performs the filesystem-level check when `realPath` is provided.
15
+ *
10
16
  * @throws When the path is absolute or contains a `..` traversal segment.
11
17
  */
12
18
  export declare function assertRelativeExtensionPath(path: string, options?: {
@@ -1 +1 @@
1
- {"version":3,"file":"extension-path.d.ts","sourceRoot":"","sources":["../../src/extension/extension-path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAQrG"}
1
+ {"version":3,"file":"extension-path.d.ts","sourceRoot":"","sources":["../../src/extension/extension-path.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAQrG"}
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Assert that an extension module path is relative and does not escape its
3
- * declaring directory.
3
+ * declaring directory via string-level traversal.
4
4
  *
5
5
  * Extension declarations are data, and a path that is absolute or escapes via `..`
6
6
  * is a trust-boundary violation even when extension loading is explicitly allowed.
7
7
  * This is a standalone validator (not a schema refinement) so the loader can enforce
8
8
  * it at load time, independent of any engine's config schema — defense in depth.
9
9
  *
10
+ * This guard is string-level only: it rejects `..` segments and absolute paths in
11
+ * the declaration but does NOT resolve symlinks. A symlink inside `baseDir` that
12
+ * points outside it passes this check. For symlink-safe confinement, supply a
13
+ * `realPath` canonicalizer to `LoadExtensionsOptions` (ADR-022); the loader
14
+ * performs the filesystem-level check when `realPath` is provided.
15
+ *
10
16
  * @throws When the path is absolute or contains a `..` traversal segment.
11
17
  */
12
18
  export function assertRelativeExtensionPath(path, options = {}) {
@@ -1 +1 @@
1
- {"version":3,"file":"file-system-node.d.ts","sourceRoot":"","sources":["../src/file-system-node.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAiBH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,UAAU,CA2D9D;AAWD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAaxD"}
1
+ {"version":3,"file":"file-system-node.d.ts","sourceRoot":"","sources":["../src/file-system-node.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAmBH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,UAAU,CA+E9D;AAWD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAaxD"}
@@ -9,7 +9,7 @@
9
9
  * `node:fs` fully, so this works on both runtimes without a Bun-specific
10
10
  * variant.
11
11
  */
12
- import { appendFileSync, cpSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
12
+ import { appendFileSync, cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
13
13
  import { dirname, resolve as resolvePath } from 'node:path';
14
14
  /**
15
15
  * Create a {@link FileSystem} backed by `node:fs`.
@@ -23,6 +23,24 @@ export function createNodeFileSystem(root) {
23
23
  resolve: (...segments) => resolvePath(projectRoot, ...segments),
24
24
  exists: (path) => existsSync(path),
25
25
  readFile: (path) => readFileSync(path, 'utf-8'),
26
+ readFileStream: async function* (path) {
27
+ // WHY: large JSONL history files (100MB+) must not be loaded into memory
28
+ // all at once. createReadStream + manual line splitting lets the importer
29
+ // process records incrementally with constant memory.
30
+ const stream = createReadStream(path, { encoding: 'utf-8' });
31
+ let buffer = '';
32
+ for await (const chunk of stream) {
33
+ buffer += chunk;
34
+ const lines = buffer.split(/\r?\n/);
35
+ // Keep the last (possibly partial) line in the buffer.
36
+ buffer = lines.pop() ?? '';
37
+ for (const line of lines) {
38
+ yield line;
39
+ }
40
+ }
41
+ if (buffer.length > 0)
42
+ yield buffer;
43
+ },
26
44
  writeFile: (path, content) => {
27
45
  ensureParentDir(path);
28
46
  writeFileSync(path, content, 'utf-8');
@@ -62,6 +80,7 @@ export function createNodeFileSystem(root) {
62
80
  return null;
63
81
  }
64
82
  },
83
+ realPath: (path) => realpathSync(path),
65
84
  };
66
85
  }
67
86
  // ── Helpers ──────────────────────────────────────────────────────────────
@@ -20,6 +20,18 @@ export interface FileSystem {
20
20
  exists(path: string): boolean | Promise<boolean>;
21
21
  /** Read file contents as UTF-8 string. Throws if not found. */
22
22
  readFile(path: string): string | Promise<string>;
23
+ /**
24
+ * Read file contents as a stream of UTF-8 text lines.
25
+ *
26
+ * Returns an async iterable yielding one line per iteration (without
27
+ * trailing newline). This is the streaming counterpart of `readFile`
28
+ * for large files that should not be loaded into memory all at once.
29
+ *
30
+ * Optional: Cloudflare Workers and other stubs may omit this method.
31
+ * Callers must check for its presence before use and fall back to
32
+ * `readFile` + `split` when unavailable.
33
+ */
34
+ readFileStream?(path: string): AsyncIterable<string>;
23
35
  /** Write file contents, creating parent directories as needed. */
24
36
  writeFile(path: string, content: string): void | Promise<void>;
25
37
  /** Append content to a file, creating it if it doesn't exist. */
@@ -41,6 +53,18 @@ export interface FileSystem {
41
53
  write(chunk: string): void;
42
54
  end(): void;
43
55
  };
56
+ /**
57
+ * Resolve the canonical (symlink-free) absolute path.
58
+ *
59
+ * WHY: used by the extension loader (ADR-022) to verify that an extension
60
+ * module's real path — after following symlinks — still falls within its
61
+ * declaring `baseDir`. Without this, a symlink can bypass the string-level
62
+ * `..` guard and load code from outside the intended directory.
63
+ *
64
+ * Optional: stubs without a real filesystem (e.g. CF Workers) may omit this.
65
+ * When absent, the extension loader skips the symlink confinement check.
66
+ */
67
+ realPath?(path: string): string;
44
68
  /** Resolve path segments relative to the project root. */
45
69
  resolve(...segments: string[]): string;
46
70
  /** Get the project root directory path. */
@@ -1 +1 @@
1
- {"version":3,"file":"file-system.d.ts","sourceRoot":"","sources":["../src/file-system.ts"],"names":[],"mappings":"AAAA,iCAAiC;AACjC,MAAM,WAAW,QAAQ;IACrB,MAAM,IAAI,OAAO,CAAC;IAClB,WAAW,IAAI,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,UAAU;IACvB,mCAAmC;IACnC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEjD,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAEjD,kEAAkE;IAClE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/D,iEAAiE;IACjE,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhE,kFAAkF;IAClF,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9C,2DAA2D;IAC3D,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAEpD,8CAA8C;IAC9C,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/C,6CAA6C;IAC7C,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAExD,4CAA4C;IAC5C,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,6EAA6E;IAC7E,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IAE/D,6FAA6F;IAC7F,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,GAAG,IAAI,IAAI,CAAA;KAAE,CAAC;IAE7E,0DAA0D;IAC1D,OAAO,CAAC,GAAG,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IAEvC,2CAA2C;IAC3C,cAAc,IAAI,MAAM,CAAC;CAC5B"}
1
+ {"version":3,"file":"file-system.d.ts","sourceRoot":"","sources":["../src/file-system.ts"],"names":[],"mappings":"AAAA,iCAAiC;AACjC,MAAM,WAAW,QAAQ;IACrB,MAAM,IAAI,OAAO,CAAC;IAClB,WAAW,IAAI,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,UAAU;IACvB,mCAAmC;IACnC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEjD,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACjD;;;;;;;;;;OAUG;IACH,cAAc,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;IAErD,kEAAkE;IAClE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/D,iEAAiE;IACjE,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhE,kFAAkF;IAClF,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9C,2DAA2D;IAC3D,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAEpD,8CAA8C;IAC9C,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/C,6CAA6C;IAC7C,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAExD,4CAA4C;IAC5C,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,6EAA6E;IAC7E,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IAE/D,6FAA6F;IAC7F,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,GAAG,IAAI,IAAI,CAAA;KAAE,CAAC;IAE7E;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAEhC,0DAA0D;IAC1D,OAAO,CAAC,GAAG,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IAEvC,2CAA2C;IAC3C,cAAc,IAAI,MAAM,CAAC;CAC5B"}
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-node-bun.d.ts","sourceRoot":"","sources":["../src/runtime-node-bun.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAUxD,oEAAoE;AACpE,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AAED;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE,cAwC5B,CAAC"}
1
+ {"version":3,"file":"runtime-node-bun.d.ts","sourceRoot":"","sources":["../src/runtime-node-bun.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAUxD,oEAAoE;AACpE,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AAED;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE,cAqC5B,CAAC"}
@@ -31,18 +31,15 @@ export const nodeBunFactory = {
31
31
  return loadNodeConfig(options);
32
32
  },
33
33
  async createDbAdapter(config) {
34
- // Dynamic import via a variable specifier — this prevents tsc from
35
- // statically resolving @gobing-ai/ts-db at build time. ts-db builds
36
- // AFTER ts-runtime (it depends on ts-runtime), so a literal specifier
37
- // or `typeof import()` annotation triggers TS2307 in a clean CI
38
- // checkout where no prebuilt dist/ exists yet. At runtime Bun resolves
39
- // the module via the workspace symlink.
40
- // ts-db is an optional peerDependency (ADR-012 addendum) — if the
41
- // consumer has not installed it, surface a typed error.
42
- const moduleSpecifier = '@gobing-ai/ts-db';
34
+ // Dynamic import via variable specifier — ts-db depends on ts-runtime,
35
+ // so a static (or literal-specifier dynamic) import forces tsc to resolve
36
+ // the module at build time, raising TS2307 when no prebuilt db dist/
37
+ // exists (CI/clean checkout). A variable specifier is opaque to tsc.
38
+ // ts-db is an optional peerDependency (ADR-012 addendum, task 0040).
39
+ const tsDbSpec = '@gobing-ai/ts-db';
43
40
  let mod;
44
41
  try {
45
- mod = (await import(moduleSpecifier));
42
+ mod = await import(tsDbSpec);
46
43
  }
47
44
  catch (cause) {
48
45
  throw new DbModuleNotInstalledError(undefined, { cause });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-runtime",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "@gobing-ai/ts-runtime — Runtime abstractions for Bun, Node, and Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -58,17 +58,17 @@
58
58
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-runtime-v<version> && git push --tags' && exit 1"
59
59
  },
60
60
  "dependencies": {
61
- "@gobing-ai/ts-utils": "^0.4.6",
61
+ "@gobing-ai/ts-utils": "^0.4.8",
62
62
  "execa": "^9.5.0",
63
63
  "yaml": "^2.7.0",
64
64
  "zod": "^4.1.0"
65
65
  },
66
66
  "devDependencies": {
67
- "@gobing-ai/ts-db": "^0.4.6",
67
+ "@gobing-ai/ts-db": "^0.4.8",
68
68
  "@types/bun": "1.3.14"
69
69
  },
70
70
  "peerDependencies": {
71
- "@gobing-ai/ts-db": "^0.4.6"
71
+ "@gobing-ai/ts-db": "^0.4.8"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@gobing-ai/ts-db": {
@@ -39,6 +39,18 @@ export interface LoadExtensionsOptions {
39
39
  * means the shared core has no ambient code-loading capability of its own.
40
40
  */
41
41
  readonly moduleLoader: (absPath: string) => Promise<Record<string, unknown>>;
42
+ /**
43
+ * Optional canonical-path resolver for symlink-safe confinement (ADR-022).
44
+ *
45
+ * When provided, the loader resolves the **real** path of the extension module
46
+ * (following symlinks) and re-checks that it remains within `baseDir`'s real
47
+ * root. This closes the symlink-escape vector: an authored path like
48
+ * `extensions/legit.ts` can pass the string-level `..` guard but still point
49
+ * outside `baseDir` via a symlink. Callers that serve extension modules from a
50
+ * real filesystem (e.g. `createNodeFileSystem().realPath`) should supply this.
51
+ * Stub filesystems without symlinks (e.g. CF Workers) may omit it.
52
+ */
53
+ readonly realPath?: (absPath: string) => string;
42
54
  }
43
55
 
44
56
  /** A validated extension module export: an object carrying at least a string `name`. */
@@ -91,6 +103,28 @@ export async function loadExtensionModules<TExtensionKind extends string>(
91
103
  // imports a caller-supplied absolute path it did not resolve itself.
92
104
  assertRelativeExtensionPath(ref.path, { sourceName: ref.sourceName });
93
105
  const absPath = resolve(ref.baseDir, ref.path);
106
+ // ADR-022: when realPath is available, canonicalize and re-check confinement
107
+ // to close the symlink-escape vector — an authored path with no ".." can still
108
+ // resolve outside baseDir via a symlink.
109
+ if (options.realPath) {
110
+ let realAbs: string;
111
+ let realBase: string;
112
+ try {
113
+ realAbs = options.realPath(absPath);
114
+ realBase = options.realPath(ref.baseDir);
115
+ } catch (error) {
116
+ // Canonicalizers surface raw filesystem errors (e.g. ENOENT for a
117
+ // missing module) — rewrap with the declaring ref so the failing
118
+ // extension is identifiable from the message alone.
119
+ const reason = error instanceof Error ? error.message : String(error);
120
+ throw new Error(`"${ref.sourceName}" extension "${ref.path}" cannot be canonicalized: ${reason}`);
121
+ }
122
+ if (realAbs !== realBase && !realAbs.startsWith(`${realBase}/`) && !realAbs.startsWith(`${realBase}\\`)) {
123
+ throw new Error(
124
+ `"${ref.sourceName}" extension "${ref.path}" resolves outside baseDir via symlink (real: "${realAbs}", base: "${realBase}") — refusing to load`,
125
+ );
126
+ }
127
+ }
94
128
  const moduleExports = await options.moduleLoader(absPath);
95
129
  const candidate = moduleExports.default ?? moduleExports.extension;
96
130
  if (
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Assert that an extension module path is relative and does not escape its
3
- * declaring directory.
3
+ * declaring directory via string-level traversal.
4
4
  *
5
5
  * Extension declarations are data, and a path that is absolute or escapes via `..`
6
6
  * is a trust-boundary violation even when extension loading is explicitly allowed.
7
7
  * This is a standalone validator (not a schema refinement) so the loader can enforce
8
8
  * it at load time, independent of any engine's config schema — defense in depth.
9
9
  *
10
+ * This guard is string-level only: it rejects `..` segments and absolute paths in
11
+ * the declaration but does NOT resolve symlinks. A symlink inside `baseDir` that
12
+ * points outside it passes this check. For symlink-safe confinement, supply a
13
+ * `realPath` canonicalizer to `LoadExtensionsOptions` (ADR-022); the loader
14
+ * performs the filesystem-level check when `realPath` is provided.
15
+ *
10
16
  * @throws When the path is absolute or contains a `..` traversal segment.
11
17
  */
12
18
  export function assertRelativeExtensionPath(path: string, options: { sourceName?: string } = {}): void {
@@ -13,11 +13,13 @@
13
13
  import {
14
14
  appendFileSync,
15
15
  cpSync,
16
+ createReadStream,
16
17
  createWriteStream,
17
18
  existsSync,
18
19
  mkdirSync,
19
20
  readdirSync,
20
21
  readFileSync,
22
+ realpathSync,
21
23
  renameSync,
22
24
  rmSync,
23
25
  statSync,
@@ -44,6 +46,24 @@ export function createNodeFileSystem(root?: string): FileSystem {
44
46
 
45
47
  readFile: (path: string) => readFileSync(path, 'utf-8'),
46
48
 
49
+ readFileStream: async function* (path: string): AsyncIterable<string> {
50
+ // WHY: large JSONL history files (100MB+) must not be loaded into memory
51
+ // all at once. createReadStream + manual line splitting lets the importer
52
+ // process records incrementally with constant memory.
53
+ const stream = createReadStream(path, { encoding: 'utf-8' });
54
+ let buffer = '';
55
+ for await (const chunk of stream) {
56
+ buffer += chunk;
57
+ const lines = buffer.split(/\r?\n/);
58
+ // Keep the last (possibly partial) line in the buffer.
59
+ buffer = lines.pop() ?? '';
60
+ for (const line of lines) {
61
+ yield line;
62
+ }
63
+ }
64
+ if (buffer.length > 0) yield buffer;
65
+ },
66
+
47
67
  writeFile: (path: string, content: string) => {
48
68
  ensureParentDir(path);
49
69
  writeFileSync(path, content, 'utf-8');
@@ -90,6 +110,8 @@ export function createNodeFileSystem(root?: string): FileSystem {
90
110
  return null;
91
111
  }
92
112
  },
113
+
114
+ realPath: (path: string) => realpathSync(path),
93
115
  };
94
116
  }
95
117
 
@@ -22,6 +22,18 @@ export interface FileSystem {
22
22
 
23
23
  /** Read file contents as UTF-8 string. Throws if not found. */
24
24
  readFile(path: string): string | Promise<string>;
25
+ /**
26
+ * Read file contents as a stream of UTF-8 text lines.
27
+ *
28
+ * Returns an async iterable yielding one line per iteration (without
29
+ * trailing newline). This is the streaming counterpart of `readFile`
30
+ * for large files that should not be loaded into memory all at once.
31
+ *
32
+ * Optional: Cloudflare Workers and other stubs may omit this method.
33
+ * Callers must check for its presence before use and fall back to
34
+ * `readFile` + `split` when unavailable.
35
+ */
36
+ readFileStream?(path: string): AsyncIterable<string>;
25
37
 
26
38
  /** Write file contents, creating parent directories as needed. */
27
39
  writeFile(path: string, content: string): void | Promise<void>;
@@ -49,6 +61,19 @@ export interface FileSystem {
49
61
  /** Create a writable stream for append-only output (Node/Bun only). Throws on CF Workers. */
50
62
  createWriteStream(path: string): { write(chunk: string): void; end(): void };
51
63
 
64
+ /**
65
+ * Resolve the canonical (symlink-free) absolute path.
66
+ *
67
+ * WHY: used by the extension loader (ADR-022) to verify that an extension
68
+ * module's real path — after following symlinks — still falls within its
69
+ * declaring `baseDir`. Without this, a symlink can bypass the string-level
70
+ * `..` guard and load code from outside the intended directory.
71
+ *
72
+ * Optional: stubs without a real filesystem (e.g. CF Workers) may omit this.
73
+ * When absent, the extension loader skips the symlink confinement check.
74
+ */
75
+ realPath?(path: string): string;
76
+
52
77
  /** Resolve path segments relative to the project root. */
53
78
  resolve(...segments: string[]): string;
54
79
 
@@ -41,18 +41,15 @@ export const nodeBunFactory: RuntimeFactory = {
41
41
  },
42
42
 
43
43
  async createDbAdapter(config: DatabaseConfig): Promise<RuntimeDbAdapter> {
44
- // Dynamic import via a variable specifier — this prevents tsc from
45
- // statically resolving @gobing-ai/ts-db at build time. ts-db builds
46
- // AFTER ts-runtime (it depends on ts-runtime), so a literal specifier
47
- // or `typeof import()` annotation triggers TS2307 in a clean CI
48
- // checkout where no prebuilt dist/ exists yet. At runtime Bun resolves
49
- // the module via the workspace symlink.
50
- // ts-db is an optional peerDependency (ADR-012 addendum) if the
51
- // consumer has not installed it, surface a typed error.
52
- const moduleSpecifier = '@gobing-ai/ts-db';
53
- let mod: { createDbAdapter: (config: { driver: 'bun-sqlite'; url?: string }) => RuntimeDbAdapter };
44
+ // Dynamic import via variable specifier — ts-db depends on ts-runtime,
45
+ // so a static (or literal-specifier dynamic) import forces tsc to resolve
46
+ // the module at build time, raising TS2307 when no prebuilt db dist/
47
+ // exists (CI/clean checkout). A variable specifier is opaque to tsc.
48
+ // ts-db is an optional peerDependency (ADR-012 addendum, task 0040).
49
+ const tsDbSpec = '@gobing-ai/ts-db';
50
+ let mod: { createDbAdapter: (config: DatabaseConfig) => Promise<RuntimeDbAdapter> };
54
51
  try {
55
- mod = (await import(moduleSpecifier)) as typeof mod;
52
+ mod = await import(tsDbSpec);
56
53
  } catch (cause) {
57
54
  throw new DbModuleNotInstalledError(undefined, { cause });
58
55
  }