@north-light/crouter 0.3.215 → 0.3.217

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.
@@ -5,7 +5,7 @@ import { z } from 'zod';
5
5
  import { buildPageSummary } from './summary.js';
6
6
  import { atomicWriteJson, atomicWriteText, clearProgress, claimPath, pageManifestPath, pageModulePath, pagePath, progressPath, publishJsonExclusive, requireTicket, responsePath, reviewPath } from './convention.js';
7
7
  import { validateReviewDescriptor, validateReviewProjection } from './review-schema.js';
8
- import { compilePage, deriveHtmlPageManifest, derivePageManifestFromDocument, parsePage, PageAuthoringError } from './page.js';
8
+ import { compilePage, deriveHtmlPageManifest, derivePageManifestFromDocument, parsePage, parsePageDelivery, PageAuthoringError } from './page.js';
9
9
  import { isResponseBearingSlot, validatePageResponses, validatePartialPageResponses } from './page-schema.js';
10
10
  import { signalInboxActivity } from './root.js';
11
11
  import { readTicketClaim, releaseClaimLocked, withTicketLock } from './claim.js';
@@ -145,6 +145,13 @@ function requireSettleablePage(dir) {
145
145
  throw new Error('page_has_no_response_lifecycle');
146
146
  return manifest;
147
147
  }
148
+ function requireCancelablePage(dir) {
149
+ // Cancellation never interprets answers. Reading only this stable envelope
150
+ // keeps an older resident daemon able to retract a page authored by a newer CLI.
151
+ const delivery = parsePageDelivery(dir);
152
+ if (!delivery.inbox && !delivery.reply)
153
+ throw new Error('page_has_no_response_lifecycle');
154
+ }
148
155
  function isEmptyResponseMap(responses) {
149
156
  return typeof responses === 'object' && responses !== null && !Array.isArray(responses) && Object.keys(responses).length === 0;
150
157
  }
@@ -272,7 +279,7 @@ export function cancelTicket(dir, opts = {}) {
272
279
  const ticket = requireTicket(dir);
273
280
  const canceled = withTicketLock(ticket, () => {
274
281
  if (existsSync(pageManifestPath(ticket)))
275
- requireSettleablePage(ticket);
282
+ requireCancelablePage(ticket);
276
283
  const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: new Date().toISOString(), ...(opts.reason === undefined ? {} : { reason: opts.reason }), ...(opts.actor === undefined ? {} : { actor: opts.actor }) };
277
284
  const won = exclusiveResult(ticket, result);
278
285
  return { status: won ? 'canceled' : 'already_resolved', result: won ? result : readTicketResult(ticket) ?? result };
@@ -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.217",
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.217",
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.217",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {