@north-light/api 0.3.588 → 0.3.590

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.
Files changed (57) hide show
  1. package/dist/api/client.d.ts +4 -1
  2. package/dist/api/client.js +2 -1
  3. package/dist/api/contract.d.ts +1 -1
  4. package/dist/api/contract.js +1 -1
  5. package/dist/api/dto/docs.d.ts +5 -0
  6. package/dist/api/dto/settings.d.ts +21 -0
  7. package/dist/build-root.d.ts +2 -0
  8. package/dist/build-root.js +6 -1
  9. package/dist/commands/pkg/app-installs.d.ts +53 -0
  10. package/dist/commands/pkg/app-installs.js +125 -0
  11. package/dist/commands/pkg/market-inspect.js +4 -0
  12. package/dist/commands/pkg/market-manage.js +6 -0
  13. package/dist/commands/pkg/plugin-inspect.js +6 -2
  14. package/dist/commands/pkg/plugin-manage.js +15 -0
  15. package/dist/core/canvas/schema-version.d.ts +1 -1
  16. package/dist/core/canvas/schema-version.js +1 -1
  17. package/dist/core/config.d.ts +1 -0
  18. package/dist/core/config.js +10 -0
  19. package/dist/core/graph/names.js +7 -6
  20. package/dist/core/graph/package-docs.d.ts +6 -15
  21. package/dist/core/graph/package-docs.js +33 -83
  22. package/dist/core/graph/package-publish.d.ts +60 -0
  23. package/dist/core/graph/package-publish.js +158 -0
  24. package/dist/core/graph/package-reconcile.d.ts +20 -1
  25. package/dist/core/graph/package-reconcile.js +83 -15
  26. package/dist/core/import/export/dump.js +12 -36
  27. package/dist/core/installed-plugins.js +5 -7
  28. package/dist/core/packages/eligibility.d.ts +16 -0
  29. package/dist/core/packages/eligibility.js +34 -0
  30. package/dist/core/packages/launch-kinds.d.ts +6 -0
  31. package/dist/core/packages/launch-kinds.js +20 -0
  32. package/dist/core/packages/source-url.d.ts +3 -0
  33. package/dist/core/packages/source-url.js +17 -0
  34. package/dist/core/permissions/ceiling.d.ts +9 -2
  35. package/dist/core/permissions/ceiling.js +23 -9
  36. package/dist/core/permissions/evaluator.js +1 -1
  37. package/dist/core/providers/bundles.js +2 -3
  38. package/dist/core/runtime/launch-target.d.ts +6 -2
  39. package/dist/core/runtime/launch-target.js +8 -4
  40. package/dist/core/runtime/launch.d.ts +8 -1
  41. package/dist/core/runtime/launch.js +13 -6
  42. package/dist/core/runtime/runner/types.d.ts +7 -1
  43. package/dist/core/runtime/spawn.js +5 -5
  44. package/dist/core/runtime/view-socket.d.ts +2 -1
  45. package/dist/core/runtime/view-socket.js +3 -2
  46. package/dist/core/runtime/warm-pool.js +1 -0
  47. package/dist/core/scoped-state/legacy-profiles.js +1 -1
  48. package/dist/core/scoped-state/person-rows.js +38 -35
  49. package/dist/core/scoped-state/person-store.d.ts +23 -0
  50. package/dist/core/scoped-state/person-store.js +5 -1
  51. package/dist/daemon/reconcilers/node-lifecycle/remote-wake.d.ts +2 -1
  52. package/dist/daemon/reconcilers/node-lifecycle/remote-wake.js +3 -2
  53. package/package.json +1 -1
  54. package/dist/core/graph/package-kinds.d.ts +0 -22
  55. package/dist/core/graph/package-kinds.js +0 -49
  56. package/dist/core/scoped-state/materialized-plugins.d.ts +0 -20
  57. package/dist/core/scoped-state/materialized-plugins.js +0 -105
@@ -12,8 +12,7 @@ import { CONFIG_FILE } from '../types.js';
12
12
  import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
13
13
  import { readPluginManifest } from './manifest.js';
14
14
  import { awaitBundleSwap, bundleSwapInFlightElsewhere } from './plugin-swap-lock.js';
15
- import { isCloudRole, requestPersonId } from './canvas/person-state.js';
16
- import { materializedPluginsDir } from './scoped-state/materialized-plugins.js';
15
+ import { isCloudRole } from './canvas/person-state.js';
17
16
  import { readCachedSettings } from './scoped-state/person-store.js';
18
17
  import { pluginsDir } from './scope.js';
19
18
  function pluginConfigForRoot(scope, root) {
@@ -41,12 +40,11 @@ function swapsToWaitOut(scopeRootPath, cfg, present) {
41
40
  return names.filter((name) => !present.has(name) && bundleSwapInFlightElsewhere(scopeRootPath, name));
42
41
  }
43
42
  export function listInstalledPluginsInRoot(scope, scopeRootPath) {
44
- // A cloud-role process serves many people and holds no plugin directories of its own: it reads the bytes it materialized for the
45
- // request's person, and none outside a request.
46
- const person = scope === 'user' && isCloudRole() ? requestPersonId() : undefined;
47
- if (scope === 'user' && isCloudRole() && person === undefined)
43
+ // A cloud-role process serves many people and holds no plugin directories: a node's plugin kinds come from publication rows
44
+ // (`appLaunchKinds`) and its plugin code from the sandbox that runs it.
45
+ if (scope === 'user' && isCloudRole())
48
46
  return [];
49
- const dir = scope !== 'user' ? join(scopeRootPath, 'plugins') : person !== undefined ? materializedPluginsDir(person) : pluginsDir('user');
47
+ const dir = scope !== 'user' ? join(scopeRootPath, 'plugins') : pluginsDir('user');
50
48
  if (dir === undefined || !pathExists(dir))
51
49
  return [];
52
50
  const cfg = pluginConfigForRoot(scope, scopeRootPath);
@@ -0,0 +1,16 @@
1
+ /** The read rule for shipped package documents: which published package apps a reader may read the documents of.
2
+ * `crtr` (builtin) always; a provider when the reader's scopes hold a scope of it; a plugin on a Mac always (the local install set is the Mac's
3
+ * own), on a cloud role when the reader's app has it enabled from the marketplace the publication was read from. The ceiling applies the set to
4
+ * every shipped document whichever root admitted it. */
5
+ import type { CanvasDb } from '../canvas/db.js';
6
+ export interface EligibilityInput {
7
+ /** whether the reader's scopes hold a scope of the provider app with this external id (`app:<id>`) */
8
+ holdsProvider: (appExt: string) => boolean;
9
+ cloud: boolean;
10
+ /** the reader's app external id (`app:<id>`), null when the app has none */
11
+ ownExt: string | null;
12
+ /** the bare user id the reader acts for; null for a reader that acts for no person */
13
+ user: string | null;
14
+ }
15
+ /** Object ids of the live published package apps the reader may read the shipped documents of. */
16
+ export declare function eligiblePackageApps(db: CanvasDb, input: EligibilityInput): Promise<string[]>;
@@ -0,0 +1,34 @@
1
+ import { normalizeSourceUrl } from './source-url.js';
2
+ /** Object ids of the live published package apps the reader may read the shipped documents of. */
3
+ export async function eligiblePackageApps(db, input) {
4
+ const rows = await db.prepare(`SELECT p.app, p.origin, p.source, i.external_id FROM package_publications p
5
+ JOIN identities i ON i.object = p.app AND i.scheme = 'app' WHERE p.withdrawn_at IS NULL`).all();
6
+ let installed = null;
7
+ const installs = async () => {
8
+ if (installed !== null)
9
+ return installed;
10
+ installed = new Map();
11
+ if (input.user === null || input.ownExt === null)
12
+ return installed;
13
+ const found = await db.prepare(`SELECT p.name, m.url FROM scope_plugins p JOIN scope_marketplaces m
14
+ ON m.person = p.person AND m.owner_key = p.owner_key AND m.name = p.source_marketplace
15
+ WHERE p.person = ? AND p.owner_key = ? AND p.enabled = 1 AND m.url IS NOT NULL`).all(`user:${input.user}`, input.ownExt);
16
+ for (const r of found)
17
+ installed.set(r.name, normalizeSourceUrl(r.url));
18
+ return installed;
19
+ };
20
+ const eligible = [];
21
+ for (const r of rows) {
22
+ if (r.origin === 'builtin')
23
+ eligible.push(r.app);
24
+ else if (r.origin === 'provider') {
25
+ if (input.holdsProvider(r.external_id))
26
+ eligible.push(r.app);
27
+ }
28
+ else if (!input.cloud)
29
+ eligible.push(r.app);
30
+ else if (r.source !== null && (await installs()).get(r.external_id.replace(/^app:/, '')) === r.source)
31
+ eligible.push(r.app);
32
+ }
33
+ return eligible;
34
+ }
@@ -0,0 +1,6 @@
1
+ /** The launch `kinds` a cloud role layers into a node's launch config: those of the plugins enabled in the node's app whose published package was
2
+ * read from the marketplace the app installed it from. A cloud role holds no plugin directories, so the publication rows are the only source. */
3
+ import type { CanvasDb } from '../canvas/db.js';
4
+ import type { PersonSnapshot } from '../scoped-state/person-store.js';
5
+ /** Launch kinds by plugin name for the plugins enabled in `app`, from live publication rows whose `source` is the install's marketplace. */
6
+ export declare function appLaunchKinds(db: CanvasDb, snapshot: PersonSnapshot, app: string): Promise<Record<string, unknown>>;
@@ -0,0 +1,20 @@
1
+ import { normalizeSourceUrl } from './source-url.js';
2
+ /** Launch kinds by plugin name for the plugins enabled in `app`, from live publication rows whose `source` is the install's marketplace. */
3
+ export async function appLaunchKinds(db, snapshot, app) {
4
+ const block = snapshot.apps[app];
5
+ if (block === undefined)
6
+ return {};
7
+ const out = {};
8
+ for (const [name, entry] of Object.entries(block.plugins)) {
9
+ if (entry.enabled === false || entry.source_marketplace === undefined)
10
+ continue;
11
+ const url = block.marketplaces[entry.source_marketplace]?.url;
12
+ if (url === undefined)
13
+ continue;
14
+ const row = await db.prepare(`SELECT p.source, p.launch_kinds_json FROM package_publications p JOIN identities i ON i.object = p.app AND i.scheme = 'app'
15
+ WHERE i.external_id = ? AND p.origin = 'plugin' AND p.withdrawn_at IS NULL AND p.launch_kinds_json IS NOT NULL`).get(`app:${name}`);
16
+ if (row !== undefined && row.source === normalizeSourceUrl(url))
17
+ out[name] = JSON.parse(row.launch_kinds_json);
18
+ }
19
+ return out;
20
+ }
@@ -0,0 +1,3 @@
1
+ /** A marketplace git URL as the directory normalizes it: lowercased host, no `.git` suffix, no trailing slash. A plugin package is published
2
+ * with this URL as its `source`, and the read rule matches an install's marketplace URL against it. */
3
+ export declare function normalizeSourceUrl(url: string): string;
@@ -0,0 +1,17 @@
1
+ /** A marketplace git URL as the directory normalizes it: lowercased host, no `.git` suffix, no trailing slash. A plugin package is published
2
+ * with this URL as its `source`, and the read rule matches an install's marketplace URL against it. */
3
+ export function normalizeSourceUrl(url) {
4
+ const trimmed = url.trim();
5
+ let out;
6
+ try {
7
+ const u = new URL(trimmed);
8
+ u.hostname = u.hostname.toLowerCase();
9
+ out = u.toString();
10
+ }
11
+ catch {
12
+ // scp-style `git@host:owner/repo`: lowercase the host part only
13
+ const m = /^([^@\s]+@)?([^:/\s]+):(.+)$/.exec(trimmed);
14
+ out = m === null ? trimmed : `${m[1] ?? ''}${m[2].toLowerCase()}:${m[3]}`;
15
+ }
16
+ return out.replace(/\/+$/, '').replace(/\.git$/, '').replace(/\/+$/, '');
17
+ }
@@ -28,6 +28,9 @@ export interface Ceiling {
28
28
  /** App object ids for the `authored:` match (first-revision author rule): the caller's app and every first-party app, one whose grant holds
29
29
  * `crtr:memory:manage:user` always. A revoked first-party grant still counts: revocation stops its calls, not who wrote the document. */
30
30
  authorApps: string[];
31
+ /** The read rule for shipped package documents: the published package apps (object ids) whose shipped documents the reader may read. A shipped document
32
+ * (`documents.source_path` set) passes only when its owner is in this set, whichever root admitted it. Null: no read is asked, nothing is narrowed. */
33
+ packageApps: string[] | null;
31
34
  }
32
35
  export type Access = 'read' | 'write' | 'message';
33
36
  export interface CeilingOptions {
@@ -38,8 +41,12 @@ export interface CeilingOptions {
38
41
  export declare function personObjectIds(db: CanvasDb): Promise<string[]>;
39
42
  /** Whether `scopes` hold a provider-group scope `<provider>:<group>` of the app whose external id is `appExt`: what lets a run read that app's private objects. */
40
43
  export declare function holdsProviderGroup(scopes: readonly string[], appExt: string): boolean;
41
- /** The ceiling of `reader` for `access`; null for a person reader (no ceiling). */
42
- export declare function resolveCeiling(db: CanvasDb, reader: Reader, access: Access, options?: CeilingOptions): Promise<Ceiling | null>;
44
+ /** The published package apps whose shipped documents `reader` may read (the read rule); null for a person reader, who reads every package. `user` is the
45
+ * bare user id the reader acts for, null for a reader that acts for no person. A revoked grant (null scopes) holds no provider scope. */
46
+ export declare function readablePackageApps(db: CanvasDb, reader: Reader, user: string | null): Promise<string[] | null>;
47
+ /** The ceiling of `reader` for `access`; null for a person reader (no ceiling). `user` is the bare user id the reader acts for (`readerUser`), null for a
48
+ * reader that acts for no person: it reads the package apps every reader reads and the providers its scopes hold, never a plugin. */
49
+ export declare function resolveCeiling(db: CanvasDb, reader: Reader, user: string | null, access: Access, options?: CeilingOptions): Promise<Ceiling | null>;
43
50
  export interface RunPlace {
44
51
  run: string;
45
52
  isolated: boolean;
@@ -2,9 +2,11 @@
2
2
  // then applied inside the same query as the schema check (`ceilingFrag`). A person reader has no ceiling. Privacy (`private` per root), run
3
3
  // confinement (`place`) and the launched own-memory narrowing are all rules here; none of them is schema text.
4
4
  import { covers } from '@north-light/identity';
5
+ import { isCloudRole } from '../canvas/person-state.js';
6
+ import { eligiblePackageApps } from '../packages/eligibility.js';
5
7
  import { fileRoot } from '../spaces/file-root.js';
6
8
  import { FALSE, TRUE, and, f, or, raw } from './sql.js';
7
- const empty = (ownApp) => ({ ownApp, roots: [], namespaces: [], place: null, predicates: { allNodes: false, spaces: false }, authorApps: [] });
9
+ const empty = (ownApp) => ({ ownApp, roots: [], namespaces: [], place: null, predicates: { allNodes: false, spaces: false }, authorApps: [], packageApps: null });
8
10
  async function externalId(db, objectId) {
9
11
  const row = await db.prepare('SELECT external_id FROM identities WHERE object = ? ORDER BY scheme LIMIT 1').get(objectId);
10
12
  return row?.external_id ?? null;
@@ -60,8 +62,17 @@ async function repoRoots(db, reader, access) {
60
62
  ids.add(c.repo.id);
61
63
  return [...ids];
62
64
  }
63
- /** The ceiling of `reader` for `access`; null for a person reader (no ceiling). */
64
- export async function resolveCeiling(db, reader, access, options = {}) {
65
+ /** The published package apps whose shipped documents `reader` may read (the read rule); null for a person reader, who reads every package. `user` is the
66
+ * bare user id the reader acts for, null for a reader that acts for no person. A revoked grant (null scopes) holds no provider scope. */
67
+ export async function readablePackageApps(db, reader, user) {
68
+ if (reader.class === 'person')
69
+ return null;
70
+ const held = reader.scopes;
71
+ return await eligiblePackageApps(db, { holdsProvider: (ext) => held !== null && holdsProviderGroup(held, ext), cloud: isCloudRole(), ownExt: await externalId(db, reader.app.id), user });
72
+ }
73
+ /** The ceiling of `reader` for `access`; null for a person reader (no ceiling). `user` is the bare user id the reader acts for (`readerUser`), null for a
74
+ * reader that acts for no person: it reads the package apps every reader reads and the providers its scopes hold, never a plugin. */
75
+ export async function resolveCeiling(db, reader, user, access, options = {}) {
65
76
  if (reader.class === 'person')
66
77
  return null;
67
78
  const c = empty(reader.app.id);
@@ -90,6 +101,9 @@ export async function resolveCeiling(db, reader, access, options = {}) {
90
101
  own(p.id, 'no-node');
91
102
  }
92
103
  }
104
+ // The shipped-document filter applies to every reader, a revoked grant's included: it holds no provider scope, so it reads only the package apps eligible without one.
105
+ if (access === 'read')
106
+ c.packageApps = await readablePackageApps(db, reader, user) ?? [];
93
107
  if (held === null)
94
108
  return c; // a revoked grant reaches only its own app
95
109
  c.predicates.spaces = true;
@@ -176,11 +190,9 @@ export async function resolveCeiling(db, reader, access, options = {}) {
176
190
  }
177
191
  // ---- tool-usable apps (read): their shipped documents and custom objects
178
192
  if (access === 'read') {
179
- const apps = await db.prepare(`SELECT a.id FROM objects a WHERE a.type = 'app' AND a.deleted_at IS NULL AND EXISTS (
180
- SELECT 1 FROM objects o JOIN documents d ON d.object = o.id WHERE o.owner = a.id AND o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL)`).all();
181
- for (const a of apps)
182
- if (a.id !== reader.app.id)
183
- c.roots.push({ id: a.id, private: false, match: 'shipped' });
193
+ for (const id of c.packageApps ?? [])
194
+ if (id !== reader.app.id)
195
+ c.roots.push({ id, private: false, match: 'shipped' });
184
196
  for (const a of await db.prepare("SELECT o.id, i.external_id FROM objects o JOIN identities i ON i.object = o.id AND i.scheme = 'app' WHERE o.type = 'app' AND o.deleted_at IS NULL").all()) {
185
197
  if (a.id !== reader.app.id)
186
198
  c.roots.push({ id: a.id, private: holdsProviderGroup(held, a.external_id), match: 'custom' });
@@ -256,7 +268,9 @@ export function ceilingFrag(c, o) {
256
268
  parts.push(raw(`(${o}.type = 'node' AND ${o}.privacy = 'public')`));
257
269
  if (c.predicates.spaces)
258
270
  parts.push(raw(`(${o}.owner IS NULL AND ${o}.type IN ('app','person','repo'))`));
259
- return and([or(parts), placeFrag(c, o)]);
271
+ const shipped = c.packageApps === null ? TRUE
272
+ : f `(NOT EXISTS (SELECT 1 FROM documents sd WHERE sd.object = ${raw(o)}.id AND sd.source_path IS NOT NULL) OR ${raw(o)}.owner = ANY(${c.packageApps}::text[]))`;
273
+ return and([or(parts), placeFrag(c, o), shipped]);
260
274
  }
261
275
  /** Run confinement, for every object below the reader's own app regardless of the alternative that passed it (a root, an own namespace, `objects:read:node`):
262
276
  * the nearest node of the object (below the app) must be in the reader's run, or neither it nor the reader's run is isolated. An object not
@@ -71,7 +71,7 @@ export async function permits(db, reader, permission, alias = 'o', options = {})
71
71
  }
72
72
  const schemaPart = cases.length === 0 ? f `(CASE WHEN ${raw(alias)}.schema_type IS NULL THEN ${builtin} ELSE FALSE END)`
73
73
  : f `(CASE WHEN ${raw(alias)}.schema_type IS NULL THEN ${builtin} ${{ sql: cases.map((c) => c.sql).join(' '), params: cases.flatMap((c) => c.params) }} ELSE FALSE END)`;
74
- const ceiling = await resolveCeiling(db, ev.reader, ACCESS[permission], options);
74
+ const ceiling = await resolveCeiling(db, ev.reader, ev.userId, ACCESS[permission], options);
75
75
  if (ceiling === null)
76
76
  return schemaPart;
77
77
  return and([ceilingFrag(ceiling, alias), schemaPart]);
@@ -83,9 +83,8 @@ async function recordEtag(id, etag) {
83
83
  else
84
84
  atomicWriteText(etagFile(id), etag);
85
85
  }
86
- // Publishing a bundle's `memory/**` documents reconciles every installed user plugin. A cloud role has no per-person plugin list at grant sync
87
- // (outside a request), so that pass would see none installed and delete shipped documents, clear manifest kinds and revoke app tokens
88
- // deployment-wide. Provider documents are therefore not published on a cloud role.
86
+ // A Mac publishes a bundle's `memory/**` documents with the rest of its local packages. A cloud role has no per-person plugin list at grant sync and holds
87
+ // no provider document text: the directory serves each provider package and the api role's package sync publishes it. The pod-disk bundle here is for commands.
89
88
  async function reconcileProviderDocs(id) {
90
89
  if (isCloudRole())
91
90
  return;
@@ -32,12 +32,16 @@ export declare function isLaunchKind(kind: string): boolean;
32
32
  /** Validate a kind against the same cwd/profile configuration used to build its launch.
33
33
  * Resolution is subprocess-isolated: crtrd is single-threaded and a config read can
34
34
  * block indefinitely on a network filesystem. */
35
- export declare function assertLaunchKind(kind: string, cwd?: string, profileId?: string | null): Promise<void>;
35
+ export declare function assertLaunchKind(kind: string, cwd?: string, profileId?: string | null,
36
+ /** The app the kind is launched in. */
37
+ app?: string): Promise<void>;
36
38
  /** Resolve the exact profile, kind, cwd, mode and launch recipe a create will use. */
37
39
  export declare function resolveLaunchTarget(req: CreateNodeRequest, options?: {
38
40
  readOnlyProfileSelection?: boolean;
39
41
  /** Preserve an existing caller's validation boundary before target resolution. */
40
- validateKind?: (kind: string) => void | Promise<void>;
42
+ validateKind?: (kind: string, app: string) => void | Promise<void>;
43
+ /** The app the node runs in, when its caller is an app's broker or cron; else its parent's, else `app:terminal`. */
44
+ app?: string;
41
45
  }): Promise<ResolvedLaunchTarget>;
42
46
  /** The directory a new node is pinned to. */
43
47
  export declare function resolveNodeCwd(req: CreateNodeRequest, parent: string | undefined, _profileId: string | null, contextCwd: string): Promise<string>;
@@ -39,8 +39,10 @@ export function isLaunchKind(kind) {
39
39
  /** Validate a kind against the same cwd/profile configuration used to build its launch.
40
40
  * Resolution is subprocess-isolated: crtrd is single-threaded and a config read can
41
41
  * block indefinitely on a network filesystem. */
42
- export async function assertLaunchKind(kind, cwd = process.cwd(), profileId = envProfileId() || null) {
43
- const kinds = Object.keys((await readMergedLaunchConfigAsync(cwd, profileId)).kinds);
42
+ export async function assertLaunchKind(kind, cwd = process.cwd(), profileId = envProfileId() || null,
43
+ /** The app the kind is launched in. */
44
+ app = 'app:terminal') {
45
+ const kinds = Object.keys((await readMergedLaunchConfigAsync(cwd, profileId, app)).kinds);
44
46
  if (!kinds.includes(kind)) {
45
47
  throw usage(`unknown kind: ${kind}`, { received: kind, next: `Valid kinds: ${kinds.sort().join(', ')}.` });
46
48
  }
@@ -54,10 +56,11 @@ export async function resolveLaunchTarget(req, options = {}) {
54
56
  });
55
57
  const profile = assertProfileActive(profileId);
56
58
  const kind = resolveCreateKind(req.kind, profile);
57
- await options.validateKind?.(kind);
59
+ const app = options.app ?? (parent === undefined ? undefined : (await getNode(parent))?.grantee) ?? 'app:terminal';
60
+ await options.validateKind?.(kind, app);
58
61
  const cwd = await resolveNodeCwd({ ...req, kind }, parent, profileId, contextCwd);
59
62
  if (options.validateKind === undefined)
60
- await assertLaunchKind(kind, cwd, profileId);
63
+ await assertLaunchKind(kind, cwd, profileId, app);
61
64
  const mode = (req.mode ?? 'base');
62
65
  const launch = (await buildLaunchSpecAsync(kind, mode, {
63
66
  lifecycle: req.root_lifecycle ?? (req.root === true ? 'resident' : 'terminal'),
@@ -65,6 +68,7 @@ export async function resolveLaunchTarget(req, options = {}) {
65
68
  ...(req.model !== undefined ? { model: req.model, modelOverride: true } : {}),
66
69
  cwd,
67
70
  profileId,
71
+ app,
68
72
  })).launch;
69
73
  return { profileId, profile, kind, mode, cwd, launch };
70
74
  }
@@ -94,6 +94,9 @@ export interface LaunchBuildOptions {
94
94
  * exactly the path that creates the user's sessions — so callers that
95
95
  * know the node's profile must pass it explicitly (null = no profile). */
96
96
  profileId?: string | null;
97
+ /** The app the launched node runs in: a cloud role layers in the launch kinds of the plugins enabled in THIS app. Required by the async
98
+ * builder (the only one a cloud role reaches); a launch for an existing or soon-to-exist node passes that node's own app. */
99
+ app?: string;
97
100
  /** Preserved intent from an existing launch recipe when rebuilding it. */
98
101
  modelIntent?: ModelIntent;
99
102
  /** Exact-consult launch: pass the captured `provider/id:thinking` spec
@@ -115,7 +118,9 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: LaunchBu
115
118
  * filesystem, so even an ordinary synchronous config read must never run on
116
119
  * its event loop.
117
120
  */
118
- export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: LaunchBuildOptions): Promise<{
121
+ export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: LaunchBuildOptions & {
122
+ app: string;
123
+ }): Promise<{
119
124
  launch: LaunchSpec;
120
125
  lifecycle: 'terminal' | 'resident';
121
126
  }>;
@@ -124,6 +129,8 @@ export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: Lau
124
129
  * failure, an uncaught exception). Exported for tests. */
125
130
  export declare function describeLaunchConfigExit(code: number | null, signal: NodeJS.Signals | null, stderr: string): string;
126
131
  export declare function readMergedLaunchConfigAsync(cwd: string, profileId: string | null,
132
+ /** The app the launch runs in. */
133
+ app: string,
127
134
  /** Test seam: the interpreter and worker script to fork with. */
128
135
  seam?: {
129
136
  execPath?: string;
@@ -12,7 +12,9 @@ import { envProfileId } from '../../shared/env.js';
12
12
  import { readMergedLaunchConfig } from '../config.js';
13
13
  import { fork } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { personId, PersonUnresolvedError, PersonOutsideRequestError } from '../canvas/person-state.js';
15
+ import { isCloudRole, personId, PersonUnresolvedError, PersonOutsideRequestError } from '../canvas/person-state.js';
16
+ import { openDb } from '../canvas/db.js';
17
+ import { appLaunchKinds } from '../packages/launch-kinds.js';
16
18
  import { currentSnapshot, PersonSettingsNotLoadedError } from '../scoped-state/person-store.js';
17
19
  import { freshChildNodeExecPath, recheckChildNode } from './branded-host.js';
18
20
  import { LAUNCH_SYSTEM_PROMPT } from './launch-prompt.js';
@@ -277,7 +279,7 @@ export function buildLaunchSpec(kind, mode, opts) {
277
279
  export async function buildLaunchSpecAsync(kind, mode, opts) {
278
280
  const cwd = opts.cwd ?? process.cwd();
279
281
  const profileId = opts.profileId !== undefined ? opts.profileId : (envProfileId() || null);
280
- const merged = await readMergedLaunchConfigAsync(cwd, profileId);
282
+ const merged = await readMergedLaunchConfigAsync(cwd, profileId, opts.app);
281
283
  return buildLaunchSpecFromMerged(kind, mode, opts, merged);
282
284
  }
283
285
  const LAUNCH_CONFIG_TIMEOUT_MS = 15_000;
@@ -293,12 +295,17 @@ export function describeLaunchConfigExit(code, signal, stderr) {
293
295
  class LaunchConfigWorkerCrash extends Error {
294
296
  }
295
297
  export async function readMergedLaunchConfigAsync(cwd, profileId,
298
+ /** The app the launch runs in. */
299
+ app,
296
300
  /** Test seam: the interpreter and worker script to fork with. */
297
301
  seam = {}) {
298
302
  const execPath = seam.execPath ?? await freshChildNodeExecPath();
299
303
  const workerPath = seam.workerPath ?? fileURLToPath(new URL('./launch-config-worker.js', import.meta.url));
304
+ // A cloud role holds no plugin directories: the worker gets the launch kinds of the plugins enabled in the launch's app, read here from publication rows.
305
+ const held = heldPersonSettings();
306
+ const pluginKinds = !isCloudRole() ? undefined : 'snapshot' in held ? await appLaunchKinds(await openDb(), held.snapshot, app) : {};
300
307
  try {
301
- return await runLaunchConfigWorker(cwd, profileId, execPath, workerPath);
308
+ return await runLaunchConfigWorker(cwd, profileId, execPath, workerPath, held, pluginKinds);
302
309
  }
303
310
  catch (error) {
304
311
  if (!(error instanceof LaunchConfigWorkerCrash))
@@ -309,7 +316,7 @@ seam = {}) {
309
316
  const next = await recheckChildNode(execPath);
310
317
  if (next === execPath)
311
318
  throw error;
312
- return runLaunchConfigWorker(cwd, profileId, next, workerPath);
319
+ return runLaunchConfigWorker(cwd, profileId, next, workerPath, held, pluginKinds);
313
320
  }
314
321
  }
315
322
  /** The calling process's person and settings snapshot, when it holds them (a process with none reads defaults, as the worker will). */
@@ -323,7 +330,7 @@ function heldPersonSettings() {
323
330
  throw error;
324
331
  }
325
332
  }
326
- function runLaunchConfigWorker(cwd, profileId, execPath, workerPath) {
333
+ function runLaunchConfigWorker(cwd, profileId, execPath, workerPath, held, appPluginKinds) {
327
334
  return new Promise((resolve, reject) => {
328
335
  const child = fork(workerPath, [], {
329
336
  execPath,
@@ -332,7 +339,7 @@ function runLaunchConfigWorker(cwd, profileId, execPath, workerPath) {
332
339
  });
333
340
  let stderr = '';
334
341
  child.stderr?.setEncoding('utf8').on('data', (chunk) => { stderr = (stderr + chunk).slice(-8_192); });
335
- child.send({ cwd, profileId, ...heldPersonSettings() });
342
+ child.send({ cwd, profileId, ...held, appPluginKinds });
336
343
  let settled = false;
337
344
  const finish = (fn) => {
338
345
  if (settled)
@@ -50,6 +50,12 @@ export type RunnerStatus = {
50
50
  state: 'unknown';
51
51
  error: string;
52
52
  };
53
+ export interface RunnerStatusOptions {
54
+ /** The execution's broker has posted idle (`executions.idle_at`), so its environment may be frozen. A runner whose environment
55
+ * thaws or bills on any request must then answer from its control plane alone and send nothing to the workload: the Blaxel runner
56
+ * reports `running` for a sandbox that exists and `gone` for one that does not. The process and machine runners ignore it. */
57
+ idle?: boolean;
58
+ }
53
59
  export type RunnerWakeResult = 'signalled' | 'no_broker';
54
60
  export interface RunnerStopOptions {
55
61
  /** Why: written to `executions.stop_reason` by the caller; carried here for the runner's diagnostics. */
@@ -91,7 +97,7 @@ export interface Runner {
91
97
  wake(execution: RunnerExecution): Promise<RunnerWakeResult>;
92
98
  /** Stop the execution's broker. Repeating it on a stopped execution confirms. */
93
99
  stop(execution: RunnerExecution, options: RunnerStopOptions): Promise<RunnerStopResult>;
94
- status(execution: RunnerExecution): Promise<RunnerStatus>;
100
+ status(execution: RunnerExecution, options?: RunnerStatusOptions): Promise<RunnerStatus>;
95
101
  /** Remove everything the runner holds for the node: the run dir (process) or the sandbox (Blaxel). */
96
102
  delete(nodeId: string): Promise<void>;
97
103
  /** Where a viewer reaches this execution's broker. */
@@ -313,13 +313,13 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch, afterNodeCrea
313
313
  // before node creation remains the sole authoritative race decider.
314
314
  const profileId = await resolveProfileId(opts.profile, spawner, birthCwd);
315
315
  const launchGrant = opts.run?.grant_id ?? spawnerNode?.grant_id ?? null;
316
+ // The identity the node will carry (a run's own, else its creator's or spawner's, else the terminal person) pays for it and picks the app it launches in.
317
+ const payerSource = opts.run ?? creatorNode ?? spawnerNode ?? forkSourceMeta;
316
318
  const preflightLaunch = await appPaidLaunch((await buildLaunchSpecAsync(opts.kind, mode, {
317
- lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: birthCwd, profileId,
319
+ lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: birthCwd, profileId, app: payerSource?.grantee ?? 'app:terminal',
318
320
  })).launch, launchGrant, opts.model);
319
321
  await assertLaunchModelRegistered(requestFromLaunch(preflightLaunch), { cwd: birthCwd, profileId });
320
- // The start check, before any worktree or node row exists: a refusal leaves nothing behind. The payer is the identity the
321
- // node will carry (a run's own, else its creator's or spawner's, else the terminal person).
322
- const payerSource = opts.run ?? creatorNode ?? spawnerNode ?? forkSourceMeta;
322
+ // The start check, before any worktree or node row exists: a refusal leaves nothing behind.
323
323
  await checkStart({ grantee: payerSource?.grantee ?? 'app:terminal', sub: payerSource?.sub, grant_id: payerSource?.grant_id });
324
324
  const forkSourceNodeId = forkSourceMeta?.node_id ?? null;
325
325
  let managedWorktree;
@@ -340,7 +340,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch, afterNodeCrea
340
340
  // which silently hid every profile-scope kind override).
341
341
  const launch = wantsWorktree
342
342
  ? await appPaidLaunch((await buildLaunchSpecAsync(opts.kind, mode, {
343
- lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: spawnCwd, profileId,
343
+ lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: spawnCwd, profileId, app: payerSource?.grantee ?? 'app:terminal',
344
344
  })).launch, launchGrant, opts.model)
345
345
  : preflightLaunch;
346
346
  if (sandboxWorktree !== undefined)
@@ -53,7 +53,8 @@ export interface BrokerViewLink {
53
53
  on(event: 'close', listener: (hadError: boolean) => void): this;
54
54
  }
55
55
  /** A broker's `/view` WebSocket as a {@link BrokerViewLink}. Each broker message is one frame, delivered as one
56
- * newline-terminated chunk; each line written is sent as one text message. The headers (the preview token) are
56
+ * newline-terminated chunk; each line written is sent as one text message that keeps its newline: the broker reads
57
+ * this WebSocket as a byte stream through its line decoder, which completes a frame only at a newline. The headers (the preview token) are
57
58
  * sent on the handshake only and kept nowhere else. */
58
59
  export declare function connectWssView(url: string, headers: Record<string, string>): BrokerViewLink;
59
60
  /** Resolve true once the broker's `/view` WebSocket accepts a handshake within `waitMs`, retrying while it refuses;
@@ -103,7 +103,8 @@ export async function waitForBrokerViewSocket(nodeId, exited, waitMs = BROKER_FO
103
103
  /** The port a sandboxed broker serves `/view` on (bound to `::`; reached only through the private preview and its per-attach token). */
104
104
  export const BROKER_VIEW_PORT = 4180;
105
105
  /** A broker's `/view` WebSocket as a {@link BrokerViewLink}. Each broker message is one frame, delivered as one
106
- * newline-terminated chunk; each line written is sent as one text message. The headers (the preview token) are
106
+ * newline-terminated chunk; each line written is sent as one text message that keeps its newline: the broker reads
107
+ * this WebSocket as a byte stream through its line decoder, which completes a frame only at a newline. The headers (the preview token) are
107
108
  * sent on the handshake only and kept nowhere else. */
108
109
  export function connectWssView(url, headers) {
109
110
  return new WssViewLink(url, headers);
@@ -148,7 +149,7 @@ class WssViewLink extends EventEmitter {
148
149
  }
149
150
  /** Like a view.sock write before connect, a line written while the handshake is in flight is queued and sent on open. */
150
151
  write(line) {
151
- const message = line.endsWith('\n') ? line.slice(0, -1) : line;
152
+ const message = line.endsWith('\n') ? line : `${line}\n`;
152
153
  if (this.ws.readyState === WebSocket.CONNECTING) {
153
154
  this.pending.push(message);
154
155
  return true;
@@ -113,6 +113,7 @@ async function resolveRecipe(request) {
113
113
  ...(request.model !== null ? { model: request.model } : {}),
114
114
  cwd: request.cwd,
115
115
  profileId: request.profileId,
116
+ app: 'app:terminal',
116
117
  });
117
118
  return { key: warmRecipeKey(request.cwd, request.profileId, launch), launch, request };
118
119
  }
@@ -42,7 +42,7 @@ export function readLegacyUserSnapshot(root) {
42
42
  for (const r of cachedStatement(db, "SELECT owner_key, name, last_updated, etag, checked_at FROM scope_plugins WHERE owner_key != 'project'").all()) {
43
43
  stateOf(r.owner_key).plugins[r.name] = { ...(r.last_updated === null ? {} : { last_updated: r.last_updated }), ...(r.etag === null ? {} : { etag: r.etag }), ...(r.checked_at === null ? {} : { checked_at: r.checked_at }) };
44
44
  }
45
- return { profiles, defaults, settings, state };
45
+ return { profiles, defaults, settings, state, apps: {} };
46
46
  }
47
47
  /** Pre-canvas per-project memory caps (`none|name|preview`) the canvas import has not converted yet. */
48
48
  export function listLegacyProjectCaps(root) {