@north-light/crouter 0.3.215 → 0.3.216

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.
@@ -1,2 +1 @@
1
- export declare function assertProfileDefaultKind(kind: string): void;
2
1
  export declare const kindLeaf: import("../../core/command.js").LeafDef;
@@ -1,19 +1,7 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
- import { InputError } from '../../core/io.js';
3
- import { readMergedLaunchConfig } from '../../core/config.js';
2
+ import { assertInstalledKind, readMergedLaunchConfig } from '../../core/config.js';
4
3
  import { loadProfileManifest, setProfileDefaultKind } from '../../core/profiles/manifest.js';
5
4
  import { stateBlock } from '../../core/help.js';
6
- export function assertProfileDefaultKind(kind) {
7
- const kinds = Object.keys(readMergedLaunchConfig().kinds).sort();
8
- if (!kinds.includes(kind)) {
9
- throw new InputError({
10
- error: 'unknown_kind',
11
- message: `unknown kind: ${kind}`,
12
- field: 'kind',
13
- next: `Valid kinds: ${kinds.join(', ')}.`,
14
- });
15
- }
16
- }
17
5
  function kindsStateBlock() {
18
6
  return stateBlock('kinds', { count: Object.keys(readMergedLaunchConfig().kinds).length }, Object.keys(readMergedLaunchConfig().kinds).sort().join('\n'));
19
7
  }
@@ -39,7 +27,7 @@ export const kindLeaf = defineLeaf({
39
27
  },
40
28
  run: async (input) => {
41
29
  const kind = input['kind'];
42
- assertProfileDefaultKind(kind);
30
+ assertInstalledKind(kind);
43
31
  const { profileId } = loadProfileManifest(input['profile']);
44
32
  const { manifest } = setProfileDefaultKind(profileId, kind);
45
33
  return {
@@ -5,7 +5,9 @@ import { loadProfileManifest, updateProfileMetadata } from '../../core/profiles/
5
5
  * with the manifest store (`assertProfileMetadata`); this only splits. */
6
6
  export function resolveMetaPairs(raw, field) {
7
7
  const pairs = Array.isArray(raw) ? raw.map(String) : typeof raw === 'string' && raw !== '' ? [raw] : [];
8
- const out = {};
8
+ // Null prototype: on a plain object a `__proto__` key would silently
9
+ // vanish here instead of reaching the store's key-shape rejection.
10
+ const out = Object.create(null);
9
11
  for (const pair of pairs) {
10
12
  const eq = pair.indexOf('=');
11
13
  if (eq <= 0) {
@@ -1,7 +1,7 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { usage } from '../../core/errors.js';
3
+ import { assertInstalledKind } from '../../core/config.js';
3
4
  import { createProfile, ensureRootProfile, profileRoot } from '../../core/profiles/manifest.js';
4
- import { assertProfileDefaultKind } from './kind.js';
5
5
  import { resolveMetaPairs } from './meta.js';
6
6
  export const newLeaf = defineLeaf({
7
7
  name: 'new',
@@ -23,7 +23,7 @@ export const newLeaf = defineLeaf({
23
23
  name: 'root',
24
24
  type: 'bool',
25
25
  required: false,
26
- constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`.',
26
+ constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`/`--default-kind`/`--meta`.',
27
27
  },
28
28
  {
29
29
  kind: 'flag',
@@ -88,7 +88,7 @@ export const newLeaf = defineLeaf({
88
88
  if (name === undefined)
89
89
  throw usage('provide `--name <name>` to create a profile, or `--root` to provision the stable root profile.');
90
90
  if (defaultKind !== undefined)
91
- assertProfileDefaultKind(defaultKind);
91
+ assertInstalledKind(defaultKind);
92
92
  const { profileId, manifest } = createProfile(name, project !== undefined ? [project] : [], {
93
93
  defaultKind,
94
94
  ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineLeaf } from '../../core/command.js';
4
- import { loadProfileManifest, profileRoot } from '../../core/profiles/manifest.js';
4
+ import { loadProfileManifest, profileRoot, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
5
5
  import { CONFIG_FILE } from '../../types.js';
6
6
  export const showLeaf = defineLeaf({
7
7
  name: 'show',
@@ -46,7 +46,7 @@ export const showLeaf = defineLeaf({
46
46
  home: manifest.home,
47
47
  paused_at: manifest.paused_at,
48
48
  default_kind: manifest.default_kind ?? null,
49
- metadata: manifest.metadata ?? {},
49
+ metadata: sanitizeProfileMetadata(manifest.metadata),
50
50
  created_at: manifest.created_at,
51
51
  last_used_at: manifest.last_used_at,
52
52
  path: profileRoot(profileId),
@@ -141,6 +141,11 @@ export declare function readMergedLaunchConfig(targetCwd?: string, targetProfile
141
141
  * existence/launch-menu enumeration is a caller concern); this only
142
142
  * resolves the config for a kind the caller already knows about. */
143
143
  export declare function resolveKindConfig(kind: string): KindConfig | undefined;
144
+ /** Reject a kind no scope registers. The one validation gate for every
145
+ * surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
146
+ * route) — a stored unknown kind would poison future omitted-kind creates
147
+ * through `default_kind` resolution. */
148
+ export declare function assertInstalledKind(kind: string): void;
144
149
  /** The sub-kinds available to spawn FROM a given top-level kind — every
145
150
  * registered sub-kind (full path contains `/`) whose `availableTo` (default:
146
151
  * its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
@@ -2,6 +2,7 @@ import { dirname, join } from 'node:path';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
3
  import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, WHIP_MESSAGE_MODES, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
4
4
  import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings/catalog.js';
5
+ import { usage } from './errors.js';
5
6
  import { emitEvent } from './events/emit.js';
6
7
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
7
8
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
@@ -753,6 +754,20 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
753
754
  export function resolveKindConfig(kind) {
754
755
  return readMergedLaunchConfig().kinds[kind];
755
756
  }
757
+ /** Reject a kind no scope registers. The one validation gate for every
758
+ * surface that PERSISTS a kind (`profile new/kind`, the profile-ensure API
759
+ * route) — a stored unknown kind would poison future omitted-kind creates
760
+ * through `default_kind` resolution. */
761
+ export function assertInstalledKind(kind) {
762
+ const kinds = Object.keys(readMergedLaunchConfig().kinds).sort();
763
+ if (!kinds.includes(kind)) {
764
+ throw usage(`unknown kind: ${kind}`, {
765
+ received: kind,
766
+ field: 'kind',
767
+ next: `Valid kinds: ${kinds.join(', ')}.`,
768
+ });
769
+ }
770
+ }
756
771
  /** The sub-kinds available to spawn FROM a given top-level kind — every
757
772
  * registered sub-kind (full path contains `/`) whose `availableTo` (default:
758
773
  * its own top-level ancestor, e.g. `plan/reviewers/security` defaults to
@@ -39,7 +39,19 @@ export declare function loadProfileManifest(profileIdOrName: string): ProfileEnt
39
39
  * below run inside this. */
40
40
  export declare function withProfileManifestLock<T>(profileId: string, fn: () => T): T;
41
41
  export declare function ensureRootProfile(): ProfileEntry;
42
- export declare function assertProfileMetadata(entries: Record<string, string>): void;
42
+ /** The env name a metadata entry surfaces as (spawn-env source F). Also the
43
+ * uniqueness domain for keys: the fold is lossy (`a-b`, `a_b`, `A_B` →
44
+ * `CRTR_PROFILE_META_A_B`), so two stored keys may not share a fold. */
45
+ export declare function metadataEnvName(key: string): string;
46
+ /** Reject anything a valid metadata map may not carry. Takes `unknown`
47
+ * because the API route hands over a caller-supplied body slice — the
48
+ * TypeScript type on the DTO proves nothing at runtime. */
49
+ export declare function assertProfileMetadata(entries: unknown): asserts entries is Record<string, string>;
50
+ /** Drop entries no valid mutation could have written — a manifest is
51
+ * hand-editable, so a projection or merge base must never trust it raw.
52
+ * Never throws; on an env-name collision the first entry wins (matching
53
+ * injection, where a deterministic winner beats insertion-order luck). */
54
+ export declare function sanitizeProfileMetadata(stored: unknown): Record<string, string>;
43
55
  export declare function createProfile(name: string, projects?: string[], opts?: {
44
56
  defaultKind?: string;
45
57
  metadata?: Record<string, string>;
@@ -295,7 +295,23 @@ export function ensureRootProfile() {
295
295
  * (`CRTR_PROFILE_META_<KEY>`, see `core/runtime/spawn-env.ts`) by
296
296
  * construction. */
297
297
  const METADATA_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
298
+ /** The env name a metadata entry surfaces as (spawn-env source F). Also the
299
+ * uniqueness domain for keys: the fold is lossy (`a-b`, `a_b`, `A_B` →
300
+ * `CRTR_PROFILE_META_A_B`), so two stored keys may not share a fold. */
301
+ export function metadataEnvName(key) {
302
+ return `CRTR_PROFILE_META_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
303
+ }
304
+ /** Reject anything a valid metadata map may not carry. Takes `unknown`
305
+ * because the API route hands over a caller-supplied body slice — the
306
+ * TypeScript type on the DTO proves nothing at runtime. */
298
307
  export function assertProfileMetadata(entries) {
308
+ if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) {
309
+ throw usage('metadata must be a map of string keys to string values', {
310
+ field: 'metadata',
311
+ next: 'Pass an object, e.g. {"role": "support"}.',
312
+ });
313
+ }
314
+ const claimed = new Map();
299
315
  for (const [key, value] of Object.entries(entries)) {
300
316
  if (!METADATA_KEY_SHAPE.test(key)) {
301
317
  throw usage(`invalid metadata key: ${JSON.stringify(key)}`, {
@@ -304,14 +320,54 @@ export function assertProfileMetadata(entries) {
304
320
  next: 'Keys start with a letter or digit, followed by letters, digits, `_`, or `-`.',
305
321
  });
306
322
  }
323
+ if (typeof value !== 'string') {
324
+ throw usage(`metadata value for ${key} must be a string`, {
325
+ received: typeof value,
326
+ field: 'metadata',
327
+ next: 'Every metadata value is an env-var value — pass strings only.',
328
+ });
329
+ }
307
330
  if (value.includes('\0')) {
308
331
  throw usage(`metadata value for ${key} contains a NUL byte, which no OS accepts in an environment variable`, {
309
332
  field: 'metadata',
310
333
  next: 'Remove NUL bytes from the value and retry.',
311
334
  });
312
335
  }
336
+ const envName = metadataEnvName(key);
337
+ const prior = claimed.get(envName);
338
+ if (prior !== undefined) {
339
+ throw usage(`metadata keys ${JSON.stringify(prior)} and ${JSON.stringify(key)} both surface as ${envName}`, {
340
+ field: 'metadata',
341
+ next: 'Rename or drop one — keys must stay distinct after uppercasing and `_`-folding.',
342
+ });
343
+ }
344
+ claimed.set(envName, key);
313
345
  }
314
346
  }
347
+ /** Drop entries no valid mutation could have written — a manifest is
348
+ * hand-editable, so a projection or merge base must never trust it raw.
349
+ * Never throws; on an env-name collision the first entry wins (matching
350
+ * injection, where a deterministic winner beats insertion-order luck). */
351
+ export function sanitizeProfileMetadata(stored) {
352
+ if (typeof stored !== 'object' || stored === null || Array.isArray(stored))
353
+ return {};
354
+ const out = {};
355
+ const claimed = new Set();
356
+ for (const [key, value] of Object.entries(stored)) {
357
+ if (typeof value !== 'string')
358
+ continue;
359
+ if (!METADATA_KEY_SHAPE.test(key))
360
+ continue;
361
+ if (value.includes('\0'))
362
+ continue;
363
+ const envName = metadataEnvName(key);
364
+ if (claimed.has(envName))
365
+ continue;
366
+ claimed.add(envName);
367
+ out[key] = value;
368
+ }
369
+ return out;
370
+ }
315
371
  export function createProfile(name, projects = [], opts = {}) {
316
372
  const trimmed = name.trim();
317
373
  if (trimmed === '')
@@ -420,9 +476,13 @@ export function setProfileDefaultKind(profileId, defaultKind) {
420
476
  export function updateProfileMetadata(profileId, set, unset = []) {
421
477
  assertProfileMetadata(set);
422
478
  return mutateManifest(profileId, (m) => {
423
- const next = { ...(m.metadata ?? {}), ...set };
479
+ const next = { ...sanitizeProfileMetadata(m.metadata), ...set };
424
480
  for (const key of unset)
425
481
  delete next[key];
482
+ // Re-assert the merged result: a set key may collide with a STORED key in
483
+ // the env-name domain (`a-b` over stored `a_b`), which per-argument
484
+ // validation cannot see.
485
+ assertProfileMetadata(next);
426
486
  const { metadata: _dropped, ...rest } = m;
427
487
  return { ...rest, ...(Object.keys(next).length > 0 ? { metadata: next } : {}) };
428
488
  });
@@ -436,18 +496,7 @@ export function readProfileMetadata(profileId) {
436
496
  if (profileId === null || profileId === '')
437
497
  return {};
438
498
  try {
439
- const stored = loadExactProfileManifest(profileId).manifest.metadata ?? {};
440
- const out = {};
441
- for (const [key, value] of Object.entries(stored)) {
442
- if (typeof value !== 'string')
443
- continue;
444
- if (!METADATA_KEY_SHAPE.test(key))
445
- continue;
446
- if (value.includes('\0'))
447
- continue;
448
- out[key] = value;
449
- }
450
- return out;
499
+ return sanitizeProfileMetadata(loadExactProfileManifest(profileId).manifest.metadata);
451
500
  }
452
501
  catch {
453
502
  return {};
@@ -33,7 +33,7 @@ import { statSync } from 'node:fs';
33
33
  import { delimiter, resolve } from 'node:path';
34
34
  import { readMergedLaunchConfig } from '../config.js';
35
35
  import { readProfileEnvVars } from '../profiles/env-store.js';
36
- import { readProfileMetadata } from '../profiles/manifest.js';
36
+ import { metadataEnvName, readProfileMetadata } from '../profiles/manifest.js';
37
37
  import { binShimDirFor } from './bin-contributions.js';
38
38
  import { FRONT_DOOR_ENV } from './front-door-env.js';
39
39
  /** A1 — literal env-var names copied verbatim (no value inspection): a login
@@ -220,7 +220,7 @@ export function buildOperationalEnvBase(opts) {
220
220
  export function profileMetadataEnv(profileId) {
221
221
  const out = {};
222
222
  for (const [key, value] of Object.entries(readProfileMetadata(profileId))) {
223
- out[`CRTR_PROFILE_META_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = value;
223
+ out[metadataEnvName(key)] = value;
224
224
  }
225
225
  return out;
226
226
  }
@@ -6,6 +6,7 @@
6
6
  // GET /v1/profiles list all profiles
7
7
  // GET /v1/profiles/:name resolve one profile by name or id
8
8
  // DELETE /v1/profiles/:name required-force cascade or detach
9
+ import { assertInstalledKind } from '../../../core/config.js';
9
10
  import { createProfile, ensureRootProfile, listProfiles, loadProfileManifest, } from '../../../core/profiles/manifest.js';
10
11
  import { requestProfileDeletion } from '../../profile-delete.js';
11
12
  import { toProfileDTO } from '../map.js';
@@ -25,6 +26,10 @@ function handleEnsure(ctx) {
25
26
  if (existing !== undefined) {
26
27
  return { status: 200, body: toProfileDTO(existing) };
27
28
  }
29
+ // Same gate as `profile new`/`profile kind`: a persisted unknown kind would
30
+ // poison every future omitted-kind create under this profile.
31
+ if (req.default_kind !== undefined)
32
+ assertInstalledKind(req.default_kind);
28
33
  const created = createProfile(name, req.projects ?? [], {
29
34
  defaultKind: req.default_kind,
30
35
  metadata: req.metadata,
@@ -10,6 +10,7 @@
10
10
  // reasonable projections; this module is what makes them concrete, so any DTO
11
11
  // field that projection proves wrong is corrected here at the source.
12
12
  import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
13
+ import { sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
13
14
  import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
14
15
  import { FinalizationError } from '../../core/feed/feed.js';
15
16
  import { NodeIdConflictError } from '../../core/runtime/nodes.js';
@@ -266,7 +267,7 @@ export function toProfileDTO(entry) {
266
267
  home: entry.manifest.home,
267
268
  paused_at: entry.manifest.paused_at,
268
269
  default_kind: entry.manifest.default_kind ?? 'general',
269
- metadata: { ...(entry.manifest.metadata ?? {}) },
270
+ metadata: sanitizeProfileMetadata(entry.manifest.metadata),
270
271
  default_dir: null,
271
272
  };
272
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.215",
3
+ "version": "0.3.216",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.215",
3
+ "version": "0.3.216",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.215",
9
+ "version": "0.3.216",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {