@gobing-ai/ts-runtime 0.4.28 → 0.4.31
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 +5 -4
- package/dist/fs.d.ts +9 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +34 -5
- package/dist/process-executor.d.ts +15 -0
- package/dist/process-executor.d.ts.map +1 -1
- package/dist/process-executor.js +31 -1
- package/dist/schema-validation.d.ts.map +1 -1
- package/dist/schema-validation.js +17 -5
- package/package.json +4 -4
- package/src/fs.ts +42 -5
- package/src/process-executor.ts +46 -1
- package/src/schema-validation.ts +17 -5
package/README.md
CHANGED
|
@@ -261,16 +261,17 @@ the most secure and performant default:
|
|
|
261
261
|
| ------- | --------- | ------------ | ------- |
|
|
262
262
|
| **Package specifier** (recommended) | `$schema: "@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json"` | Resolved through `node_modules` via the module resolver, then read from disk | No network, no path guessing; survives hoisting/pnpm/monorepo layouts. Schemas ship in each package's `schemas/` (declared in `files`). **Quote the value** — YAML treats a leading `@` as reserved. |
|
|
263
263
|
| Relative path | `$schema: ./schemas/rule-file.schema.json` | Resolved against the config file's directory | Fine for repo-local schemas; brittle if the config moves. |
|
|
264
|
-
| Remote URL | `$schema: https://json-schema.org/.../rule-file.schema.json` | Fetched over HTTP(S) — **off by default** | SSRF/DoS surface for third-party configs. Opt in with `{ allowRemote: true }`
|
|
264
|
+
| Remote URL | `$schema: https://json-schema.org/.../rule-file.schema.json` | Fetched over HTTP(S) — **off by default** | SSRF/DoS surface for third-party configs. Opt in with **both** `{ allowRemote: true }` **and** a `fetch` implementation — there is no built-in fetch; the caller owns timeout/credentials. |
|
|
265
265
|
|
|
266
266
|
```ts
|
|
267
267
|
// Bundled package schema (default path — no extra options needed):
|
|
268
268
|
// $schema: "@gobing-ai/ts-rule-engine/schemas/rule-file.schema.json"
|
|
269
269
|
await loadStructuredConfig('rules.yaml');
|
|
270
270
|
|
|
271
|
-
// Remote schema is refused unless
|
|
272
|
-
await loadStructuredConfig('rules.yaml', { allowRemote: true });
|
|
273
|
-
|
|
271
|
+
// Remote schema is refused unless BOTH flags are present:
|
|
272
|
+
await loadStructuredConfig('rules.yaml', { allowRemote: true, fetch: myFetch }); // caller-supplied fetch
|
|
273
|
+
// { allowRemote: true } alone -> refused (no built-in fetch)
|
|
274
|
+
// { fetch: myFetch } alone -> refused (no explicit opt-in)
|
|
274
275
|
```
|
|
275
276
|
|
|
276
277
|
> **Security:** remote schema fetching is disabled by default. Resolving a bundled schema from
|
package/dist/fs.d.ts
CHANGED
|
@@ -5,13 +5,21 @@ export declare function ensureDirForFile(path: string, fs?: CanonicalFileSystem)
|
|
|
5
5
|
export declare function atomicWriteFile(path: string, content: string, fs?: CanonicalFileSystem): Promise<void>;
|
|
6
6
|
/** Atomically writes a value as JSON with trailing newline. */
|
|
7
7
|
export declare function atomicWriteJson(path: string, value: unknown, fs?: CanonicalFileSystem): Promise<void>;
|
|
8
|
-
/** Reads and parses a JSON file. */
|
|
8
|
+
/** Reads and parses a JSON file. Throws with the file named when the content is not valid JSON. */
|
|
9
9
|
export declare function readJsonFile<T = unknown>(path: string, fs?: CanonicalFileSystem): Promise<T>;
|
|
10
10
|
/** Writes a value as JSON with 2-space indentation and trailing newline. */
|
|
11
11
|
export declare function writeJsonFile(path: string, value: unknown, fs?: CanonicalFileSystem): Promise<void>;
|
|
12
12
|
/**
|
|
13
13
|
* Recursively walks a directory, returning sorted paths to all files,
|
|
14
14
|
* optionally excluding entries by name.
|
|
15
|
+
*
|
|
16
|
+
* Cycle-safe and root-confined (task 0060 F3): directory symlinks are tracked
|
|
17
|
+
* by their canonical real path, so a link back to an ancestor (or the start
|
|
18
|
+
* root itself) is walked once at most, and a link escaping the start root is
|
|
19
|
+
* skipped. In-root directory symlinks are still traversed — importer and
|
|
20
|
+
* rule-engine walk `$HOME/...` trees that are often symlinks into a dotfiles
|
|
21
|
+
* repo. When the filesystem has no `realPath` (in-memory / CF stubs), today's
|
|
22
|
+
* follow-`stat` behaviour is kept (no ambient capability).
|
|
15
23
|
*/
|
|
16
24
|
export declare function walkDir(path: string, fs?: CanonicalFileSystem, exclude?: Set<string>): Promise<string[]>;
|
|
17
25
|
/**
|
package/dist/fs.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,IAAI,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAGvE,iEAAiE;AACjE,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAE,mBAA4C,GAAG,OAAO,CAAC,IAAI,CAAC,CAEpH;AAED,0GAA0G;AAC1G,wBAAsB,eAAe,CACjC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,EAAE,GAAE,mBAA4C,GACjD,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,+DAA+D;AAC/D,wBAAsB,eAAe,CACjC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,OAAO,EACd,EAAE,GAAE,mBAA4C,GACjD,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,
|
|
1
|
+
{"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,IAAI,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAGvE,iEAAiE;AACjE,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAE,mBAA4C,GAAG,OAAO,CAAC,IAAI,CAAC,CAEpH;AAED,0GAA0G;AAC1G,wBAAsB,eAAe,CACjC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,EAAE,GAAE,mBAA4C,GACjD,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,+DAA+D;AAC/D,wBAAsB,eAAe,CACjC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,OAAO,EACd,EAAE,GAAE,mBAA4C,GACjD,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,mGAAmG;AACnG,wBAAsB,YAAY,CAAC,CAAC,GAAG,OAAO,EAC1C,IAAI,EAAE,MAAM,EACZ,EAAE,GAAE,mBAA4C,GACjD,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED,4EAA4E;AAC5E,wBAAsB,aAAa,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,OAAO,EACd,EAAE,GAAE,mBAA4C,GACjD,OAAO,CAAC,IAAI,CAAC,CAEf;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CACzB,IAAI,EAAE,MAAM,EACZ,EAAE,GAAE,mBAA4C,EAChD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GACtB,OAAO,CAAC,MAAM,EAAE,CAAC,CAMnB;AAkCD;;GAEG;AACH,wBAAgB,eAAe,CAC3B,IAAI,EAAE,MAAM,EACZ,EAAE,GAAE,mBAA4C,GACjD;IAAE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,GAAG,IAAI,IAAI,CAAA;CAAE,CAE7C"}
|
package/dist/fs.js
CHANGED
|
@@ -15,9 +15,15 @@ export async function atomicWriteFile(path, content, fs = createNodeFileSystem()
|
|
|
15
15
|
export async function atomicWriteJson(path, value, fs = createNodeFileSystem()) {
|
|
16
16
|
await atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`, fs);
|
|
17
17
|
}
|
|
18
|
-
/** Reads and parses a JSON file. */
|
|
18
|
+
/** Reads and parses a JSON file. Throws with the file named when the content is not valid JSON. */
|
|
19
19
|
export async function readJsonFile(path, fs = createNodeFileSystem()) {
|
|
20
|
-
|
|
20
|
+
const content = await fs.readFile(path);
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(content);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new Error(`Failed to parse JSON file "${path}": ${error.message}`);
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
/** Writes a value as JSON with 2-space indentation and trailing newline. */
|
|
23
29
|
export async function writeJsonFile(path, value, fs = createNodeFileSystem()) {
|
|
@@ -26,23 +32,46 @@ export async function writeJsonFile(path, value, fs = createNodeFileSystem()) {
|
|
|
26
32
|
/**
|
|
27
33
|
* Recursively walks a directory, returning sorted paths to all files,
|
|
28
34
|
* optionally excluding entries by name.
|
|
35
|
+
*
|
|
36
|
+
* Cycle-safe and root-confined (task 0060 F3): directory symlinks are tracked
|
|
37
|
+
* by their canonical real path, so a link back to an ancestor (or the start
|
|
38
|
+
* root itself) is walked once at most, and a link escaping the start root is
|
|
39
|
+
* skipped. In-root directory symlinks are still traversed — importer and
|
|
40
|
+
* rule-engine walk `$HOME/...` trees that are often symlinks into a dotfiles
|
|
41
|
+
* repo. When the filesystem has no `realPath` (in-memory / CF stubs), today's
|
|
42
|
+
* follow-`stat` behaviour is kept (no ambient capability).
|
|
29
43
|
*/
|
|
30
44
|
export async function walkDir(path, fs = createNodeFileSystem(), exclude) {
|
|
31
|
-
const
|
|
45
|
+
const startReal = fs.realPath?.(path) ?? path;
|
|
46
|
+
const visited = new Set([startReal]);
|
|
32
47
|
const result = [];
|
|
48
|
+
await walkDirInto(path, fs, exclude, startReal, visited, result);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
async function walkDirInto(path, fs, exclude, startReal, visited, result) {
|
|
52
|
+
const entries = (await fs.readDir(path)).sort();
|
|
33
53
|
for (const entry of entries) {
|
|
34
54
|
if (exclude?.has(entry))
|
|
35
55
|
continue;
|
|
36
56
|
const fullPath = joinPath(path, entry);
|
|
37
57
|
const entryStat = await fs.stat(fullPath);
|
|
38
58
|
if (entryStat?.isDirectory()) {
|
|
39
|
-
|
|
59
|
+
const entryReal = fs.realPath?.(fullPath) ?? fullPath;
|
|
60
|
+
if (visited.has(entryReal))
|
|
61
|
+
continue; // cycle or already-walked real dir
|
|
62
|
+
if (fs.realPath !== undefined &&
|
|
63
|
+
entryReal !== startReal &&
|
|
64
|
+
!entryReal.startsWith(`${startReal}/`) &&
|
|
65
|
+
!entryReal.startsWith(`${startReal}\\`)) {
|
|
66
|
+
continue; // symlink escapes the start root
|
|
67
|
+
}
|
|
68
|
+
visited.add(entryReal);
|
|
69
|
+
await walkDirInto(fullPath, fs, exclude, startReal, visited, result);
|
|
40
70
|
}
|
|
41
71
|
else if (entryStat?.isFile()) {
|
|
42
72
|
result.push(fullPath);
|
|
43
73
|
}
|
|
44
74
|
}
|
|
45
|
-
return result;
|
|
46
75
|
}
|
|
47
76
|
/**
|
|
48
77
|
* Creates a writable stream for append-only output at the given path.
|
|
@@ -48,6 +48,13 @@ export interface ProcessOptions {
|
|
|
48
48
|
args?: string[];
|
|
49
49
|
cwd?: string;
|
|
50
50
|
env?: Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* `'merge'` (default): a partial `env` extends the parent environment (execa
|
|
53
|
+
* `extendEnv: true` semantics). `'replace'`: the child gets exactly `env` and
|
|
54
|
+
* none of the parent variables. Task 0060 R5 unified the default to merge so
|
|
55
|
+
* `run` and `runStreaming` share one contract.
|
|
56
|
+
*/
|
|
57
|
+
envMode?: 'merge' | 'replace';
|
|
51
58
|
timeout?: number;
|
|
52
59
|
maxOutput?: number;
|
|
53
60
|
label?: string;
|
|
@@ -114,6 +121,8 @@ export interface ProcessEventDetail {
|
|
|
114
121
|
timestamp: string;
|
|
115
122
|
label?: string;
|
|
116
123
|
error?: string;
|
|
124
|
+
/** Producer-owned observability severity. */
|
|
125
|
+
severity: 'info' | 'warning' | 'error';
|
|
117
126
|
}
|
|
118
127
|
/** Zero-dependency structural event sink for process observability. */
|
|
119
128
|
export interface ProcessEventSink {
|
|
@@ -134,6 +143,12 @@ export interface PipeProcessOptions {
|
|
|
134
143
|
args?: string[];
|
|
135
144
|
cwd?: string;
|
|
136
145
|
env?: Record<string, string>;
|
|
146
|
+
/**
|
|
147
|
+
* `'merge'` (default): a partial `env` extends the parent environment, matching
|
|
148
|
+
* {@link ProcessExecutor.run}. `'replace'`: the child gets exactly `env`.
|
|
149
|
+
* Previously `runStreaming` always replaced; task 0060 R5 made merge the default.
|
|
150
|
+
*/
|
|
151
|
+
envMode?: 'merge' | 'replace';
|
|
137
152
|
label?: string;
|
|
138
153
|
/**
|
|
139
154
|
* Registry metadata (spur#0264). Defaults: source `'other'` for streaming.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"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"}
|
package/dist/process-executor.js
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { isatty } from 'node:tty';
|
|
2
2
|
import { execa } from 'execa';
|
|
3
|
+
import { getProcessEnv } from './config.js';
|
|
4
|
+
function processEventSeverity(reason, exitCode) {
|
|
5
|
+
if (reason === 'error')
|
|
6
|
+
return 'error';
|
|
7
|
+
if (reason === 'timeout')
|
|
8
|
+
return 'warning';
|
|
9
|
+
if (exitCode !== null && exitCode !== 0)
|
|
10
|
+
return 'warning';
|
|
11
|
+
return 'info';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the child env per the unified contract (task 0060 R5): a partial env
|
|
15
|
+
* extends the parent environment unless `envMode: 'replace'` opts out. Parent
|
|
16
|
+
* values can be `undefined` (deleted keys) — Bun's env is string-only, so filter them.
|
|
17
|
+
*/
|
|
18
|
+
function resolveChildEnv(env, envMode) {
|
|
19
|
+
if (envMode === 'replace')
|
|
20
|
+
return env;
|
|
21
|
+
const merged = {};
|
|
22
|
+
for (const [key, value] of Object.entries(getProcessEnv())) {
|
|
23
|
+
if (value !== undefined)
|
|
24
|
+
merged[key] = value;
|
|
25
|
+
}
|
|
26
|
+
return { ...merged, ...env };
|
|
27
|
+
}
|
|
3
28
|
// ── NodeProcessExecutor (concrete Node/Bun implementation) ───────────────
|
|
4
29
|
/**
|
|
5
30
|
* Concrete Node/Bun implementation of {@link ProcessExecutor}, wrapping `execa`
|
|
@@ -49,6 +74,7 @@ export class NodeProcessExecutor {
|
|
|
49
74
|
durationMs: 0,
|
|
50
75
|
reason: 'exit',
|
|
51
76
|
timestamp: startedIso,
|
|
77
|
+
severity: 'info',
|
|
52
78
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
53
79
|
});
|
|
54
80
|
try {
|
|
@@ -133,6 +159,7 @@ export class NodeProcessExecutor {
|
|
|
133
159
|
durationMs: 0,
|
|
134
160
|
reason: 'exit',
|
|
135
161
|
timestamp: startedIso,
|
|
162
|
+
severity: 'info',
|
|
136
163
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
137
164
|
});
|
|
138
165
|
const subprocess = Bun.spawn({
|
|
@@ -141,7 +168,7 @@ export class NodeProcessExecutor {
|
|
|
141
168
|
stdout: 'pipe',
|
|
142
169
|
stderr: 'pipe',
|
|
143
170
|
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
144
|
-
...(options.env !== undefined ? { env: options.env } : {}),
|
|
171
|
+
...(options.env !== undefined ? { env: resolveChildEnv(options.env, options.envMode) } : {}),
|
|
145
172
|
});
|
|
146
173
|
const pipe = new BunPipeProcess(subprocess);
|
|
147
174
|
if (pipe.pid !== null) {
|
|
@@ -165,6 +192,7 @@ export class NodeProcessExecutor {
|
|
|
165
192
|
durationMs: 0,
|
|
166
193
|
reason: 'error',
|
|
167
194
|
timestamp: new Date().toISOString(),
|
|
195
|
+
severity: processEventSeverity('error', null),
|
|
168
196
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
169
197
|
error: errorMessage(error),
|
|
170
198
|
});
|
|
@@ -214,6 +242,7 @@ export class NodeProcessExecutor {
|
|
|
214
242
|
durationMs: result.durationMs,
|
|
215
243
|
reason,
|
|
216
244
|
timestamp: new Date().toISOString(),
|
|
245
|
+
severity: processEventSeverity(reason, result.exitCode),
|
|
217
246
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
218
247
|
...(error !== undefined ? { error: errorMessage(error) } : {}),
|
|
219
248
|
});
|
|
@@ -243,6 +272,7 @@ class ObservedPipeProcess {
|
|
|
243
272
|
durationMs: Date.now() - context.startedAt,
|
|
244
273
|
reason: this.killedWith !== undefined ? 'signal' : 'exit',
|
|
245
274
|
timestamp: new Date().toISOString(),
|
|
275
|
+
severity: processEventSeverity(this.killedWith !== undefined ? 'signal' : 'exit', exitCode),
|
|
246
276
|
...(context.label !== undefined ? { label: context.label } : {}),
|
|
247
277
|
});
|
|
248
278
|
return exitCode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-validation.d.ts","sourceRoot":"","sources":["../src/schema-validation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAOhD,4GAA4G;AAC5G,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,wFAAwF;AACxF,MAAM,WAAW,UAAU;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACxC,oBAAoB,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IAC5C,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACtC;AAED,oGAAoG;AACpG,MAAM,WAAW,2BAA2B;IACxC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7C;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACtD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,IAAI,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;CAC7C;AAED,+GAA+G;AAC/G,qBAAa,2BAA4B,SAAQ,KAAK;IAG9C,QAAQ,CAAC,UAAU,EAAE,SAAS,mBAAmB,EAAE;gBADnD,OAAO,EAAE,MAAM,EACN,UAAU,GAAE,SAAS,mBAAmB,EAAO;CAK/D;AAED,6GAA6G;AAC7G,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,2BAAgC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGpH;AAED,qGAAqG;AACrG,wBAAsB,qBAAqB,CACvC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,OAAO,CAAC,CAMlB;
|
|
1
|
+
{"version":3,"file":"schema-validation.d.ts","sourceRoot":"","sources":["../src/schema-validation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAOhD,4GAA4G;AAC5G,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,wFAAwF;AACxF,MAAM,WAAW,UAAU;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACxC,oBAAoB,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IAC5C,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACtC;AAED,oGAAoG;AACpG,MAAM,WAAW,2BAA2B;IACxC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7C;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACtD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,IAAI,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;CAC7C;AAED,+GAA+G;AAC/G,qBAAa,2BAA4B,SAAQ,KAAK;IAG9C,QAAQ,CAAC,UAAU,EAAE,SAAS,mBAAmB,EAAE;gBADnD,OAAO,EAAE,MAAM,EACN,UAAU,GAAE,SAAS,mBAAmB,EAAO;CAK/D;AAED,6GAA6G;AAC7G,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,2BAAgC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGpH;AAED,qGAAqG;AACrG,wBAAsB,qBAAqB,CACvC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,OAAO,CAAC,CAMlB;AAWD,qHAAqH;AACrH,wBAAsB,0BAA0B,CAC5C,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,IAAI,CAAC,CA+Bf;AAED,wHAAwH;AACxH,wBAAgB,kBAAkB,CAC9B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,UAAU,EAClB,IAAI,SAAK,EACT,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAM,EACrC,QAAQ,GAAE,WAAW,CAAC,MAAM,CAAa,GAC1C,mBAAmB,EAAE,CAwDvB"}
|
|
@@ -19,12 +19,21 @@ export async function loadStructuredConfig(path, options = {}) {
|
|
|
19
19
|
}
|
|
20
20
|
/** Parses a config string (YAML or JSON) and validates against its declared `$schema` if present. */
|
|
21
21
|
export async function parseStructuredConfig(content, source, options = {}) {
|
|
22
|
-
const parsed = source.endsWith('.json') ?
|
|
22
|
+
const parsed = source.endsWith('.json') ? parseJsonContent(content, source) : parseYaml(content);
|
|
23
23
|
if (options.validateSchema !== false) {
|
|
24
24
|
await validateDeclaredJsonSchema(parsed, source, options);
|
|
25
25
|
}
|
|
26
26
|
return parsed;
|
|
27
27
|
}
|
|
28
|
+
/** Strict JSON parse that rethrows with the offending source named; config parse failures stay fail-loud. */
|
|
29
|
+
function parseJsonContent(content, source) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(content);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new StructuredConfigSchemaError(`Failed to parse JSON config "${source}": ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
28
37
|
/** Extracts the `$schema` reference from a parsed config object, resolves and fetches the schema, then validates. */
|
|
29
38
|
export async function validateDeclaredJsonSchema(value, source, options = {}) {
|
|
30
39
|
if (!isObject(value))
|
|
@@ -204,11 +213,14 @@ function splitPackageSpecifier(specifier) {
|
|
|
204
213
|
}
|
|
205
214
|
async function readSchema(schemaLocation, options) {
|
|
206
215
|
if (isRemoteRef(schemaLocation)) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
216
|
+
// Dual gate (task 0060 F2): remote fetch requires BOTH the explicit allowRemote opt-in
|
|
217
|
+
// AND an injected fetch implementation. `allowRemote` alone must not imply a built-in
|
|
218
|
+
// fetch (that path never existed and would be an unbounded SSRF/DoS surface); `fetch`
|
|
219
|
+
// alone must not silently enable network I/O the caller never authorised.
|
|
220
|
+
if (options.allowRemote !== true || options.fetch === undefined) {
|
|
221
|
+
throw new StructuredConfigSchemaError(`Refusing to fetch remote JSON schema "${schemaLocation}": pass both allowRemote: true and a fetch implementation to opt in`);
|
|
210
222
|
}
|
|
211
|
-
const response = await
|
|
223
|
+
const response = await options.fetch(schemaLocation);
|
|
212
224
|
if (!response.ok) {
|
|
213
225
|
throw new StructuredConfigSchemaError(`Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`);
|
|
214
226
|
}
|
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.31",
|
|
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.31",
|
|
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.31",
|
|
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.31"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@gobing-ai/ts-db": {
|
package/src/fs.ts
CHANGED
|
@@ -27,12 +27,17 @@ export async function atomicWriteJson(
|
|
|
27
27
|
await atomicWriteFile(path, `${JSON.stringify(value, null, 2)}\n`, fs);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Reads and parses a JSON file. */
|
|
30
|
+
/** Reads and parses a JSON file. Throws with the file named when the content is not valid JSON. */
|
|
31
31
|
export async function readJsonFile<T = unknown>(
|
|
32
32
|
path: string,
|
|
33
33
|
fs: CanonicalFileSystem = createNodeFileSystem(),
|
|
34
34
|
): Promise<T> {
|
|
35
|
-
|
|
35
|
+
const content = await fs.readFile(path);
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(content) as T;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`Failed to parse JSON file "${path}": ${(error as Error).message}`);
|
|
40
|
+
}
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/** Writes a value as JSON with 2-space indentation and trailing newline. */
|
|
@@ -47,25 +52,57 @@ export async function writeJsonFile(
|
|
|
47
52
|
/**
|
|
48
53
|
* Recursively walks a directory, returning sorted paths to all files,
|
|
49
54
|
* optionally excluding entries by name.
|
|
55
|
+
*
|
|
56
|
+
* Cycle-safe and root-confined (task 0060 F3): directory symlinks are tracked
|
|
57
|
+
* by their canonical real path, so a link back to an ancestor (or the start
|
|
58
|
+
* root itself) is walked once at most, and a link escaping the start root is
|
|
59
|
+
* skipped. In-root directory symlinks are still traversed — importer and
|
|
60
|
+
* rule-engine walk `$HOME/...` trees that are often symlinks into a dotfiles
|
|
61
|
+
* repo. When the filesystem has no `realPath` (in-memory / CF stubs), today's
|
|
62
|
+
* follow-`stat` behaviour is kept (no ambient capability).
|
|
50
63
|
*/
|
|
51
64
|
export async function walkDir(
|
|
52
65
|
path: string,
|
|
53
66
|
fs: CanonicalFileSystem = createNodeFileSystem(),
|
|
54
67
|
exclude?: Set<string>,
|
|
55
68
|
): Promise<string[]> {
|
|
56
|
-
const
|
|
69
|
+
const startReal = fs.realPath?.(path) ?? path;
|
|
70
|
+
const visited = new Set<string>([startReal]);
|
|
57
71
|
const result: string[] = [];
|
|
72
|
+
await walkDirInto(path, fs, exclude, startReal, visited, result);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function walkDirInto(
|
|
77
|
+
path: string,
|
|
78
|
+
fs: CanonicalFileSystem,
|
|
79
|
+
exclude: Set<string> | undefined,
|
|
80
|
+
startReal: string,
|
|
81
|
+
visited: Set<string>,
|
|
82
|
+
result: string[],
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const entries = (await fs.readDir(path)).sort();
|
|
58
85
|
for (const entry of entries) {
|
|
59
86
|
if (exclude?.has(entry)) continue;
|
|
60
87
|
const fullPath = joinPath(path, entry);
|
|
61
88
|
const entryStat = await fs.stat(fullPath);
|
|
62
89
|
if (entryStat?.isDirectory()) {
|
|
63
|
-
|
|
90
|
+
const entryReal = fs.realPath?.(fullPath) ?? fullPath;
|
|
91
|
+
if (visited.has(entryReal)) continue; // cycle or already-walked real dir
|
|
92
|
+
if (
|
|
93
|
+
fs.realPath !== undefined &&
|
|
94
|
+
entryReal !== startReal &&
|
|
95
|
+
!entryReal.startsWith(`${startReal}/`) &&
|
|
96
|
+
!entryReal.startsWith(`${startReal}\\`)
|
|
97
|
+
) {
|
|
98
|
+
continue; // symlink escapes the start root
|
|
99
|
+
}
|
|
100
|
+
visited.add(entryReal);
|
|
101
|
+
await walkDirInto(fullPath, fs, exclude, startReal, visited, result);
|
|
64
102
|
} else if (entryStat?.isFile()) {
|
|
65
103
|
result.push(fullPath);
|
|
66
104
|
}
|
|
67
105
|
}
|
|
68
|
-
return result;
|
|
69
106
|
}
|
|
70
107
|
|
|
71
108
|
/**
|
package/src/process-executor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isatty } from 'node:tty';
|
|
2
2
|
import { type Options as ExecaOptions, execa } from 'execa';
|
|
3
|
+
import { getProcessEnv } from './config';
|
|
3
4
|
import type { ProcessExecutionSource, ProcessRegistry } from './process-registry';
|
|
4
5
|
import type { RuntimePaths } from './runtime-paths';
|
|
5
6
|
|
|
@@ -48,6 +49,13 @@ export interface ProcessOptions {
|
|
|
48
49
|
args?: string[];
|
|
49
50
|
cwd?: string;
|
|
50
51
|
env?: Record<string, string>;
|
|
52
|
+
/**
|
|
53
|
+
* `'merge'` (default): a partial `env` extends the parent environment (execa
|
|
54
|
+
* `extendEnv: true` semantics). `'replace'`: the child gets exactly `env` and
|
|
55
|
+
* none of the parent variables. Task 0060 R5 unified the default to merge so
|
|
56
|
+
* `run` and `runStreaming` share one contract.
|
|
57
|
+
*/
|
|
58
|
+
envMode?: 'merge' | 'replace';
|
|
51
59
|
timeout?: number;
|
|
52
60
|
maxOutput?: number;
|
|
53
61
|
label?: string;
|
|
@@ -107,6 +115,13 @@ export interface ProcessResult {
|
|
|
107
115
|
/** Reason a process completion event was emitted. */
|
|
108
116
|
export type ProcessExitReason = 'exit' | 'signal' | 'timeout' | 'error';
|
|
109
117
|
|
|
118
|
+
function processEventSeverity(reason: ProcessExitReason, exitCode: number | null): ProcessEventDetail['severity'] {
|
|
119
|
+
if (reason === 'error') return 'error';
|
|
120
|
+
if (reason === 'timeout') return 'warning';
|
|
121
|
+
if (exitCode !== null && exitCode !== 0) return 'warning';
|
|
122
|
+
return 'info';
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
/** Payload emitted for process execution observability. */
|
|
111
126
|
export interface ProcessEventDetail {
|
|
112
127
|
command: string;
|
|
@@ -118,6 +133,8 @@ export interface ProcessEventDetail {
|
|
|
118
133
|
timestamp: string;
|
|
119
134
|
label?: string;
|
|
120
135
|
error?: string;
|
|
136
|
+
/** Producer-owned observability severity. */
|
|
137
|
+
severity: 'info' | 'warning' | 'error';
|
|
121
138
|
}
|
|
122
139
|
|
|
123
140
|
/** Zero-dependency structural event sink for process observability. */
|
|
@@ -142,6 +159,12 @@ export interface PipeProcessOptions {
|
|
|
142
159
|
args?: string[];
|
|
143
160
|
cwd?: string;
|
|
144
161
|
env?: Record<string, string>;
|
|
162
|
+
/**
|
|
163
|
+
* `'merge'` (default): a partial `env` extends the parent environment, matching
|
|
164
|
+
* {@link ProcessExecutor.run}. `'replace'`: the child gets exactly `env`.
|
|
165
|
+
* Previously `runStreaming` always replaced; task 0060 R5 made merge the default.
|
|
166
|
+
*/
|
|
167
|
+
envMode?: 'merge' | 'replace';
|
|
145
168
|
label?: string;
|
|
146
169
|
/**
|
|
147
170
|
* Registry metadata (spur#0264). Defaults: source `'other'` for streaming.
|
|
@@ -155,6 +178,23 @@ export interface PipeProcessOptions {
|
|
|
155
178
|
/** Signal values accepted by subprocess kill. */
|
|
156
179
|
type BunSubprocess = ReturnType<typeof Bun.spawn>;
|
|
157
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the child env per the unified contract (task 0060 R5): a partial env
|
|
183
|
+
* extends the parent environment unless `envMode: 'replace'` opts out. Parent
|
|
184
|
+
* values can be `undefined` (deleted keys) — Bun's env is string-only, so filter them.
|
|
185
|
+
*/
|
|
186
|
+
function resolveChildEnv(
|
|
187
|
+
env: Record<string, string>,
|
|
188
|
+
envMode: 'merge' | 'replace' | undefined,
|
|
189
|
+
): Record<string, string> {
|
|
190
|
+
if (envMode === 'replace') return env;
|
|
191
|
+
const merged: Record<string, string> = {};
|
|
192
|
+
for (const [key, value] of Object.entries(getProcessEnv())) {
|
|
193
|
+
if (value !== undefined) merged[key] = value;
|
|
194
|
+
}
|
|
195
|
+
return { ...merged, ...env };
|
|
196
|
+
}
|
|
197
|
+
|
|
158
198
|
/** Signal values accepted by subprocess kill (e.g. 'SIGTERM', 'SIGKILL'). */
|
|
159
199
|
export type ProcessSignal = Parameters<BunSubprocess['kill']>[0];
|
|
160
200
|
|
|
@@ -249,6 +289,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
249
289
|
durationMs: 0,
|
|
250
290
|
reason: 'exit',
|
|
251
291
|
timestamp: startedIso,
|
|
292
|
+
severity: 'info',
|
|
252
293
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
253
294
|
});
|
|
254
295
|
|
|
@@ -341,6 +382,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
341
382
|
durationMs: 0,
|
|
342
383
|
reason: 'exit',
|
|
343
384
|
timestamp: startedIso,
|
|
385
|
+
severity: 'info',
|
|
344
386
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
345
387
|
});
|
|
346
388
|
const subprocess = Bun.spawn({
|
|
@@ -349,7 +391,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
349
391
|
stdout: 'pipe',
|
|
350
392
|
stderr: 'pipe',
|
|
351
393
|
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
352
|
-
...(options.env !== undefined ? { env: options.env } : {}),
|
|
394
|
+
...(options.env !== undefined ? { env: resolveChildEnv(options.env, options.envMode) } : {}),
|
|
353
395
|
});
|
|
354
396
|
const pipe = new BunPipeProcess(subprocess);
|
|
355
397
|
if (pipe.pid !== null) {
|
|
@@ -372,6 +414,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
372
414
|
durationMs: 0,
|
|
373
415
|
reason: 'error',
|
|
374
416
|
timestamp: new Date().toISOString(),
|
|
417
|
+
severity: processEventSeverity('error', null),
|
|
375
418
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
376
419
|
error: errorMessage(error),
|
|
377
420
|
});
|
|
@@ -437,6 +480,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
437
480
|
durationMs: result.durationMs,
|
|
438
481
|
reason,
|
|
439
482
|
timestamp: new Date().toISOString(),
|
|
483
|
+
severity: processEventSeverity(reason, result.exitCode),
|
|
440
484
|
...(options.label !== undefined ? { label: options.label } : {}),
|
|
441
485
|
...(error !== undefined ? { error: errorMessage(error) } : {}),
|
|
442
486
|
});
|
|
@@ -479,6 +523,7 @@ class ObservedPipeProcess implements PipeProcess {
|
|
|
479
523
|
durationMs: Date.now() - context.startedAt,
|
|
480
524
|
reason: this.killedWith !== undefined ? 'signal' : 'exit',
|
|
481
525
|
timestamp: new Date().toISOString(),
|
|
526
|
+
severity: processEventSeverity(this.killedWith !== undefined ? 'signal' : 'exit', exitCode),
|
|
482
527
|
...(context.label !== undefined ? { label: context.label } : {}),
|
|
483
528
|
});
|
|
484
529
|
return exitCode;
|
package/src/schema-validation.ts
CHANGED
|
@@ -76,13 +76,22 @@ export async function parseStructuredConfig(
|
|
|
76
76
|
source: string,
|
|
77
77
|
options: StructuredConfigLoadOptions = {},
|
|
78
78
|
): Promise<unknown> {
|
|
79
|
-
const parsed = source.endsWith('.json') ?
|
|
79
|
+
const parsed = source.endsWith('.json') ? parseJsonContent(content, source) : parseYaml(content);
|
|
80
80
|
if (options.validateSchema !== false) {
|
|
81
81
|
await validateDeclaredJsonSchema(parsed, source, options);
|
|
82
82
|
}
|
|
83
83
|
return parsed;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** Strict JSON parse that rethrows with the offending source named; config parse failures stay fail-loud. */
|
|
87
|
+
function parseJsonContent(content: string, source: string): unknown {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(content);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new StructuredConfigSchemaError(`Failed to parse JSON config "${source}": ${(error as Error).message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
/** Extracts the `$schema` reference from a parsed config object, resolves and fetches the schema, then validates. */
|
|
87
96
|
export async function validateDeclaredJsonSchema(
|
|
88
97
|
value: unknown,
|
|
@@ -344,13 +353,16 @@ function splitPackageSpecifier(specifier: string): { pkg: string; subpath: strin
|
|
|
344
353
|
|
|
345
354
|
async function readSchema(schemaLocation: string, options: StructuredConfigLoadOptions): Promise<string> {
|
|
346
355
|
if (isRemoteRef(schemaLocation)) {
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
// Dual gate (task 0060 F2): remote fetch requires BOTH the explicit allowRemote opt-in
|
|
357
|
+
// AND an injected fetch implementation. `allowRemote` alone must not imply a built-in
|
|
358
|
+
// fetch (that path never existed and would be an unbounded SSRF/DoS surface); `fetch`
|
|
359
|
+
// alone must not silently enable network I/O the caller never authorised.
|
|
360
|
+
if (options.allowRemote !== true || options.fetch === undefined) {
|
|
349
361
|
throw new StructuredConfigSchemaError(
|
|
350
|
-
`Refusing to fetch remote JSON schema "${schemaLocation}": pass a fetch implementation to opt in`,
|
|
362
|
+
`Refusing to fetch remote JSON schema "${schemaLocation}": pass both allowRemote: true and a fetch implementation to opt in`,
|
|
351
363
|
);
|
|
352
364
|
}
|
|
353
|
-
const response = await
|
|
365
|
+
const response = await options.fetch(schemaLocation);
|
|
354
366
|
if (!response.ok) {
|
|
355
367
|
throw new StructuredConfigSchemaError(
|
|
356
368
|
`Failed to fetch JSON schema "${schemaLocation}": HTTP ${response.status}`,
|