@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.
@@ -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
  }
@@ -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;
@@ -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) {
@@ -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;
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
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
- if (readFileSync(layoutVersionFile(), 'utf8').trim() !== LAYOUT_VERSION)
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
- mkdirSync(dir, { recursive: true, mode: 0o700 });
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
- writeFileSync(layoutVersionFile(), `${LAYOUT_VERSION}\n`, { flag: 'wx', mode: 0o600 });
139
+ publishFileExclusive(layoutVersionFile(), `${LAYOUT_VERSION}\n`, 0o600);
112
140
  }
@@ -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
- writeFileSync(this.path, this.initialContent, WRITE_OPTIONS);
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);
@@ -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;
@@ -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.some((held) => covers(held, scope));
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.440",
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",