@nylorun/runtime 0.19.1-beta → 0.21.0-beta

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 (137) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +22 -13
  3. package/dist/adapters/streams/s2.js +5 -11
  4. package/dist/api/components.d.ts +326 -205
  5. package/dist/api/components.js +3 -3
  6. package/dist/api/http/app.js +2 -0
  7. package/dist/api/http/define.d.ts +3 -2
  8. package/dist/api/http/define.js +10 -11
  9. package/dist/api/http/routes/mcp.d.ts +9 -0
  10. package/dist/api/http/routes/mcp.js +32 -0
  11. package/dist/api/http/routes/vaults.d.ts +1 -9
  12. package/dist/api/http/routes/vaults.js +3 -103
  13. package/dist/api/openapi.js +37 -6
  14. package/dist/artifacts/media-types.d.ts +2 -0
  15. package/dist/artifacts/media-types.js +5 -0
  16. package/dist/execution/types.d.ts +1 -1
  17. package/dist/gates/http-tool.d.ts +7 -0
  18. package/dist/gates/http-tool.js +34 -3
  19. package/dist/gates/mcp-handler.d.ts +4 -1
  20. package/dist/gates/mcp-handler.js +64 -8
  21. package/dist/gates/tenant-vaults.d.ts +9 -15
  22. package/dist/gates/tenant-vaults.js +9 -17
  23. package/dist/gates/tool-client.js +36 -17
  24. package/dist/gates/tool-contract.d.ts +19 -3
  25. package/dist/gates/tool-contract.js +9 -3
  26. package/dist/gates/tool-gate.d.ts +2 -2
  27. package/dist/harness/calls.d.ts +2 -0
  28. package/dist/harness/calls.js +13 -3
  29. package/dist/harness-api/record.d.ts +7 -6
  30. package/dist/harness-api/record.js +99 -17
  31. package/dist/harness-api/server.js +35 -27
  32. package/dist/harness-api/start.js +2 -1
  33. package/dist/host/app.d.ts +3 -0
  34. package/dist/host/app.js +33 -4
  35. package/dist/host/config-for.d.ts +0 -2
  36. package/dist/host/config-for.js +0 -1
  37. package/dist/host/create-host.d.ts +6 -0
  38. package/dist/host/create-host.js +1 -0
  39. package/dist/host/gates.js +0 -1
  40. package/dist/host/main.js +24 -7
  41. package/dist/host/stack-config.d.ts +5 -8
  42. package/dist/host/stack-config.js +9 -20
  43. package/dist/index.d.ts +0 -1
  44. package/dist/infra/index.d.ts +2 -1
  45. package/dist/infra/index.js +0 -1
  46. package/dist/infra/readiness.d.ts +6 -6
  47. package/dist/infra/readiness.js +2 -5
  48. package/dist/keys/client.js +1 -9
  49. package/dist/keys/keys.d.ts +11 -13
  50. package/dist/keys/keys.js +5 -7
  51. package/dist/management-openapi.json +671 -378
  52. package/dist/mcp/connect.d.ts +159 -13
  53. package/dist/mcp/connect.js +538 -55
  54. package/dist/mcp/pool.d.ts +11 -2
  55. package/dist/mcp/pool.js +74 -27
  56. package/dist/mcp/preview.d.ts +35 -0
  57. package/dist/mcp/preview.js +215 -0
  58. package/dist/mcp/snapshot.d.ts +79 -5
  59. package/dist/mcp/snapshot.js +145 -27
  60. package/dist/model/catalog.d.ts +14 -0
  61. package/dist/model/catalog.js +14 -1
  62. package/dist/model/pi-model.js +36 -2
  63. package/dist/openapi.json +644 -266
  64. package/dist/redact.d.ts +19 -1
  65. package/dist/redact.js +42 -13
  66. package/dist/sandbox/session-sandbox.d.ts +7 -0
  67. package/dist/sandbox/session-sandbox.js +89 -28
  68. package/dist/store/postgres/connect.d.ts +10 -0
  69. package/dist/store/postgres/connect.js +30 -3
  70. package/dist/store/postgres/control.d.ts +14 -0
  71. package/dist/store/postgres/control.js +187 -0
  72. package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
  73. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  74. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  75. package/dist/store/postgres/schema.d.ts +122 -261
  76. package/dist/store/postgres/schema.js +22 -36
  77. package/dist/store/postgres/store.js +16 -18
  78. package/dist/store/postgres/tenant.d.ts +14 -0
  79. package/dist/store/postgres/tenant.js +22 -2
  80. package/dist/store/types.d.ts +42 -8
  81. package/dist/streams/types.d.ts +6 -29
  82. package/dist/streams/types.js +5 -4
  83. package/dist/tenant/artifact-tool.d.ts +9 -1
  84. package/dist/tenant/artifact-tool.js +113 -9
  85. package/dist/tenant/auth.d.ts +16 -6
  86. package/dist/tenant/auth.js +41 -17
  87. package/dist/tenant/commands.js +10 -6
  88. package/dist/tenant/context.d.ts +9 -6
  89. package/dist/tenant/control.d.ts +26 -0
  90. package/dist/tenant/control.js +35 -0
  91. package/dist/tenant/effects.d.ts +2 -2
  92. package/dist/tenant/effects.js +20 -5
  93. package/dist/tenant/ephemeral.d.ts +0 -6
  94. package/dist/tenant/ephemeral.js +1 -1
  95. package/dist/tenant/http.d.ts +4 -2
  96. package/dist/tenant/http.js +4 -2
  97. package/dist/tenant/identity-file.d.ts +16 -12
  98. package/dist/tenant/identity-file.js +41 -29
  99. package/dist/tenant/issuers.d.ts +4 -3
  100. package/dist/tenant/issuers.js +22 -16
  101. package/dist/tenant/outbound.d.ts +14 -5
  102. package/dist/tenant/outbound.js +21 -9
  103. package/dist/tenant/reset.d.ts +2 -2
  104. package/dist/tenant/reset.js +8 -2
  105. package/dist/tenant/resource-server.d.ts +56 -0
  106. package/dist/tenant/resource-server.js +62 -0
  107. package/dist/tenant/runtime.d.ts +1 -1
  108. package/dist/tenant/runtime.js +16 -15
  109. package/dist/tenant/scheduler.d.ts +1 -1
  110. package/dist/tenant/scheduler.js +1 -1
  111. package/dist/tenant/sessions.js +20 -3
  112. package/dist/tenant/store-pg.js +9 -0
  113. package/dist/tenant/streams.d.ts +16 -22
  114. package/dist/tenant/streams.js +7 -87
  115. package/dist/tenant/sweep.js +9 -1
  116. package/dist/tenant/tool-results.d.ts +57 -0
  117. package/dist/tenant/tool-results.js +298 -0
  118. package/dist/tenant/tool-search.d.ts +43 -0
  119. package/dist/tenant/tool-search.js +100 -0
  120. package/dist/tenant/transcript.d.ts +108 -0
  121. package/dist/tenant/transcript.js +13 -0
  122. package/dist/tenant/types.d.ts +0 -14
  123. package/dist/vault/crypto.d.ts +0 -7
  124. package/dist/vault/crypto.js +0 -16
  125. package/dist/vault/headers.d.ts +17 -0
  126. package/dist/vault/headers.js +61 -0
  127. package/dist/vault/service.d.ts +11 -68
  128. package/dist/vault/service.js +83 -438
  129. package/dist/vault/sources.d.ts +20 -48
  130. package/dist/vault/sources.js +25 -189
  131. package/dist/version.d.ts +1 -1
  132. package/dist/version.js +1 -1
  133. package/package.json +3 -3
  134. package/dist/streams/control.d.ts +0 -13
  135. package/dist/streams/control.js +0 -25
  136. package/dist/vault/oauth.d.ts +0 -55
  137. package/dist/vault/oauth.js +0 -186
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The Runtime API as an OAuth 2.1 resource server (protocol 9, Host feature `resource-server`).
3
+ * Nylorun is never the authorization server: the operator's identity providers are, listed in
4
+ * the identity file, and this module only says so in the standard places.
5
+ *
6
+ * - `GET /.well-known/oauth-protected-resource` (RFC 9728): the resource (`NYLORUN_PUBLIC_URL`,
7
+ * else the origin the request reached), every trusted issuer in the file's order, and the
8
+ * scopes their tokens may carry. Without trusted issuers there is none: the Runtime takes no
9
+ * OAuth tokens.
10
+ * - `WWW-Authenticate: Bearer` challenges (OAuth 2.1 §5.3, RFC 6750): a missing credential
11
+ * carries no error code; a rejected one `error="invalid_token"`; a token missing a route's
12
+ * scope `error="insufficient_scope"` with the scopes that would do. On the Runtime API they
13
+ * point at the metadata (`resource_metadata`); the Management API takes management keys only
14
+ * and is no OAuth resource, so its challenge is a bare `Bearer`.
15
+ */
16
+ import type { IncomingMessage } from "node:http";
17
+ import type { TrustedIssuers } from "./issuers.js";
18
+ /** Where the protected resource metadata is served (RFC 9728 §3). */
19
+ export declare const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
20
+ /** What the metadata and the challenges are made of: `TenantConfig`'s fields of the same names. */
21
+ export interface ResourceServerConfig {
22
+ /** The URL clients reach the Runtime API at (`NYLORUN_PUBLIC_URL`, no trailing slash). */
23
+ readonly publicUrl?: string;
24
+ /** The identity file's trusted issuers. */
25
+ readonly issuers?: TrustedIssuers;
26
+ }
27
+ /** RFC 9728 protected resource metadata. */
28
+ export interface ProtectedResourceMetadata {
29
+ readonly resource: string;
30
+ readonly authorization_servers: readonly string[];
31
+ readonly scopes_supported: readonly string[];
32
+ readonly bearer_methods_supported: readonly ["header"];
33
+ readonly resource_name: string;
34
+ }
35
+ /** The metadata, or undefined when the Runtime trusts no issuer. */
36
+ export declare function protectedResourceMetadata(config: ResourceServerConfig, incoming: IncomingMessage): ProtectedResourceMetadata | undefined;
37
+ /**
38
+ * The metadata's URL for a challenge's `resource_metadata`, or undefined when there is none.
39
+ * A resource with a path has its metadata at the origin, the path after the well-known part.
40
+ */
41
+ export declare function protectedResourceMetadataUrl(config: ResourceServerConfig, incoming: IncomingMessage): string | undefined;
42
+ interface Challenge {
43
+ readonly error?: "invalid_token" | "insufficient_scope";
44
+ readonly description?: string;
45
+ readonly scope?: string;
46
+ readonly resourceMetadata?: string | undefined;
47
+ }
48
+ /** A `WWW-Authenticate: Bearer` value. Every parameter value is plain ASCII without quotes. */
49
+ export declare function bearerChallenge(challenge?: Challenge): string;
50
+ /**
51
+ * The `401` for a request without a usable credential: `credential_required` when it sent
52
+ * none (no error code, OAuth 2.1 §5.3.2), `credential_invalid` when the Tenant refused the one
53
+ * it sent. The reason is the Runtime's log's, never the client's.
54
+ */
55
+ export declare function failCredential(kind: "required" | "invalid", resourceMetadata: string | undefined): never;
56
+ export {};
@@ -0,0 +1,62 @@
1
+ import { TOKEN_SCOPES } from "@nylorun/core/contracts";
2
+ import { HttpError } from "./http.js";
3
+ /** Where the protected resource metadata is served (RFC 9728 §3). */
4
+ export const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
5
+ /** The origin `incoming` reached, as `@hono/node-server` builds a request's URL. */
6
+ function requestOrigin(incoming) {
7
+ const scheme = incoming.socket?.encrypted ? "https" : "http";
8
+ return `${scheme}://${incoming.headers.host ?? "localhost"}`;
9
+ }
10
+ /** This Runtime API's resource identifier: the public URL, else the request's origin. */
11
+ function resourceOf(config, incoming) {
12
+ return config.publicUrl ?? requestOrigin(incoming);
13
+ }
14
+ /** The metadata, or undefined when the Runtime trusts no issuer. */
15
+ export function protectedResourceMetadata(config, incoming) {
16
+ const issuers = config.issuers?.issuers ?? [];
17
+ if (issuers.length === 0)
18
+ return undefined;
19
+ // `studio` signs a person in to Studio; no Runtime API route asks for it.
20
+ const granted = new Set(issuers.flatMap((issuer) => issuer.config.allowedScopes));
21
+ return {
22
+ resource: resourceOf(config, incoming),
23
+ authorization_servers: issuers.map((issuer) => issuer.config.issuer),
24
+ scopes_supported: TOKEN_SCOPES.filter((scope) => granted.has(scope)),
25
+ bearer_methods_supported: ["header"],
26
+ resource_name: "Nylorun Runtime API",
27
+ };
28
+ }
29
+ /**
30
+ * The metadata's URL for a challenge's `resource_metadata`, or undefined when there is none.
31
+ * A resource with a path has its metadata at the origin, the path after the well-known part.
32
+ */
33
+ export function protectedResourceMetadataUrl(config, incoming) {
34
+ if ((config.issuers?.issuers.length ?? 0) === 0)
35
+ return undefined;
36
+ const url = new URL(resourceOf(config, incoming));
37
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
38
+ return `${url.origin}${PROTECTED_RESOURCE_PATH}${path}`;
39
+ }
40
+ /** A `WWW-Authenticate: Bearer` value. Every parameter value is plain ASCII without quotes. */
41
+ export function bearerChallenge(challenge = {}) {
42
+ const params = [];
43
+ if (challenge.error)
44
+ params.push(`error="${challenge.error}"`);
45
+ if (challenge.description)
46
+ params.push(`error_description="${challenge.description}"`);
47
+ if (challenge.scope)
48
+ params.push(`scope="${challenge.scope}"`);
49
+ if (challenge.resourceMetadata)
50
+ params.push(`resource_metadata="${challenge.resourceMetadata}"`);
51
+ return params.length === 0 ? "Bearer" : `Bearer ${params.join(", ")}`;
52
+ }
53
+ /**
54
+ * The `401` for a request without a usable credential: `credential_required` when it sent
55
+ * none (no error code, OAuth 2.1 §5.3.2), `credential_invalid` when the Tenant refused the one
56
+ * it sent. The reason is the Runtime's log's, never the client's.
57
+ */
58
+ export function failCredential(kind, resourceMetadata) {
59
+ if (kind === "required")
60
+ throw new HttpError(401, "A bearer credential is required", { code: "credential_required" }, { "www-authenticate": bearerChallenge({ resourceMetadata }) });
61
+ throw new HttpError(401, "The credential is not valid here", { code: "credential_invalid" }, { "www-authenticate": bearerChallenge({ error: "invalid_token", resourceMetadata }) });
62
+ }
@@ -108,7 +108,7 @@ export declare class TenantRuntime implements TenantHandle {
108
108
  drain(activeWork: "drain" | "cancel", timeoutMs?: number): Promise<void>;
109
109
  /**
110
110
  * Aborts the advance of `sessionId` if it runs on this process. Cancel calls it after
111
- * committing `cancelled`; the control stream calls it for cancels made elsewhere.
111
+ * committing `cancelled`; the control bus calls it for cancels made elsewhere.
112
112
  */
113
113
  abortLocal(sessionId: string): void;
114
114
  /**
@@ -5,7 +5,6 @@ import { tenantModelGate } from "../gates/in-process.js";
5
5
  import { createKekFile, readVaultKek } from "../vault/kek.js";
6
6
  import { SigningKeys } from "./signing-keys.js";
7
7
  import { VaultService } from "../vault/service.js";
8
- import { CredentialSources } from "../vault/sources.js";
9
8
  import { McpPool } from "../mcp/pool.js";
10
9
  import { SandboxManager } from "../sandbox/manager.js";
11
10
  import { storeSandboxRecords } from "../sandbox/records.js";
@@ -13,6 +12,7 @@ import { defaultSandboxBackends } from "../sandbox/select.js";
13
12
  import { MemoryExecution } from "../execution/memory.js";
14
13
  import { openError } from "./cause.js";
15
14
  import { MemoryStreams } from "../streams/memory.js";
15
+ import { closeControl, wireControl } from "./control.js";
16
16
  import { createSessionStreams, endAllStreams, } from "./session-streams.js";
17
17
  import { closeStreams, streamsStatus, wireStreams, } from "./streams.js";
18
18
  import { abortAll, abortLocal, createWorkState, drain, waitForIdle, } from "./scheduler.js";
@@ -21,7 +21,6 @@ import { sweep } from "./sweep.js";
21
21
  import { TenantWorkers, WORKER_ID, } from "./worker.js";
22
22
  import { authorize } from "./effects.js";
23
23
  import { inProcessToolGate } from "../gates/tool-gate.js";
24
- import { guardedFetch } from "./outbound.js";
25
24
  import { inProcessKeys } from "../keys/keys.js";
26
25
  import { tenantApi } from "../api/http/app.js";
27
26
  import { createFsBlobStore } from "../blob/index.js";
@@ -85,6 +84,7 @@ export class TenantRuntime {
85
84
  mkdirSync(paths.logs, { recursive: true });
86
85
  const store = hooks.store;
87
86
  let wired;
87
+ let control;
88
88
  let detach;
89
89
  let harness;
90
90
  try {
@@ -116,16 +116,7 @@ export class TenantRuntime {
116
116
  const opened = store;
117
117
  // A seeded Tenant setting wins over the Host default (A18).
118
118
  const seededBackend = await opened.tx((t) => t.getSetting("sandbox.backend"));
119
- const vault = new VaultService({
120
- store: opened,
121
- kek: ensureKek,
122
- // OAuth refresh and connect follow the Host's address policy (F9 C2).
123
- fetch: config.vaultFetch ?? guardedFetch(config.delivery ?? {}),
124
- });
125
- const credentials = new CredentialSources({
126
- vault,
127
- ...(config.resolver ? { resolver: config.resolver } : {}),
128
- });
119
+ const vault = new VaultService({ store: opened, kek: ensureKek });
129
120
  // `ctx` is assigned below; these callbacks only run once the Tenant is open.
130
121
  let ctx;
131
122
  const toolGate = hooks.toolGate ??
@@ -135,7 +126,14 @@ export class TenantRuntime {
135
126
  logger: config.logger,
136
127
  });
137
128
  const signingKeys = new SigningKeys({ tenantId: config.tenantId, kek: ensureKek });
138
- const keys = hooks.keys ?? inProcessKeys({ store: opened, vault, signingKeys, kek: ensureKek });
129
+ const keys = hooks.keys ??
130
+ inProcessKeys({
131
+ store: opened,
132
+ vault,
133
+ signingKeys,
134
+ kek: ensureKek,
135
+ policy: config.delivery ?? {},
136
+ });
139
137
  const harnessMode = hooks.harness ?? "memory";
140
138
  const inProcess = harnessMode === "memory" || harnessMode === "json";
141
139
  const preference = seededBackend ?? config.sandbox.backend;
@@ -240,7 +238,6 @@ export class TenantRuntime {
240
238
  reads: hooks.reads,
241
239
  store: opened,
242
240
  vault,
243
- credentials,
244
241
  ...(mcp ? { mcp } : {}),
245
242
  sandbox,
246
243
  flowLimits,
@@ -303,6 +300,8 @@ export class TenantRuntime {
303
300
  ...(hooks.hostRelay ? { hostRelay: true } : {}),
304
301
  ...(hooks.retireGraceMs !== undefined ? { retireGraceMs: hooks.retireGraceMs } : {}),
305
302
  });
303
+ await wireControl(ctx);
304
+ control = ctx.control;
306
305
  // Register the handlers, then arm the sweep: its first pass runs at once and re-wakes
307
306
  // sessions a previous process left runnable or running (takeover handles the rest).
308
307
  const worker = {
@@ -324,6 +323,7 @@ export class TenantRuntime {
324
323
  await detach?.().catch(() => undefined);
325
324
  await harness?.stop().catch(() => undefined);
326
325
  await wired?.close().catch(() => undefined);
326
+ await control?.close().catch(() => undefined);
327
327
  await hooks.reads?.close().catch(() => undefined);
328
328
  await store.close().catch(() => undefined);
329
329
  throw error;
@@ -350,7 +350,7 @@ export class TenantRuntime {
350
350
  }
351
351
  /**
352
352
  * Aborts the advance of `sessionId` if it runs on this process. Cancel calls it after
353
- * committing `cancelled`; the control stream calls it for cancels made elsewhere.
353
+ * committing `cancelled`; the control bus calls it for cancels made elsewhere.
354
354
  */
355
355
  abortLocal(sessionId) {
356
356
  this.ctx.abortLocal(sessionId);
@@ -402,6 +402,7 @@ export class TenantRuntime {
402
402
  return closeStreams(ctx);
403
403
  },
404
404
  ],
405
+ ["control", () => closeControl(ctx)],
405
406
  ["reads", () => ctx.reads?.close()],
406
407
  ["store", () => ctx.store.close()],
407
408
  ];
@@ -9,7 +9,7 @@ export interface WorkState {
9
9
  export declare function createWorkState(): WorkState;
10
10
  /**
11
11
  * Abort the advance of `id` if it runs on this process.
12
- * Cancel calls it after committing `cancelled`, and so does the control stream for cancels made
12
+ * Cancel calls it after committing `cancelled`, and so does the control bus for cancels made
13
13
  * elsewhere. With `turnId`, an advance of another turn keeps running: the signal came late.
14
14
  */
15
15
  export declare function abortLocal(ctx: TenantContext, id: string, kind?: AdvanceAbortKind, turnId?: string): void;
@@ -27,7 +27,7 @@ function abortWith(controller, kind) {
27
27
  }
28
28
  /**
29
29
  * Abort the advance of `id` if it runs on this process.
30
- * Cancel calls it after committing `cancelled`, and so does the control stream for cancels made
30
+ * Cancel calls it after committing `cancelled`, and so does the control bus for cancels made
31
31
  * elsewhere. With `turnId`, an advance of another turn keeps running: the signal came late.
32
32
  */
33
33
  export function abortLocal(ctx, id, kind = "cancel", turnId) {
@@ -10,7 +10,7 @@ import { aggregateWaits, isWorkflowManifest } from "../core/flow-host.js";
10
10
  import { canonical } from "../store/canonical.js";
11
11
  import { validateSandboxAttach } from "../core/sandbox-routes.js";
12
12
  import { resolveSandbox } from "../sandbox/resolve.js";
13
- import { withSandboxCapability } from "../sandbox/session-sandbox.js";
13
+ import { withPlatformTools, withSandboxCapability } from "../sandbox/session-sandbox.js";
14
14
  import { declaredSandboxes, sandboxSpecOf, sessionSandboxSpec, } from "../sandbox/share.js";
15
15
  import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
16
16
  import { checkPlacement, requirePods } from "../sandbox/placement.js";
@@ -156,12 +156,14 @@ export async function putSession(ctx, id, body, access, options = {}) {
156
156
  checkPlacement(await readSandboxConfig(t), sandbox.kind);
157
157
  if (sandbox.kind === "pod")
158
158
  await requirePods(ctx.pods, `Sandbox ${sandbox.sandboxId}`);
159
+ const pinned = sandbox.manifest === undefined ? platformTools(definition) : sandbox;
159
160
  const created = {
160
161
  id,
161
162
  agentId: body.agentId,
162
163
  ownerUserId: body.ownerUserId,
163
- manifest: sandbox.manifest ?? definition.manifest,
164
- manifestHash: sandbox.manifestHash ?? definition.manifestHash,
164
+ manifest: pinned.manifest ?? definition.manifest,
165
+ manifestHash: pinned.manifestHash ?? definition.manifestHash,
166
+ definitionHash: definition.manifestHash,
165
167
  implementationVersion: definition.implementationVersion,
166
168
  info: body.info,
167
169
  status: "idle",
@@ -232,6 +234,20 @@ async function sessionSandbox(t, body, definition, options) {
232
234
  return fail(resolved.status, resolved.errors.join(" "));
233
235
  return pin(definition, resolved.spec, resolved.source);
234
236
  }
237
+ /**
238
+ * The manifest a session without a sandbox pins: its agents' `read_artifact` (R2b C11), when
239
+ * one has a remote MCP server or an HTTP tool; nothing otherwise, and for a workflow.
240
+ */
241
+ function platformTools(definition) {
242
+ if (isWorkflowManifest(definition.manifest))
243
+ return {};
244
+ const pinned = withPlatformTools(definition.manifest);
245
+ if (pinned === undefined)
246
+ return {};
247
+ if (!pinned.ok)
248
+ return fail(400, pinned.message);
249
+ return { manifest: pinned.manifest, manifestHash: pinned.manifestHash };
250
+ }
235
251
  async function pin(definition, spec, source) {
236
252
  // A workflow's manifest stays as registered: its agents inherit the sandbox when they start.
237
253
  if (isWorkflowManifest(definition.manifest))
@@ -269,6 +285,7 @@ export async function sessionView(t, s) {
269
285
  agentId: s.agentId,
270
286
  ownerUserId: s.ownerUserId,
271
287
  manifestHash: s.manifestHash,
288
+ ...(s.definitionHash === undefined ? {} : { definitionHash: s.definitionHash }),
272
289
  implementationVersion: s.implementationVersion,
273
290
  status: s.status,
274
291
  activeTurnId: s.activeTurnId,
@@ -55,6 +55,15 @@ export function createPostgresTenantOpener(options) {
55
55
  from: opened.migrated.from,
56
56
  to: opened.migrated.to,
57
57
  });
58
+ // Protocol 10 removed the vault's `oauth` credential type: the migration deleted them.
59
+ for (const removed of opened.removedCredentials)
60
+ logger?.warn("oauth_credential_removed", {
61
+ tenantId,
62
+ vaultId: removed.vaultId,
63
+ credentialId: removed.credentialId,
64
+ url: removed.url,
65
+ message: "MCP OAuth credentials are gone (protocol 10): store a key for this server in a vault, or reach it through a gateway",
66
+ });
58
67
  let reads;
59
68
  try {
60
69
  reads = createPostgresReadStore(options.sql, tenantId);
@@ -8,23 +8,24 @@
8
8
  * otherwise (tests, a Host without S2) this module runs a relay of the Tenant's own commits,
9
9
  * which reconciles the record with the streams when it starts.
10
10
  * - **Basin generations.** The Tenant's session streams live in its current basin generation
11
- * (`streams/basin.ts`). A reset moves it to the next one (`tenantReset`): the old basin gets
12
- * a `sessions.reset` signal, every process moves its readers, and the old basin is deleted
13
- * after a grace period (again when the Tenant opens, until it is gone).
11
+ * (`streams/basin.ts`). A reset moves it to the next one: its transaction puts a
12
+ * `sessions.reset` signal on the control bus (`control.ts`), every process moves its readers
13
+ * (`tenantReset` here, `checkSessionStreams` elsewhere), and the old basin is deleted after a
14
+ * grace period (again when the Tenant opens, until it is gone).
14
15
  * - **Basin.** The current basin is created when the Tenant opens. A missing basin (S2 down
15
16
  * then, or deleted since) is repaired on first use: a failed `ensureTenant` retries in the background with backoff,
16
17
  * and `streamsStatus` reports the basin's state.
17
18
  * - **Readers.** History and session SSE read the session's stream
18
- * (`tenant/session-streams.ts`). This module runs one `tenant/control` reader per Tenant per
19
- * process, on the current basin, which calls `ctx.abortLocal` for each `session.cancel`
20
- * and checks the session streams for each `sessions.reset`.
19
+ * (`tenant/session-streams.ts`). Those are the only readers: S2 serves API listeners, and
20
+ * nothing internal reads or writes it (D48). Signals between processes go over the control
21
+ * bus on Postgres (`control.ts`).
21
22
  *
22
23
  * The caller passes the streams: the Host's S2 streams, or `MemoryStreams` for tests and a
23
24
  * local development Host (not durable).
24
25
  */
25
26
  import type { SessionStore } from "../store/types.js";
26
27
  import { type StreamRelay, type StreamRelayStatus } from "../streams/relay/core.js";
27
- import { type DurableStreams } from "../streams/types.js";
28
+ import type { DurableStreams } from "../streams/types.js";
28
29
  import type { TenantContext } from "./context.js";
29
30
  export interface WireStreamsOptions {
30
31
  tenantId: string;
@@ -56,7 +57,7 @@ export interface StreamsWiring {
56
57
  readonly relay: StreamRelay | undefined;
57
58
  /** The current basin's state (`streamsStatus`). */
58
59
  basin(): BasinStatus;
59
- /** Moves this process's readers and control reader to basin generation `generation`. */
60
+ /** Moves this process's readers to basin generation `generation`. */
60
61
  moveTo(generation: number): void;
61
62
  /** Deletes the basins of retired generations after the grace period. */
62
63
  retire(generations: readonly number[]): void;
@@ -65,23 +66,16 @@ export interface StreamsWiring {
65
66
  }
66
67
  /**
67
68
  * Wires the Tenant to Durable Streams: reads its basin generation, checks the basin, starts
68
- * the Tenant's relay (unless the Host relays), the control reader and the retired basins'
69
- * deletion, and records the handles on `ctx.sessionStreams.wiring`. Call it once, after
70
- * `ctx` is built and before the Tenant serves requests.
69
+ * the Tenant's relay (unless the Host relays) and the retired basins' deletion, and records
70
+ * the handles on `ctx.sessionStreams.wiring`. Call it once, after `ctx` is built and before
71
+ * the Tenant serves requests.
71
72
  */
72
73
  export declare function wireStreams(ctx: TenantContext, options: WireStreamsOptions): Promise<StreamsWiring>;
73
74
  /**
74
- * Appends `session.cancel` for `sessionId` and its cancelled turn to `tenant/control`, so the
75
- * process running that turn's advance aborts it. Call it from `t.afterCommit` after a cancel
76
- * commits. A lost signal costs latency only (the advance checks the Session Store before every
77
- * effect), so failures are logged, never thrown.
78
- */
79
- export declare function signalSessionCancel(ctx: TenantContext, sessionId: string, turnId: string | null): void;
80
- /**
81
- * After a reset deleted the Tenant's sessions and moved it to a new basin generation: tells
82
- * every process with the Tenant open (`sessions.reset` on the old basin's control stream),
83
- * moves this process's readers, and deletes the old basin after the grace period. Failures
84
- * are logged; the periodic check and the next open finish the job.
75
+ * After a reset deleted the Tenant's sessions and moved it to a new basin generation: moves
76
+ * this process's readers at once and deletes the old basin after the grace period. The other
77
+ * processes move on the reset's `sessions.reset` signal (`control.ts`); the periodic check
78
+ * and the next open finish the job.
85
79
  */
86
80
  export declare function tenantReset(ctx: TenantContext): Promise<void>;
87
81
  /** Tenant status of the streams seam (for `GET /v1/tenant`). */
@@ -1,7 +1,5 @@
1
1
  import { basinOf } from "../streams/basin.js";
2
- import { signalCancel, signalSessionsReset } from "../streams/control.js";
3
2
  import { createStreamRelay, } from "../streams/relay/core.js";
4
- import { CONTROL_STREAM, } from "../streams/types.js";
5
3
  import { checkSessionStreams, currentBasin, sleep, } from "./session-streams.js";
6
4
  const RETRY_MIN_MS = 100;
7
5
  const RETRY_MAX_MS = 2000;
@@ -16,9 +14,9 @@ const RETIRE_GRACE_MS = 60_000;
16
14
  const messageOf = (error) => error instanceof Error ? error.message : String(error);
17
15
  /**
18
16
  * Wires the Tenant to Durable Streams: reads its basin generation, checks the basin, starts
19
- * the Tenant's relay (unless the Host relays), the control reader and the retired basins'
20
- * deletion, and records the handles on `ctx.sessionStreams.wiring`. Call it once, after
21
- * `ctx` is built and before the Tenant serves requests.
17
+ * the Tenant's relay (unless the Host relays) and the retired basins' deletion, and records
18
+ * the handles on `ctx.sessionStreams.wiring`. Call it once, after `ctx` is built and before
19
+ * the Tenant serves requests.
22
20
  */
23
21
  export async function wireStreams(ctx, options) {
24
22
  if (ctx.sessionStreams.wiring)
@@ -53,17 +51,6 @@ export async function wireStreams(ctx, options) {
53
51
  },
54
52
  });
55
53
  relay?.start();
56
- // One control reader, on the current basin; moving to a new generation restarts it there.
57
- let control = new AbortController();
58
- const startControl = () => follow(streams, currentBasin(ctx), CONTROL_STREAM, AbortSignal.any([stop.signal, control.signal]), (body) => {
59
- const signal = body;
60
- if (signal?.type === "session.cancel" && typeof signal.sessionId === "string")
61
- ctx.abortLocal(signal.sessionId, typeof signal.turnId === "string" ? signal.turnId : undefined);
62
- else if (signal?.type === "sessions.reset")
63
- void checkSessionStreams(ctx).catch(report("session stream check failed"));
64
- }, report("control stream read failed; retrying"));
65
- // Signals appended from here on reach this process.
66
- await startControl();
67
54
  // Streams whose session was reset end on the `sessions.reset` signal; this catches lost ones.
68
55
  const feedCheck = setInterval(() => void checkSessionStreams(ctx).catch(report("session stream check failed")), FEED_CHECK_MS);
69
56
  feedCheck.unref();
@@ -78,9 +65,6 @@ export async function wireStreams(ctx, options) {
78
65
  return;
79
66
  ctx.sessionStreams.generation = generation;
80
67
  basin.repair();
81
- control.abort();
82
- control = new AbortController();
83
- void startControl();
84
68
  },
85
69
  retire(retired) {
86
70
  for (const generation of retired) {
@@ -153,33 +137,16 @@ function commitSource(store, tenantId) {
153
137
  };
154
138
  }
155
139
  /**
156
- * Appends `session.cancel` for `sessionId` and its cancelled turn to `tenant/control`, so the
157
- * process running that turn's advance aborts it. Call it from `t.afterCommit` after a cancel
158
- * commits. A lost signal costs latency only (the advance checks the Session Store before every
159
- * effect), so failures are logged, never thrown.
160
- */
161
- export function signalSessionCancel(ctx, sessionId, turnId) {
162
- const streams = ctx.sessionStreams.wiring?.streams;
163
- if (!streams)
164
- return;
165
- void signalCancel(streams, currentBasin(ctx), sessionId, turnId ?? undefined).catch((error) => ctx.config.logger.warn("cancel signal failed", {
166
- sessionId,
167
- message: messageOf(error),
168
- }));
169
- }
170
- /**
171
- * After a reset deleted the Tenant's sessions and moved it to a new basin generation: tells
172
- * every process with the Tenant open (`sessions.reset` on the old basin's control stream),
173
- * moves this process's readers, and deletes the old basin after the grace period. Failures
174
- * are logged; the periodic check and the next open finish the job.
140
+ * After a reset deleted the Tenant's sessions and moved it to a new basin generation: moves
141
+ * this process's readers at once and deletes the old basin after the grace period. The other
142
+ * processes move on the reset's `sessions.reset` signal (`control.ts`); the periodic check
143
+ * and the next open finish the job.
175
144
  */
176
145
  export async function tenantReset(ctx) {
177
146
  const wiring = ctx.sessionStreams.wiring;
178
147
  if (!wiring)
179
148
  return;
180
- const previous = currentBasin(ctx);
181
149
  const generations = await ctx.store.tx((t) => t.basinGenerations());
182
- await signalSessionsReset(wiring.streams, previous, generations.current).catch((error) => ctx.config.logger.warn("sessions.reset signal failed", { message: messageOf(error) }));
183
150
  wiring.moveTo(generations.current);
184
151
  wiring.retire(generations.retired);
185
152
  }
@@ -283,50 +250,3 @@ function basinKeeper(streams, basinKey, signal, onError) {
283
250
  status: () => ({ ready, failures, lastError }),
284
251
  };
285
252
  }
286
- /**
287
- * Follows a signal stream from its tail at start, calling `onRecord` for each record, until
288
- * `signal` aborts. Failed reads retry with backoff; a read that ends by itself (the basin was
289
- * deleted) starts again from the new tail. Resolves once the first tail is known (or failed).
290
- */
291
- function follow(streams, basin, stream, signal, onRecord, onError) {
292
- let started;
293
- const ready = new Promise((resolve) => (started = resolve));
294
- void (async () => {
295
- let from;
296
- let delay = RETRY_MIN_MS;
297
- while (!signal.aborted) {
298
- try {
299
- try {
300
- from ??= await streams.tail(basin, stream);
301
- }
302
- finally {
303
- started();
304
- }
305
- for await (const record of streams.read(basin, stream, from, {
306
- signal,
307
- })) {
308
- from = record.seq + 1;
309
- delay = RETRY_MIN_MS;
310
- try {
311
- onRecord(record.body);
312
- }
313
- catch (error) {
314
- onError(error);
315
- }
316
- }
317
- if (signal.aborted)
318
- return;
319
- from = undefined;
320
- }
321
- catch (error) {
322
- if (signal.aborted)
323
- return;
324
- onError(error);
325
- }
326
- await sleep(delay, signal);
327
- delay = Math.min(delay * 2, RETRY_MAX_MS);
328
- }
329
- started();
330
- })();
331
- return ready;
332
- }
@@ -13,7 +13,9 @@
13
13
  * stopped, and sandboxes whose session or sandbox resource is gone are removed, by the
14
14
  * workspace capability (`harness-api/workspace.ts`: here, or in the harness that serves
15
15
  * workspaces). Idle MCP connections of the in-process harness are closed.
16
- * 4. **Hooks.** Callbacks registered with `ctx.onSweep`.
16
+ * 4. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
17
+ * deleted: followers read back only the last two minutes (`store/postgres/control.ts`).
18
+ * 5. **Hooks.** Callbacks registered with `ctx.onSweep`.
17
19
  *
18
20
  * Every step runs one transaction per session it changes (a linked agent and its workflow
19
21
  * share one, child first), so the sweep follows the lock order in `store/types.ts`. A failing
@@ -22,6 +24,8 @@
22
24
  */
23
25
  import { pendingAgentEffects, reconcilePendingAgentEffect, } from "../core/flow-host.js";
24
26
  const BATCH = 100;
27
+ /** How long a control signal is kept after it was written. */
28
+ const SIGNAL_RETENTION_MS = 60 * 60 * 1000;
25
29
  export async function sweep(ctx) {
26
30
  if (ctx.closing || ctx.closed)
27
31
  return;
@@ -40,6 +44,10 @@ export async function sweep(ctx) {
40
44
  ["pods", () => reconcileStalePods(ctx, now)],
41
45
  // A harness elsewhere closes its own idle MCP connections.
42
46
  ["mcp", async () => ctx.mcp?.sweep()],
47
+ [
48
+ "signals",
49
+ () => ctx.store.tx((t) => t.pruneSignals(new Date(now.getTime() - SIGNAL_RETENTION_MS))),
50
+ ],
43
51
  ...[...ctx.sweepHooks].map((hook) => ["hook", hook]),
44
52
  ];
45
53
  let failure;
@@ -0,0 +1,57 @@
1
+ import type { HostEffect } from "@nylorun/harness/run";
2
+ import type { TenantContext } from "./context.js";
3
+ /** The most of one tool result that is recorded inline (Q22): 32 KiB, as JSON. */
4
+ export declare const TOOL_RESULT_INLINE_BYTES: number;
5
+ /** How much of a stored result's start, and of its end, the preview keeps. */
6
+ export declare const PREVIEW_HEAD_BYTES: number;
7
+ export declare const PREVIEW_TAIL_BYTES = 1024;
8
+ /**
9
+ * The most one step's results may hold together: a step's results are one transcript entry, so
10
+ * one event, which must stay well under S2's 1 MiB record.
11
+ */
12
+ export declare const STEP_RESULTS_BUDGET_BYTES: number;
13
+ /** A result's cap once its step's budget is spent: a stub naming its artifact. */
14
+ export declare const TOOL_RESULT_STUB_BYTES = 512;
15
+ /** A result this small is never shaped, nor counted against its step's budget. */
16
+ export declare const TOOL_RESULT_SMALL_BYTES = 1024;
17
+ /** The call whose outcome is shaped, and the session its artifacts belong to. */
18
+ export interface ShapedCall {
19
+ readonly request: HostEffect;
20
+ readonly session: {
21
+ readonly id: string;
22
+ readonly ownerUserId: string;
23
+ };
24
+ /** A remote MCP tool's: its output may be the server's content parts. */
25
+ readonly mcp: boolean;
26
+ /** This result's cap (`resultLimit`). Default `TOOL_RESULT_INLINE_BYTES`. */
27
+ readonly limit?: number;
28
+ }
29
+ /** A result's cap when the other results of its step already took `used` bytes. */
30
+ export declare function resultLimit(used: number): number;
31
+ /** The JSON size of `value` in bytes: what it adds to an event or a prompt. */
32
+ export declare function jsonBytes(value: unknown): number;
33
+ /**
34
+ * What a tool outcome's result weighs in its step's transcript entry: a completed one's output,
35
+ * a failed one's message; 0 for anything else, such as a model call's outcome.
36
+ */
37
+ export declare function resultBytes(value: unknown): number;
38
+ /**
39
+ * False for an outcome `shapeToolOutcome` returns as it is, whatever made it: a model call's, a
40
+ * small tool result with no parts. Spares a look at the effect for most outcomes.
41
+ */
42
+ export declare function mayNeedShaping(value: unknown): boolean;
43
+ /**
44
+ * The outcome `value` of `call`, shaped to fit its cap (see the module comment). An outcome that
45
+ * fits and has no binary part comes back as it is.
46
+ */
47
+ export declare function shapeToolOutcome(ctx: TenantContext, call: ShapedCall, value: unknown, signal: AbortSignal): Promise<unknown>;
48
+ /**
49
+ * The start and end of `text` in whole characters, `budget` bytes split four to one: by default
50
+ * the first `PREVIEW_HEAD_BYTES` and the last `PREVIEW_TAIL_BYTES`.
51
+ */
52
+ export declare function previewOf(text: string, budget?: number): string;
53
+ /**
54
+ * The nearest character boundary of the UTF-8 `bytes` at or before (`down`) or at or after
55
+ * (`up`) byte `at`: never inside a character.
56
+ */
57
+ export declare function utf8Boundary(bytes: Uint8Array, at: number, direction: "down" | "up"): number;