@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 +42 -0
- package/dist/extension/extension-loader.d.ts +12 -0
- package/dist/extension/extension-loader.d.ts.map +1 -1
- package/dist/extension/extension-loader.js +21 -0
- package/dist/extension/extension-path.d.ts +7 -1
- package/dist/extension/extension-path.d.ts.map +1 -1
- package/dist/extension/extension-path.js +7 -1
- package/dist/file-system-node.d.ts.map +1 -1
- package/dist/file-system-node.js +20 -1
- package/dist/file-system.d.ts +24 -0
- package/dist/file-system.d.ts.map +1 -1
- package/dist/runtime-node-bun.d.ts.map +1 -1
- package/dist/runtime-node-bun.js +7 -10
- package/package.json +4 -4
- package/src/extension/extension-loader.ts +34 -0
- package/src/extension/extension-path.ts +7 -1
- package/src/file-system-node.ts +22 -0
- package/src/file-system.ts +25 -0
- package/src/runtime-node-bun.ts +8 -11
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;
|
|
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
|
|
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;
|
|
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"}
|
package/dist/file-system-node.js
CHANGED
|
@@ -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 ──────────────────────────────────────────────────────────────
|
package/dist/file-system.d.ts
CHANGED
|
@@ -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;
|
|
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,
|
|
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"}
|
package/dist/runtime-node-bun.js
CHANGED
|
@@ -31,18 +31,15 @@ export const nodeBunFactory = {
|
|
|
31
31
|
return loadNodeConfig(options);
|
|
32
32
|
},
|
|
33
33
|
async createDbAdapter(config) {
|
|
34
|
-
// Dynamic import via
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
package/src/file-system-node.ts
CHANGED
|
@@ -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
|
|
package/src/file-system.ts
CHANGED
|
@@ -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
|
|
package/src/runtime-node-bun.ts
CHANGED
|
@@ -41,18 +41,15 @@ export const nodeBunFactory: RuntimeFactory = {
|
|
|
41
41
|
},
|
|
42
42
|
|
|
43
43
|
async createDbAdapter(config: DatabaseConfig): Promise<RuntimeDbAdapter> {
|
|
44
|
-
// Dynamic import via
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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 =
|
|
52
|
+
mod = await import(tsDbSpec);
|
|
56
53
|
} catch (cause) {
|
|
57
54
|
throw new DbModuleNotInstalledError(undefined, { cause });
|
|
58
55
|
}
|