@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
@@ -59,7 +59,7 @@ import type { RecordReader } from "../streams/relay/types.js";
59
59
  import type { EventPayload, EventType, LiveEvent, SandboxEvent, SandboxEventPayload, SandboxEventType, SandboxKind, SandboxPodDesired, SandboxPodObserved, SessionEventOf } from "@nylorun/core/contracts";
60
60
  import type { SandboxManifest } from "@nylorun/core/define";
61
61
  import type { HostEffect } from "@nylorun/harness/run";
62
- import type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, PrincipalRow, ToolCrossingRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, OAuthPendingRow } from "./postgres/schema.js";
62
+ import type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, PrincipalRow, ToolCrossingRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow } from "./postgres/schema.js";
63
63
  /** Tables holding JSON documents keyed by `id`. */
64
64
  export type DocTable = "definitions" | "sessions" | "commands" | "effects" | "sandboxes" | "links";
65
65
  export declare const DOC_TABLES: readonly DocTable[];
@@ -195,7 +195,7 @@ export type CommitListener = (commit: Commit) => void;
195
195
  * `bytea` columns, never inside a JSON body), the vault's audit and idempotency records, the
196
196
  * Tenant's signing keys, a model call in the usage ledger and a model budget.
197
197
  */
198
- export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, OAuthPendingRow, };
198
+ export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, };
199
199
  /** Which rows of the usage ledger a total covers. */
200
200
  export interface ModelUsageQuery {
201
201
  scope: "tenant" | "agent" | "turn";
@@ -261,6 +261,39 @@ export interface SessionStore {
261
261
  record(): RecordReader;
262
262
  /** Reachability and schema check for Tenant status and `/ready`. Never throws. */
263
263
  health(): Promise<StoreHealth>;
264
+ /**
265
+ * Follows the control bus (D21): calls `onSignal` once for each signal committed after the
266
+ * returned promise resolves, on every process that follows. Delivery is by notification,
267
+ * backed by a poll of recent signals (every `pollMs`, and each time the listener connects
268
+ * again) that catches the notifications a dropped connection missed. `onSignal` must not
269
+ * throw; what it throws goes to `onError`, as do listener failures.
270
+ */
271
+ followSignals(onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
272
+ close(): Promise<void>;
273
+ }
274
+ /**
275
+ * A signal on the control bus (D21, D48): one process tells the others with the Tenant open.
276
+ * Signals are not events: they are never recorded in a session's log or streamed, and a
277
+ * lost one costs latency, never correctness. They never go through S2.
278
+ */
279
+ export type ControlSignal = {
280
+ /** The process running the session's advance aborts it. */
281
+ type: "session.cancel";
282
+ sessionId: string;
283
+ /** The cancelled turn: an advance of another turn keeps running. Absent: any advance. */
284
+ turnId?: string;
285
+ } | {
286
+ /** The Tenant's sessions were reset and it moved to basin generation `generation`. */
287
+ type: "sessions.reset";
288
+ generation: number;
289
+ };
290
+ export interface FollowSignalsOptions {
291
+ /** How often recent signals are read back. Default 5 s. */
292
+ pollMs?: number;
293
+ onError?: (error: unknown) => void;
294
+ }
295
+ export interface SignalFollower {
296
+ /** Stops following and ends the listener's connection. */
264
297
  close(): Promise<void>;
265
298
  }
266
299
  export interface StoreHealth {
@@ -393,6 +426,13 @@ export interface Tx {
393
426
  /** Links of one workflow session joined to their existing agent sessions, ordered by agent session id. */
394
427
  linkedSessions<S extends SessionDoc = SessionDoc>(workflowSessionId: string): Promise<LinkedSession<S>[]>;
395
428
  counts(): Promise<StoreCounts>;
429
+ /**
430
+ * Puts `signal` on the control bus: a row, and a notification Postgres sends every
431
+ * following process when this transaction commits (and never on rollback).
432
+ */
433
+ signal(signal: ControlSignal): Promise<void>;
434
+ /** Deletes signals written before `before`. Returns how many. */
435
+ pruneSignals(before: Date): Promise<number>;
396
436
  basinGenerations(): Promise<BasinGenerations>;
397
437
  /** Forgets a retired generation once its basin is deleted. */
398
438
  forgetRetiredGeneration(generation: number): Promise<void>;
@@ -443,12 +483,6 @@ export interface Tx {
443
483
  getVaultIdempotency(id: string): Promise<VaultIdempotencyRow | undefined>;
444
484
  /** Rejects when the id exists. */
445
485
  insertVaultIdempotency(row: VaultIdempotencyRow): Promise<void>;
446
- /** An MCP OAuth connect's start (F9 C2). Rejects on a duplicate state or an unknown vault. */
447
- insertOAuthPending(row: OAuthPendingRow): Promise<void>;
448
- /** Deletes and returns the pending connect of `stateHash`, expired or not: each is used once. */
449
- takeOAuthPending(stateHash: string): Promise<OAuthPendingRow | undefined>;
450
- /** Deletes the pending connects that expired before `now` (ISO); returns how many. */
451
- deleteExpiredOAuthPending(now: string): Promise<number>;
452
486
  /**
453
487
  * Serializes signing key changes: held until the transaction ends. Take it before reading
454
488
  * the keys a change depends on, so concurrent first uses or rotations do not collide.
@@ -7,7 +7,10 @@
7
7
  * | Stream | Contents | Written by | Read by |
8
8
  * | --- | --- | --- | --- |
9
9
  * | `sessions/<sessionId>` | every event of the session, in sequence | the stream relay, from the record | history and session SSE |
10
- * | `tenant/control` | `session.cancel`, `sessions.reset` and `action.resolved` signals | API nodes | every process with the Tenant open |
10
+ *
11
+ * S2 serves API listeners only (history, SSE, AG-UI, A2A): nothing inside the Runtime signals
12
+ * through it or waits on it (D48). Signals between processes go over the control bus on
13
+ * Postgres (`store/postgres/control.ts`).
11
14
  *
12
15
  * **Basin generations.** A session id is unique within its Tenant's basin generation. A
13
16
  * Tenant reset, the only path that frees ids, moves the Tenant to the next generation, so
@@ -104,38 +107,12 @@ export interface DurableStreams {
104
107
  listStreams(tenantId: string, prefix: string): Promise<string[]>;
105
108
  close(): Promise<void>;
106
109
  /**
107
- * Resolves when the backing service answers, rejects otherwise (readiness,
108
- * `infra/streams.ts`). Absent for in-process implementations, which are
110
+ * Resolves when the backing service answers, rejects otherwise (the Tenant's status,
111
+ * `streamsStatus` in `tenant/streams.ts`; never readiness, D48). Absent for in-process implementations, which are
109
112
  * always reachable.
110
113
  */
111
114
  probe?(signal: AbortSignal): Promise<void>;
112
115
  }
113
116
  export declare const SESSION_STREAM_PREFIX = "sessions/";
114
- /** `session.cancel` and `sessions.reset` signals for every process with the Tenant open. */
115
- export declare const CONTROL_STREAM = "tenant/control";
116
117
  /** The stream of a session: `sessions/<sessionId>`. */
117
118
  export declare function sessionStream(sessionId: string): string;
118
- /**
119
- * Signals are not events: they are appended directly, never recorded, and a
120
- * lost signal costs latency, not correctness.
121
- */
122
- /** Ends the advance of `sessionId` on the process running it. */
123
- export interface SessionCancelSignal {
124
- type: "session.cancel";
125
- sessionId: string;
126
- /**
127
- * The cancelled turn. Only an advance of this turn stops, so a signal delivered late (an
128
- * append retried after S2 returns) never stops a later turn. Absent: any advance stops.
129
- */
130
- turnId?: string;
131
- }
132
- /**
133
- * The Tenant was reset and moved to basin generation `generation`. Appended to the old
134
- * generation's `tenant/control`: each process switches its readers to the new basin and
135
- * ends the streams of sessions the reset deleted.
136
- */
137
- export interface SessionsResetSignal {
138
- type: "sessions.reset";
139
- generation?: number;
140
- }
141
- export type ControlSignal = SessionCancelSignal | SessionsResetSignal;
@@ -7,7 +7,10 @@
7
7
  * | Stream | Contents | Written by | Read by |
8
8
  * | --- | --- | --- | --- |
9
9
  * | `sessions/<sessionId>` | every event of the session, in sequence | the stream relay, from the record | history and session SSE |
10
- * | `tenant/control` | `session.cancel`, `sessions.reset` and `action.resolved` signals | API nodes | every process with the Tenant open |
10
+ *
11
+ * S2 serves API listeners only (history, SSE, AG-UI, A2A): nothing inside the Runtime signals
12
+ * through it or waits on it (D48). Signals between processes go over the control bus on
13
+ * Postgres (`store/postgres/control.ts`).
11
14
  *
12
15
  * **Basin generations.** A session id is unique within its Tenant's basin generation. A
13
16
  * Tenant reset, the only path that frees ids, moves the Tenant to the next generation, so
@@ -39,10 +42,8 @@
39
42
  * encode them as they need.
40
43
  */
41
44
  // ---------------------------------------------------------------------------
42
- // Stream names and signal records
45
+ // Stream names
43
46
  export const SESSION_STREAM_PREFIX = "sessions/";
44
- /** `session.cancel` and `sessions.reset` signals for every process with the Tenant open. */
45
- export const CONTROL_STREAM = "tenant/control";
46
47
  /** The stream of a session: `sessions/<sessionId>`. */
47
48
  export function sessionStream(sessionId) {
48
49
  if (!sessionId)
@@ -1,5 +1,13 @@
1
1
  import type { SandboxToolOutcome } from "@nylorun/core/contracts";
2
+ import { type ToolResultFile } from "@nylorun/core/define";
2
3
  import type { HostEffect } from "@nylorun/harness/run";
3
4
  import { type TenantContext } from "./context.js";
4
- export { isSaveArtifactCall } from "../harness/calls.js";
5
+ export { isReadArtifactCall, isSaveArtifactCall } from "../harness/calls.js";
5
6
  export declare function callSaveArtifact(ctx: TenantContext, request: HostEffect, signal: AbortSignal): Promise<SandboxToolOutcome>;
7
+ /** What `read_artifact` returns: a page of a text artifact, or an image as a file. */
8
+ interface ReadOutcome {
9
+ readonly kind: "completed";
10
+ readonly output: Record<string, unknown>;
11
+ readonly files?: readonly ToolResultFile[];
12
+ }
13
+ export declare function callReadArtifact(ctx: TenantContext, request: HostEffect, signal: AbortSignal): Promise<ReadOutcome | SandboxToolOutcome>;
@@ -1,20 +1,28 @@
1
1
  /**
2
- * `nylorun.save_artifact` (F8.1): our engine's built-in tool that saves a file as an artifact
3
- * of its session. The `nylorun.artifacts` capability comes with a session's sandbox
4
- * (`sandbox/session-sandbox.ts`); the Runtime runs the tool itself, like the sandbox tools:
2
+ * Our engine's built-in artifact tools, of the `nylorun.artifacts` capability
3
+ * (`sandbox/session-sandbox.ts`). The Runtime runs them itself, like the sandbox tools.
5
4
  *
5
+ * `save_artifact` (F8.1), which comes with a session's sandbox, saves a file as an artifact of
6
+ * its session:
6
7
  * - `path`: a file in the session's sandbox, read as bytes in the sandbox's queue;
7
8
  * - `content`: text the model passes.
8
- *
9
9
  * The version commits with `artifact.created` (or `artifact.version.created`) on the session's
10
- * stream, carrying the turn and the tool call. Expected problems (no such file, too large, the
11
- * Tenant's total reached) are failed outcomes the model sees; only infrastructure errors throw.
10
+ * stream, carrying the turn and the tool call.
11
+ *
12
+ * `read_artifact` (R2b C11, Q24), which an agent with a remote MCP server or an HTTP tool has,
13
+ * reads a text artifact of its own session in pages of at most 32 KiB, from a byte `offset`:
14
+ * how the model reads a tool result the Runtime stored because it was too large to show
15
+ * (`tool-results.ts`). An image is shown to a model that reads images, as a file of the result.
16
+ * It reaches only the session's own artifacts, never a Tenant-wide one or another session's.
17
+ *
18
+ * Expected problems (no such file, too large, the Tenant's total reached, not text) are failed
19
+ * outcomes the model sees; only infrastructure errors throw.
12
20
  */
13
21
  import { basename } from "node:path";
14
- import { SANDBOX_CAPABILITY_ID } from "@nylorun/core/define";
22
+ import { READ_ARTIFACT_MAX_BYTES, SANDBOX_CAPABILITY_ID, } from "@nylorun/core/define";
15
23
  import { isArtifactId } from "@nylorun/core/compatibility";
16
24
  import { readArtifactLimits } from "../artifacts/config.js";
17
- import { contentTypeFor, normalizeContentType } from "../artifacts/media-types.js";
25
+ import { MODEL_FILE_MAX_BYTES, contentTypeFor, essence, isModelImage, isText, normalizeContentType, } from "../artifacts/media-types.js";
18
26
  import { uploadArtifact } from "../artifacts/service.js";
19
27
  import { DEFAULT_CONTENT_TYPE } from "../blob/index.js";
20
28
  import { sandboxCapabilityOf } from "../sandbox/manager.js";
@@ -22,8 +30,9 @@ import { sandboxWorkspaceOf } from "../sandbox/share.js";
22
30
  import { sandboxLookup, sessionOf } from "./context.js";
23
31
  import { HttpError } from "./http.js";
24
32
  import { manifestFor } from "./session.js";
33
+ import { jsonBytes, utf8Boundary } from "./tool-results.js";
25
34
  import { toolIds } from "./transcript.js";
26
- export { isSaveArtifactCall } from "../harness/calls.js";
35
+ export { isReadArtifactCall, isSaveArtifactCall } from "../harness/calls.js";
27
36
  const failed = (code, message) => ({
28
37
  kind: "failed",
29
38
  code,
@@ -117,3 +126,98 @@ export async function callSaveArtifact(ctx, request, signal) {
117
126
  throw error;
118
127
  }
119
128
  }
129
+ export async function callReadArtifact(ctx, request, signal) {
130
+ const input = (request.input ?? {});
131
+ const artifactId = typeof input.artifactId === "string" ? input.artifactId : "";
132
+ const offset = input.offset ?? 0;
133
+ const length = input.length ?? READ_ARTIFACT_MAX_BYTES;
134
+ if (artifactId === "" ||
135
+ typeof offset !== "number" ||
136
+ !Number.isSafeInteger(offset) ||
137
+ offset < 0 ||
138
+ typeof length !== "number" ||
139
+ !Number.isSafeInteger(length) ||
140
+ length < 1 ||
141
+ length > READ_ARTIFACT_MAX_BYTES)
142
+ return failed("artifact.invalid_input", `Give artifactId, an offset of 0 or more, and a length of 1 to ${READ_ARTIFACT_MAX_BYTES}.`);
143
+ const found = await ctx.store.tx(async (t) => {
144
+ const s = await sessionOf(t, request.sessionId);
145
+ if (!isArtifactId(artifactId))
146
+ return undefined;
147
+ // Only an artifact of this session: never a Tenant-wide one, nor another session's.
148
+ const artifact = await t.artifact(artifactId);
149
+ if (!artifact || artifact.sessionId !== s.id)
150
+ return undefined;
151
+ const version = await t.artifactVersion(artifact.id, artifact.latestVersion);
152
+ return version ? { artifact, version } : undefined;
153
+ });
154
+ if (!found)
155
+ return failed("artifact.not_found", `Artifact ${artifactId} is not an artifact of this session.`);
156
+ const { artifact, version } = found;
157
+ const about = {
158
+ artifactId: artifact.id,
159
+ version: version.version,
160
+ name: artifact.name,
161
+ contentType: version.contentType,
162
+ size: version.size,
163
+ };
164
+ if (artifact.kind !== "file")
165
+ return failed("artifact.not_text", `${artifact.name} is a folder; read_artifact reads a file.`);
166
+ if (isModelImage(version.contentType) && version.size <= MODEL_FILE_MAX_BYTES)
167
+ return {
168
+ kind: "completed",
169
+ output: about,
170
+ files: [
171
+ {
172
+ mediaType: version.contentType,
173
+ reference: { artifactId: artifact.id, version: version.version, name: artifact.name },
174
+ },
175
+ ],
176
+ };
177
+ if (!isText(version.contentType))
178
+ return failed("artifact.not_text", `${artifact.name} is ${essence(version.contentType)}, not text; read_artifact reads text and shows images.`);
179
+ if (offset > version.size || (offset === version.size && version.size > 0))
180
+ return failed("artifact.invalid_input", `Offset ${offset} is past the end of ${artifact.name}, which is ${version.size} bytes.`);
181
+ const end = Math.min(offset + length, version.size);
182
+ const bytes = end === offset ? new Uint8Array() : await readRange(ctx, version.blobKey, offset, end - 1, signal);
183
+ const page = textPage(bytes, end === version.size);
184
+ const next = offset + page.bytes;
185
+ return {
186
+ kind: "completed",
187
+ output: {
188
+ ...about,
189
+ offset,
190
+ content: page.text,
191
+ ...(next < version.size ? { nextOffset: next } : {}),
192
+ },
193
+ };
194
+ }
195
+ /** Bytes `start` to `end` (both inclusive) of a version's blob. */
196
+ async function readRange(ctx, blobKey, start, end, signal) {
197
+ const got = await ctx.blobs.get(blobKey, { range: { start, end }, signal });
198
+ if (!got)
199
+ throw new Error("The artifact's bytes are missing from the Object store");
200
+ return new Uint8Array(await new Response(got.body).arrayBuffer());
201
+ }
202
+ /**
203
+ * The text of a page of UTF-8 `bytes` in whole characters, at most `READ_ARTIFACT_MAX_BYTES`
204
+ * once escaped as JSON, and how many bytes it used: the next page starts there. A page that
205
+ * starts inside a character skips its rest, and one that ends inside one (not `last`) stops
206
+ * before it.
207
+ */
208
+ function textPage(bytes, last) {
209
+ const from = utf8Boundary(bytes, 0, "up");
210
+ let to = last ? bytes.byteLength : utf8Boundary(bytes, bytes.byteLength, "down");
211
+ // A page shorter than one character: take what there is.
212
+ if (to <= from)
213
+ to = bytes.byteLength;
214
+ const decode = () => Buffer.from(bytes.subarray(from, to)).toString("utf8");
215
+ let text = decode();
216
+ // Escaped (quotes, newlines, control characters), text takes more room than its bytes: each
217
+ // byte left out frees at least one.
218
+ for (let size = jsonBytes(text); to > from && size > READ_ARTIFACT_MAX_BYTES; size = jsonBytes(text)) {
219
+ to = utf8Boundary(bytes, Math.max(from, to - (size - READ_ARTIFACT_MAX_BYTES)), "down");
220
+ text = decode();
221
+ }
222
+ return { text, bytes: to };
223
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Tenant request authentication and authorization: a bearer token resolves to an application
3
- * principal or a trusted issuer's token; anything else is the opaque 404
4
- * (D5). An application principal may act for a subject (`Nylorun-Subject`, `Nylorun-Scopes`);
5
- * an issuer token names its subject itself. `requireScopes` limits them to the routes their
6
- * scopes allow, decided from the route alone.
3
+ * principal or a trusted issuer's token. Anything else is `401` with a `Bearer` challenge
4
+ * (protocol 9, `resource-server.ts`): `credential_required` without one, `credential_invalid`
5
+ * for one the Tenant refuses, its reason only logged. An application principal may act for a
6
+ * subject (`Nylorun-Subject`, `Nylorun-Scopes`); an issuer token names its subject itself.
7
+ * `requireScopes` limits them to the routes their scopes allow, decided from the route alone.
7
8
  *
8
9
  * Browsers: a request with `Origin` may carry an issuer token (CORS is the operator's proxy's),
9
10
  * never an application key, which is a server secret.
@@ -21,6 +22,11 @@ export interface KeyAccess {
21
22
  }
22
23
  /** Every route that does not say otherwise is a Runtime API route. */
23
24
  export declare const RUNTIME_KEYS: KeyAccess;
25
+ /**
26
+ * The `resource_metadata` a route's challenges point at: a Runtime API route's, when the Runtime
27
+ * trusts issuers. The Management API is no OAuth resource.
28
+ */
29
+ export declare function challengeMetadata(ctx: TenantContext, request: IncomingMessage, keys: KeyAccess): string | undefined;
24
30
  export declare function authenticate(ctx: TenantContext, request: IncomingMessage, keys?: KeyAccess): Promise<AuthScope>;
25
31
  /**
26
32
  * What a subject needs for a route: any one of the scopes, nothing grants it (`never`), or
@@ -30,9 +36,13 @@ export declare function authenticate(ctx: TenantContext, request: IncomingMessag
30
36
  export type SubjectAccess = readonly SubjectScope[] | "never" | "any";
31
37
  /**
32
38
  * A subject or a token caller reaches a route only with one of the scopes it declares:
33
- * `403 scope_required`, decided from the route alone before anything is read.
39
+ * `403 scope_required`, decided from the route alone before anything is read. A token caller
40
+ * also gets the OAuth challenge (`error="insufficient_scope"` and the scopes that would do), so
41
+ * its client can ask its identity provider for them; a route no scope reaches has none.
34
42
  */
35
- export declare function requireScopes(scope: AuthScope, access: SubjectAccess): void;
43
+ export declare function requireScopes(scope: AuthScope, access: SubjectAccess,
44
+ /** The route's `resource_metadata` (`challengeMetadata`), for a token caller's challenge. */
45
+ resourceMetadata?: string): void;
36
46
  export declare function accessOf(scope: AuthScope): SessionAccess | undefined;
37
47
  /** The subject whose sessions the request is limited to, if it acts for one. */
38
48
  export declare function ownerOf(scope: AuthScope): string | undefined;
@@ -1,9 +1,10 @@
1
1
  import { SCOPES_HEADER, SUBJECT_HEADER, } from "@nylorun/core/compatibility";
2
- import { parseSubjectHeaders } from "@nylorun/core/contracts";
2
+ import { parseSubjectHeaders, TOKEN_SCOPES } from "@nylorun/core/contracts";
3
3
  import { hashToken } from "../core/bearer.js";
4
- import { fail, failOpaque } from "./http.js";
4
+ import { fail } from "./http.js";
5
5
  import { verifyIssuerToken } from "./issuers.js";
6
6
  import { looksLikeToken } from "./jwt.js";
7
+ import { bearerChallenge, failCredential, protectedResourceMetadataUrl } from "./resource-server.js";
7
8
  const SUBJECT_INVALID = { code: "subject_invalid" };
8
9
  /** One header's value, or `undefined`; a header sent twice is refused. */
9
10
  function singleHeader(request, name) {
@@ -18,20 +19,30 @@ function singleHeader(request, name) {
18
19
  }
19
20
  /** Every route that does not say otherwise is a Runtime API route. */
20
21
  export const RUNTIME_KEYS = { application: true, management: false };
22
+ /** The bearer of an `Authorization` header: the scheme is case-insensitive (RFC 9110 §11.1). */
23
+ const BEARER = /^bearer +(\S+) *$/i;
24
+ /**
25
+ * The `resource_metadata` a route's challenges point at: a Runtime API route's, when the Runtime
26
+ * trusts issuers. The Management API is no OAuth resource.
27
+ */
28
+ export function challengeMetadata(ctx, request, keys) {
29
+ return keys.application ? protectedResourceMetadataUrl(ctx.config, request) : undefined;
30
+ }
21
31
  export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
22
- const header = request.headers.authorization;
23
- const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
24
- if (!token || !header?.startsWith("Bearer ")) {
32
+ const metadata = challengeMetadata(ctx, request, keys);
33
+ const token = BEARER.exec(request.headers.authorization ?? "")?.[1];
34
+ // No credential, or another scheme: the client may not know it needs one (no error code).
35
+ if (!token) {
25
36
  ctx.config.logger.warn("credential rejected", {
26
37
  reason: "missing_bearer",
27
38
  });
28
- return failOpaque();
39
+ return failCredential("required", metadata);
29
40
  }
30
41
  // A trusted issuer's token (Host feature `trusted-issuers`), from a server or a browser: its
31
42
  // unverified `iss` names an issuer of the identity file, and only that issuer verifies it.
32
43
  const issuer = ctx.config.issuers?.claimed(token);
33
44
  if (issuer) {
34
- const scope = await verifyIssuerToken(ctx, issuer, token);
45
+ const scope = await verifyIssuerToken(ctx, issuer, token, metadata);
35
46
  if (singleHeader(request, SUBJECT_HEADER) !== undefined ||
36
47
  singleHeader(request, SCOPES_HEADER) !== undefined)
37
48
  fail(403, "An issuer token cannot act for another subject");
@@ -40,7 +51,7 @@ export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
40
51
  // Any other JWT: no issuer of this Host signed it (subject tokens are gone, protocol 7).
41
52
  if (looksLikeToken(token)) {
42
53
  ctx.config.logger.warn("credential rejected", { reason: "token_unknown_issuer" });
43
- return failOpaque();
54
+ return failCredential("invalid", metadata);
44
55
  }
45
56
  // Application keys never come from a browser or a shipped app: refused before they are even
46
57
  // looked up.
@@ -54,9 +65,9 @@ export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
54
65
  ctx.config.logger.warn("credential rejected", {
55
66
  reason: "unknown_token",
56
67
  });
57
- return failOpaque();
68
+ return failCredential("invalid", metadata);
58
69
  }
59
- // Read only after authentication, so an unknown caller sees the opaque 404 either way.
70
+ // Read only after authentication, so an unknown caller learns nothing of them.
60
71
  const subject = singleHeader(request, SUBJECT_HEADER);
61
72
  const scopes = singleHeader(request, SCOPES_HEADER);
62
73
  // A management key acts as itself on the Management API; Studio's key does too on a route
@@ -92,9 +103,13 @@ export async function authenticate(ctx, request, keys = RUNTIME_KEYS) {
92
103
  }
93
104
  /**
94
105
  * A subject or a token caller reaches a route only with one of the scopes it declares:
95
- * `403 scope_required`, decided from the route alone before anything is read.
106
+ * `403 scope_required`, decided from the route alone before anything is read. A token caller
107
+ * also gets the OAuth challenge (`error="insufficient_scope"` and the scopes that would do), so
108
+ * its client can ask its identity provider for them; a route no scope reaches has none.
96
109
  */
97
- export function requireScopes(scope, access) {
110
+ export function requireScopes(scope, access,
111
+ /** The route's `resource_metadata` (`challengeMetadata`), for a token caller's challenge. */
112
+ resourceMetadata) {
98
113
  if (scope.kind !== "subject" && scope.kind !== "token")
99
114
  return;
100
115
  if (access === "any")
@@ -105,11 +120,20 @@ export function requireScopes(scope, access) {
105
120
  details: { scopes: [] },
106
121
  });
107
122
  const held = scope.scopes;
108
- if (!access.some((name) => held.has(name)))
109
- fail(403, `Scope ${access.join(" or ")} required`, {
110
- code: "scope_required",
111
- details: { scopes: access },
112
- });
123
+ if (!access.some((name) => held.has(name))) {
124
+ // Any one of the route's scopes reaches it, but OAuth's list is of scopes held together:
125
+ // the challenge names the first a token can carry, or none when no token reaches the route.
126
+ const askable = access.find((name) => TOKEN_SCOPES.includes(name));
127
+ fail(403, `Scope ${access.join(" or ")} required`, { code: "scope_required", details: { scopes: access } }, scope.kind === "token" && askable
128
+ ? {
129
+ "www-authenticate": bearerChallenge({
130
+ error: "insufficient_scope",
131
+ scope: askable,
132
+ resourceMetadata,
133
+ }),
134
+ }
135
+ : {});
136
+ }
113
137
  }
114
138
  export function accessOf(scope) {
115
139
  switch (scope.kind) {
@@ -10,9 +10,10 @@
10
10
  * (every accepted one writes a new checkpoint segment), except that a workflow's `approve` and
11
11
  * `respond` name `<type>:<turnId>:<interactionId>`: a flow resumes in the same segment.
12
12
  *
13
- * Cancel commits `cancelled` first; the engine host sees it before its next effect and before
14
- * settlement on any Worker. It then aborts an advance running on this process
15
- * (`ctx.abortLocal`); reaching an advance on another process is the control stream's job.
13
+ * Cancel commits `cancelled` first, with a `session.cancel` signal on the control bus in the
14
+ * same transaction; the engine host sees it before its next effect and before settlement on
15
+ * any Worker. It then aborts an advance running on this process (`ctx.abortLocal`); every
16
+ * other process aborts its own on the signal (`control.ts`).
16
17
  */
17
18
  import { randomUUID } from "node:crypto";
18
19
  import { createDurableCheckpoint, createFlowCheckpoint, resumeFlowCheckpoint, } from "@nylorun/harness/run";
@@ -24,7 +25,6 @@ import { fail } from "./http.js";
24
25
  import { accessOf } from "./auth.js";
25
26
  import { checkSandboxTurn } from "./sandboxes.js";
26
27
  import { rebaseSessionState, turnManifestOf, variantStore } from "./session.js";
27
- import { signalSessionCancel } from "./streams.js";
28
28
  import { slimModelEffects } from "./slim.js";
29
29
  import { resolveMessageParts } from "../artifacts/parts.js";
30
30
  /** A command's identity for idempotency: everything but the request id. */
@@ -83,8 +83,12 @@ export async function command(ctx, id, input, scope) {
83
83
  reason: command.reason,
84
84
  });
85
85
  await slimModelEffects(t, id, cancelledTurnId);
86
- // The process running the advance aborts it on `session.cancel` (tenant/control).
87
- t.afterCommit(() => signalSessionCancel(ctx, id, cancelledTurnId));
86
+ // The process running the advance aborts it on this signal (control.ts), sent at commit.
87
+ await t.signal({
88
+ type: "session.cancel",
89
+ sessionId: id,
90
+ ...(cancelledTurnId !== null ? { turnId: cancelledTurnId } : {}),
91
+ });
88
92
  s.activeTurnId = null;
89
93
  if (cancelledTurnId !== null)
90
94
  s.lastTurnId = cancelledTurnId;
@@ -24,7 +24,6 @@ import type { ModelGate } from "../gates/model-gate.js";
24
24
  import type { ToolGate } from "../gates/tool-gate.js";
25
25
  import type { Keys } from "../keys/keys.js";
26
26
  import type { VaultService } from "../vault/service.js";
27
- import type { CredentialSources } from "../vault/sources.js";
28
27
  import type { McpPool } from "../mcp/pool.js";
29
28
  import type { McpDiagnostic, McpSnapshot } from "../mcp/snapshot.js";
30
29
  import type { HarnessApiServer } from "../harness-api/server.js";
@@ -46,6 +45,11 @@ export interface Session {
46
45
  /** Session pin — the manifest the session was created with. */
47
46
  manifest: any;
48
47
  manifestHash: string;
48
+ /**
49
+ * The hash of the definition the session was opened from; `manifestHash` differs when the
50
+ * Runtime pinned its own capabilities beside it (R2b C11). Absent on older sessions.
51
+ */
52
+ definitionHash?: string;
49
53
  /** Validated turn-manifest variants, keyed by hash (loops.md §4.2). */
50
54
  variants?: Record<string, AgentManifest>;
51
55
  implementationVersion: string;
@@ -139,11 +143,6 @@ export interface TenantContext {
139
143
  readonly envelope: TenantEnvelope;
140
144
  readonly store: SessionStore;
141
145
  readonly vault: VaultService;
142
- /**
143
- * A session's MCP credentials, for the in-process MCP pool: its attached vaults, then the
144
- * operator's credential resolver (`TenantConfig.resolver`, F9 C1).
145
- */
146
- readonly credentials: CredentialSources;
147
146
  /**
148
147
  * The MCP pool of the Tenant's in-process harness. Absent when harnesses run elsewhere
149
148
  * (`NYLORUN_HARNESS=remote`): each keeps its own (F6.2).
@@ -201,6 +200,10 @@ export interface TenantContext {
201
200
  readonly work: WorkState;
202
201
  /** Live delivery over Durable Streams: one `SessionStream` per observed session, and the streams wiring. */
203
202
  readonly sessionStreams: SessionStreams;
203
+ /** This process's follower of the control bus (`control.ts`), once wired. */
204
+ control?: {
205
+ close(): Promise<void>;
206
+ };
204
207
  /** The Tenant's signing keys: capability links, run and host tokens. */
205
208
  readonly signingKeys: SigningKeys;
206
209
  /** The Worker id this process writes as session `owner` (§10.6). */
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The control bus of an open Tenant (blueprint D21, D48): the signals one process sends the
3
+ * others with the Tenant open, on Postgres (`Tx.signal`, `SessionStore.followSignals`). They
4
+ * never go through S2, which only serves API listeners.
5
+ *
6
+ * - `session.cancel` is written by the cancel's own transaction (`commands.ts`): every process
7
+ * aborts the cancelled turn's advance if it runs there (`ctx.abortLocal`).
8
+ * - `sessions.reset` is written by the reset's transaction (`reset.ts`): every process moves
9
+ * its session streams to the new basin generation and ends those of deleted sessions
10
+ * (`checkSessionStreams`).
11
+ *
12
+ * A lost signal costs latency, never correctness: the advance checks the Session Store before
13
+ * every effect, and the session streams are checked periodically (`streams.ts`).
14
+ */
15
+ import type { TenantContext } from "./context.js";
16
+ export interface WireControlOptions {
17
+ /** How often the follower reads recent signals back (`FollowSignalsOptions.pollMs`). */
18
+ pollMs?: number;
19
+ }
20
+ /**
21
+ * Follows the control bus for this process and keeps the follower on `ctx.control`. Call it
22
+ * once, after `ctx` is built; signals committed from its return on reach this process.
23
+ */
24
+ export declare function wireControl(ctx: TenantContext, options?: WireControlOptions): Promise<void>;
25
+ /** Close: stops following the control bus. */
26
+ export declare function closeControl(ctx: TenantContext): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { checkSessionStreams } from "./session-streams.js";
2
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
3
+ /**
4
+ * Follows the control bus for this process and keeps the follower on `ctx.control`. Call it
5
+ * once, after `ctx` is built; signals committed from its return on reach this process.
6
+ */
7
+ export async function wireControl(ctx, options = {}) {
8
+ if (ctx.control)
9
+ throw new Error("The control bus is already wired");
10
+ let closed = false;
11
+ // Nothing is reported once closed: the Tenant (and its log directory) may be gone.
12
+ const report = (what) => (error) => {
13
+ if (!closed)
14
+ ctx.config.logger.warn(what, { message: messageOf(error) });
15
+ };
16
+ const follower = await ctx.store.followSignals((signal) => {
17
+ if (signal.type === "session.cancel")
18
+ ctx.abortLocal(signal.sessionId, signal.turnId);
19
+ else
20
+ void checkSessionStreams(ctx).catch(report("session stream check failed"));
21
+ }, {
22
+ ...(options.pollMs !== undefined ? { pollMs: options.pollMs } : {}),
23
+ onError: report("control bus unavailable; retrying"),
24
+ });
25
+ ctx.control = {
26
+ async close() {
27
+ closed = true;
28
+ await follower.close();
29
+ },
30
+ };
31
+ }
32
+ /** Close: stops following the control bus. */
33
+ export async function closeControl(ctx) {
34
+ await ctx.control?.close();
35
+ }
@@ -14,7 +14,7 @@ import type { EffectOutcome } from "@nylorun/core/contracts";
14
14
  import type { EffectResolution, HostEffect } from "@nylorun/harness/run";
15
15
  import type { Tx } from "../store/types.js";
16
16
  import type { AuthorizeResult } from "../vault/service.js";
17
- import type { McpCredentialRequest } from "../vault/sources.js";
17
+ import { type McpCredentialRequest } from "../vault/sources.js";
18
18
  import { type Lease, type Session, type TenantContext } from "./context.js";
19
19
  /**
20
20
  * The outcome of a flow `agent` effect once the linked turn it started ended, or undefined
@@ -42,6 +42,6 @@ export declare function recoversModelCalls(ctx: TenantContext): boolean;
42
42
  export declare function resolveNewFlowEffect(ctx: TenantContext, request: HostEffect, signal: AbortSignal, lease: Lease): Promise<EffectResolution>;
43
43
  /**
44
44
  * The credential of an MCP server or HTTP tool request made on behalf of a session: from its
45
- * attached vaults, else the operator's credential resolver (`vault/sources.ts`).
45
+ * attached vaults (`vault/sources.ts`).
46
46
  */
47
47
  export declare function authorize(ctx: TenantContext, sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;