@north-light/api 0.3.440 → 0.3.442
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/dist/core/canvas/paths.d.ts +2 -0
- package/dist/core/canvas/paths.js +4 -0
- package/dist/core/fs-utils.d.ts +7 -0
- package/dist/core/fs-utils.js +40 -1
- package/dist/core/layout.d.ts +8 -0
- package/dist/core/layout.js +34 -6
- package/dist/core/locked-file.js +4 -1
- package/dist/core/scoped-state/db.js +6 -0
- package/dist/shared/env.d.ts +7 -0
- package/dist/shared/env.js +22 -1
- package/package.json +1 -1
|
@@ -39,6 +39,8 @@ export declare function isolatedNodeHome(nodeId: string, grantee: string): strin
|
|
|
39
39
|
/** The HOME a node's processes get: its own home in an isolated run, else the app's. */
|
|
40
40
|
export declare function nodeHome(nodeId: string, grantee: string, isolated: boolean): string;
|
|
41
41
|
export declare function nodeRunDir(nodeId: string): string;
|
|
42
|
+
/** The run's scope allow-list as a JSON array, beside `broker-launch.json`. */
|
|
43
|
+
export declare function scopesFilePath(nodeId: string): string;
|
|
42
44
|
export declare function contextDir(nodeId: string, grantee?: string): string;
|
|
43
45
|
/** Replace the literal env-var tokens `$CRTR_CONTEXT_DIR` / `$CRTR_NODE_ID` in
|
|
44
46
|
* agent-facing doc text with the node's real values, so a doc surfaced INTO a
|
|
@@ -96,6 +96,10 @@ export function nodeRunDir(nodeId) {
|
|
|
96
96
|
assertSafeNodeId(nodeId);
|
|
97
97
|
return process.platform === 'linux' ? join(socketDir(), 'nodes', nodeId) : join(nodeRecordDir(nodeId), 'run');
|
|
98
98
|
}
|
|
99
|
+
/** The run's scope allow-list as a JSON array, beside `broker-launch.json`. */
|
|
100
|
+
export function scopesFilePath(nodeId) {
|
|
101
|
+
return join(nodeRunDir(nodeId), 'scopes.json');
|
|
102
|
+
}
|
|
99
103
|
export function contextDir(nodeId, grantee) {
|
|
100
104
|
return join(nodeAgentDir(nodeId, grantee), 'context');
|
|
101
105
|
}
|
package/dist/core/fs-utils.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export declare function syncDirectory(path: string): void;
|
|
|
27
27
|
* tmux inbox watcher, or crtrd serving a ticket read — sees either the old
|
|
28
28
|
* bytes or the new ones and never a truncated file mid-write. */
|
|
29
29
|
export declare function atomicWriteText(path: string, content: string | Uint8Array, opts?: AtomicWriteOptions): void;
|
|
30
|
+
/** Publish `path` with `content` so it is never observable empty or partial, and
|
|
31
|
+
* never replaces an existing file. The bytes reach stable storage under a
|
|
32
|
+
* temporary name first; a hard link then makes them appear at `path` in one
|
|
33
|
+
* step and fails with EEXIST if another initializer won. The containing
|
|
34
|
+
* directory is fsynced so the new entry survives a volume handoff or power loss.
|
|
35
|
+
* Returns false when `path` already existed. */
|
|
36
|
+
export declare function publishFileExclusive(path: string, content: string, mode: number): boolean;
|
|
30
37
|
/** Fsync every file and directory under `root` (and `root` itself), so a tree about to be renamed into place
|
|
31
38
|
* reaches stable storage before the rename can. */
|
|
32
39
|
export declare function syncTree(root: string): void;
|
package/dist/core/fs-utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, closeSync, fsyncSync, openSync, } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, closeSync, fsyncSync, openSync, linkSync, unlinkSync, writeSync, } from 'node:fs';
|
|
2
2
|
import { dirname, join, relative, sep } from 'node:path';
|
|
3
3
|
import { homedir, platform } from 'node:os';
|
|
4
4
|
/** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
|
|
@@ -80,6 +80,45 @@ export function atomicWriteText(path, content, opts) {
|
|
|
80
80
|
rmSync(temp, { force: true });
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
/** Publish `path` with `content` so it is never observable empty or partial, and
|
|
84
|
+
* never replaces an existing file. The bytes reach stable storage under a
|
|
85
|
+
* temporary name first; a hard link then makes them appear at `path` in one
|
|
86
|
+
* step and fails with EEXIST if another initializer won. The containing
|
|
87
|
+
* directory is fsynced so the new entry survives a volume handoff or power loss.
|
|
88
|
+
* Returns false when `path` already existed. */
|
|
89
|
+
export function publishFileExclusive(path, content, mode) {
|
|
90
|
+
const temporary = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
91
|
+
const fd = openSync(temporary, 'wx', mode);
|
|
92
|
+
try {
|
|
93
|
+
try {
|
|
94
|
+
const bytes = Buffer.from(content, 'utf8');
|
|
95
|
+
for (let offset = 0; offset < bytes.length;)
|
|
96
|
+
offset += writeSync(fd, bytes, offset, bytes.length - offset);
|
|
97
|
+
fsyncSync(fd);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
linkSync(temporary, path);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
// No in-place fallback for a filesystem without hard links: creating the final name before its
|
|
107
|
+
// bytes are durable is exactly the empty marker this exists to prevent, so that fails loudly.
|
|
108
|
+
if (error.code === 'EEXIST')
|
|
109
|
+
return false;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
syncDirectory(dirname(path));
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(temporary);
|
|
118
|
+
}
|
|
119
|
+
catch { /* already gone */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
83
122
|
/** Fsync every file and directory under `root` (and `root` itself), so a tree about to be renamed into place
|
|
84
123
|
* reaches stable storage before the rename can. */
|
|
85
124
|
export function syncTree(root) {
|
package/dist/core/layout.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export declare function cronFolder(app: string, cronId: string): string;
|
|
|
31
31
|
* and the terminal wherever a sandbox exists, keeps its own app home. Callers
|
|
32
32
|
* keep `PI_CODING_AGENT_DIR` in the app home either way. */
|
|
33
33
|
export declare function appProcessHome(app: string): string;
|
|
34
|
+
/** The marker's byte length and an escaped, truncated preview, safe to print: a
|
|
35
|
+
* corrupt marker (empty, truncated, NUL-filled by a lost write) is otherwise
|
|
36
|
+
* indistinguishable from an intentional newer layout in a crash log. */
|
|
37
|
+
export declare function describeLayoutMarker(bytes: Uint8Array): string;
|
|
38
|
+
/** Refuse any marker other than the supported version. A non-matching marker is
|
|
39
|
+
* never rewritten: an empty or damaged one means the volume lost a write, and
|
|
40
|
+
* stamping the current version over it would assert a layout nobody verified. */
|
|
41
|
+
export declare function assertSupportedLayoutMarker(path: string): void;
|
|
34
42
|
/** A missing version on an occupied volume is never initialized by daemon startup. */
|
|
35
43
|
export declare function layoutNeedsMigration(): boolean;
|
|
36
44
|
export declare function initializeEmptyLayout(): void;
|
package/dist/core/layout.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, statSync
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir, userInfo } from 'node:os';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { envHomeOverride } from '../shared/env.js';
|
|
5
5
|
import { readConfig } from './config.js';
|
|
6
|
+
import { publishFileExclusive, syncDirectory } from './fs-utils.js';
|
|
6
7
|
export const LAYOUT_VERSION = '1';
|
|
7
8
|
/** CRTR_HOME names the volume root, not the canvas records directory. */
|
|
8
9
|
export function layoutRoot() {
|
|
@@ -81,13 +82,29 @@ export function appProcessHome(app) {
|
|
|
81
82
|
return space.home;
|
|
82
83
|
return basename(space.root) === 'terminal' ? userFilesRoot() : space.home;
|
|
83
84
|
}
|
|
85
|
+
/** The marker's byte length and an escaped, truncated preview, safe to print: a
|
|
86
|
+
* corrupt marker (empty, truncated, NUL-filled by a lost write) is otherwise
|
|
87
|
+
* indistinguishable from an intentional newer layout in a crash log. */
|
|
88
|
+
export function describeLayoutMarker(bytes) {
|
|
89
|
+
const preview = Array.from(bytes.subarray(0, 32), (byte) => byte === 0x5c ? '\\\\' : byte === 0x22 ? '\\"' : byte === 0x0a ? '\\n' : byte === 0x0d ? '\\r' : byte === 0x09 ? '\\t'
|
|
90
|
+
: byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : `\\x${byte.toString(16).padStart(2, '0')}`).join('');
|
|
91
|
+
return `${bytes.length} bytes, preview "${preview}"${bytes.length > 32 ? '…' : ''}`;
|
|
92
|
+
}
|
|
93
|
+
/** Refuse any marker other than the supported version. A non-matching marker is
|
|
94
|
+
* never rewritten: an empty or damaged one means the volume lost a write, and
|
|
95
|
+
* stamping the current version over it would assert a layout nobody verified. */
|
|
96
|
+
export function assertSupportedLayoutMarker(path) {
|
|
97
|
+
const bytes = readFileSync(path);
|
|
98
|
+
if (bytes.toString('utf8').trim() !== LAYOUT_VERSION) {
|
|
99
|
+
throw new Error(`unsupported runtime layout version: ${path} holds ${describeLayoutMarker(bytes)}; expected "${LAYOUT_VERSION}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
84
102
|
/** A missing version on an occupied volume is never initialized by daemon startup. */
|
|
85
103
|
export function layoutNeedsMigration() {
|
|
86
104
|
if (process.env['CRTR_LAYOUT_REFUSE'] === '1')
|
|
87
105
|
return true;
|
|
88
106
|
if (existsSync(layoutVersionFile())) {
|
|
89
|
-
|
|
90
|
-
throw new Error('unsupported runtime layout version');
|
|
107
|
+
assertSupportedLayoutMarker(layoutVersionFile());
|
|
91
108
|
return false;
|
|
92
109
|
}
|
|
93
110
|
const root = layoutRoot();
|
|
@@ -102,11 +119,22 @@ export function layoutNeedsMigration() {
|
|
|
102
119
|
export function initializeEmptyLayout() {
|
|
103
120
|
if (layoutNeedsMigration())
|
|
104
121
|
throw new Error('layout_migration_required');
|
|
105
|
-
for (const dir of [runtimeRoot(), userRoot(), appsRoot(), secretsDir()])
|
|
106
|
-
|
|
122
|
+
for (const dir of [runtimeRoot(), userRoot(), appsRoot(), secretsDir()]) {
|
|
123
|
+
// mkdir returns the first directory it had to create; every entry from there down to
|
|
124
|
+
// `dir` is new, and so is the entry for the first one in its (existing) parent.
|
|
125
|
+
const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
126
|
+
if (created === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
for (let entry = dir;; entry = dirname(entry)) {
|
|
129
|
+
syncDirectory(entry);
|
|
130
|
+
if (entry === created)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
syncDirectory(dirname(created));
|
|
134
|
+
}
|
|
107
135
|
mkdirSync(socketDir(), { recursive: true, mode: 0o755 });
|
|
108
136
|
if (process.platform === 'linux' && (statSync(socketDir()).mode & 0o777) !== 0o755)
|
|
109
137
|
throw new Error('launcher socket directory must be mode 0755');
|
|
110
138
|
if (!existsSync(layoutVersionFile()))
|
|
111
|
-
|
|
139
|
+
publishFileExclusive(layoutVersionFile(), `${LAYOUT_VERSION}\n`, 0o600);
|
|
112
140
|
}
|
package/dist/core/locked-file.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { chmodSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, closeSync } from 'node:fs';
|
|
16
16
|
import { dirname } from 'node:path';
|
|
17
17
|
import lockfile from 'proper-lockfile';
|
|
18
|
+
import { publishFileExclusive } from './fs-utils.js';
|
|
18
19
|
const WRITE_OPTIONS = { encoding: 'utf-8', mode: 0o600 };
|
|
19
20
|
// proper-lockfile decides a held lock is abandoned when its lockdir mtime is older than
|
|
20
21
|
// the acquirer's `stale`, and refreshes the mtime on the holder's `update` timer. The holder
|
|
@@ -39,7 +40,9 @@ export class LockedJsonFile {
|
|
|
39
40
|
if (!existsSync(dir))
|
|
40
41
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
42
|
if (!existsSync(this.path)) {
|
|
42
|
-
|
|
43
|
+
// Published whole and exclusively: another process may be creating it too, and a
|
|
44
|
+
// reader (or a lost write) must never see the empty file a plain create leaves.
|
|
45
|
+
publishFileExclusive(this.path, this.initialContent, 0o600);
|
|
43
46
|
chmodSync(this.path, 0o600);
|
|
44
47
|
}
|
|
45
48
|
}
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import lockfile from 'proper-lockfile';
|
|
6
6
|
import { CONFIG_FILE, PROFILE_DIR, STATE_FILE } from '../../types.js';
|
|
7
7
|
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
8
|
+
import { syncDirectory, syncFile } from '../fs-utils.js';
|
|
8
9
|
import { layoutNeedsMigration } from '../layout.js';
|
|
9
10
|
import { providerStore, projectStore, userStore } from './paths.js';
|
|
10
11
|
import { createScopedSchema, ensureProviderModelCooldowns, PROVIDER_SCHEMA_VERSION, SCOPED_SCHEMA_VERSION } from './schema.js';
|
|
@@ -218,6 +219,11 @@ export function createScopedStore(store, create) {
|
|
|
218
219
|
secure(store.path);
|
|
219
220
|
db.close();
|
|
220
221
|
}
|
|
222
|
+
// Closing checkpoints the WAL into the database file. Force the finished file and its
|
|
223
|
+
// directory entry to stable storage so a volume handed off right after first boot never
|
|
224
|
+
// comes back with the empty, schema-less file that `scoped SQL store is uninitialized` names.
|
|
225
|
+
syncFile(store.path);
|
|
226
|
+
syncDirectory(store.root);
|
|
221
227
|
}
|
|
222
228
|
export function withScopedWrite(store, run) {
|
|
223
229
|
const db = openScopedStore(store);
|
package/dist/shared/env.d.ts
CHANGED
|
@@ -18,10 +18,17 @@ export declare function envGrantee(): string | undefined;
|
|
|
18
18
|
* that variable is not this signal. */
|
|
19
19
|
export declare const INBOX_BETWEEN_TURNS_ENV = "CRTR_INBOX_BETWEEN_TURNS";
|
|
20
20
|
export declare function envInboxBetweenTurns(): boolean;
|
|
21
|
+
/** Path of the JSON file holding the run's scope allow-list (set by `nodeEnv`). */
|
|
22
|
+
export declare const SCOPES_FILE_ENV = "CRTR_SCOPES_FILE";
|
|
21
23
|
/** The current run's scope allow-list; null means the row inherits everything. */
|
|
22
24
|
export declare function envScopes(): readonly string[] | null;
|
|
23
25
|
/** Whether a launched scope list covers a capability. Null means not narrowed. */
|
|
24
26
|
export declare function scopeAllowed(scopes: readonly string[] | null | undefined, scope: string): boolean;
|
|
27
|
+
/** Whether any held scope covers `scope`. A held scope equal to the request always covers it, so the
|
|
28
|
+
* common case of a run narrowed to a subset of its grant is a string scan instead of `covers` on every
|
|
29
|
+
* pair (a 10,000-scope grant checked against 10,000 scopes is otherwise ~15 s). A request `covers`
|
|
30
|
+
* would reject as unparseable still throws. */
|
|
31
|
+
export declare function coveredByAny(held: readonly string[], scope: string): boolean;
|
|
25
32
|
export declare function envExecutionId(): string | undefined;
|
|
26
33
|
/** The current node's target profile id (`CRTR_PROFILE_ID`), or undefined. */
|
|
27
34
|
export declare function envProfileId(): string | undefined;
|
package/dist/shared/env.js
CHANGED
|
@@ -75,8 +75,18 @@ export const INBOX_BETWEEN_TURNS_ENV = 'CRTR_INBOX_BETWEEN_TURNS';
|
|
|
75
75
|
export function envInboxBetweenTurns() {
|
|
76
76
|
return process.env[INBOX_BETWEEN_TURNS_ENV] === '1';
|
|
77
77
|
}
|
|
78
|
+
/** Path of the JSON file holding the run's scope allow-list (set by `nodeEnv`). */
|
|
79
|
+
export const SCOPES_FILE_ENV = 'CRTR_SCOPES_FILE';
|
|
78
80
|
/** The current run's scope allow-list; null means the row inherits everything. */
|
|
79
81
|
export function envScopes() {
|
|
82
|
+
const file = process.env[SCOPES_FILE_ENV];
|
|
83
|
+
if (file !== undefined && file !== '') {
|
|
84
|
+
// A listed file that cannot be read must not widen the run: fail closed.
|
|
85
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
86
|
+
if (!Array.isArray(parsed) || parsed.some((scope) => typeof scope !== 'string'))
|
|
87
|
+
throw new Error(`invalid scopes file ${file}`);
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
80
90
|
const raw = process.env['CRTR_SCOPES'];
|
|
81
91
|
return raw === undefined ? null : raw === '' ? [] : raw.split(',');
|
|
82
92
|
}
|
|
@@ -84,7 +94,18 @@ export function envScopes() {
|
|
|
84
94
|
export function scopeAllowed(scopes, scope) {
|
|
85
95
|
if (scopes === null || scopes === undefined)
|
|
86
96
|
return true;
|
|
87
|
-
return scopes
|
|
97
|
+
return coveredByAny(scopes, scope);
|
|
98
|
+
}
|
|
99
|
+
/** Whether any held scope covers `scope`. A held scope equal to the request always covers it, so the
|
|
100
|
+
* common case of a run narrowed to a subset of its grant is a string scan instead of `covers` on every
|
|
101
|
+
* pair (a 10,000-scope grant checked against 10,000 scopes is otherwise ~15 s). A request `covers`
|
|
102
|
+
* would reject as unparseable still throws. */
|
|
103
|
+
export function coveredByAny(held, scope) {
|
|
104
|
+
if (held.includes(scope)) {
|
|
105
|
+
covers(scope, scope);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return held.some((h) => covers(h, scope));
|
|
88
109
|
}
|
|
89
110
|
export function envExecutionId() {
|
|
90
111
|
return process.env['CRTR_EXECUTION_ID'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.442",
|
|
4
4
|
"description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/api/index.js",
|