@nylorun/runtime 0.20.0-beta → 0.21.1-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 (109) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +19 -12
  3. package/dist/adapters/execution/restate.js +26 -1
  4. package/dist/api/components.d.ts +155 -36
  5. package/dist/api/components.js +3 -3
  6. package/dist/api/http/app.js +2 -0
  7. package/dist/api/http/routes/mcp.d.ts +9 -0
  8. package/dist/api/http/routes/mcp.js +32 -0
  9. package/dist/api/http/routes/vaults.d.ts +1 -9
  10. package/dist/api/http/routes/vaults.js +3 -103
  11. package/dist/api/openapi.js +6 -3
  12. package/dist/artifacts/media-types.d.ts +2 -0
  13. package/dist/artifacts/media-types.js +5 -0
  14. package/dist/core/flow-host.d.ts +2 -3
  15. package/dist/core/flow-host.js +10 -7
  16. package/dist/execution/types.d.ts +13 -5
  17. package/dist/execution/types.js +13 -5
  18. package/dist/gates/http-tool.d.ts +7 -0
  19. package/dist/gates/http-tool.js +34 -3
  20. package/dist/gates/mcp-handler.d.ts +4 -1
  21. package/dist/gates/mcp-handler.js +64 -8
  22. package/dist/gates/tenant-vaults.d.ts +9 -15
  23. package/dist/gates/tenant-vaults.js +9 -17
  24. package/dist/gates/tool-client.js +36 -17
  25. package/dist/gates/tool-contract.d.ts +19 -3
  26. package/dist/gates/tool-contract.js +9 -3
  27. package/dist/gates/tool-gate.d.ts +2 -2
  28. package/dist/harness/calls.d.ts +2 -0
  29. package/dist/harness/calls.js +13 -3
  30. package/dist/harness-api/record.d.ts +7 -6
  31. package/dist/harness-api/record.js +99 -17
  32. package/dist/harness-api/server.js +35 -27
  33. package/dist/harness-api/start.js +2 -1
  34. package/dist/host/config-for.d.ts +0 -2
  35. package/dist/host/config-for.js +0 -1
  36. package/dist/host/create-host.d.ts +1 -1
  37. package/dist/host/create-host.js +2 -1
  38. package/dist/host/gates.js +0 -1
  39. package/dist/host/http.d.ts +0 -1
  40. package/dist/host/http.js +0 -6
  41. package/dist/host/main.js +13 -3
  42. package/dist/host/stack-config.d.ts +7 -8
  43. package/dist/host/stack-config.js +50 -92
  44. package/dist/index.d.ts +0 -1
  45. package/dist/keys/client.js +1 -9
  46. package/dist/keys/keys.d.ts +11 -13
  47. package/dist/keys/keys.js +5 -7
  48. package/dist/management-openapi.json +315 -345
  49. package/dist/mcp/connect.d.ts +159 -13
  50. package/dist/mcp/connect.js +538 -55
  51. package/dist/mcp/pool.d.ts +11 -2
  52. package/dist/mcp/pool.js +74 -27
  53. package/dist/mcp/preview.d.ts +35 -0
  54. package/dist/mcp/preview.js +215 -0
  55. package/dist/mcp/snapshot.d.ts +79 -5
  56. package/dist/mcp/snapshot.js +145 -27
  57. package/dist/model/catalog.d.ts +14 -0
  58. package/dist/model/catalog.js +14 -1
  59. package/dist/model/pi-model.js +36 -2
  60. package/dist/openapi.json +362 -123
  61. package/dist/redact.d.ts +19 -1
  62. package/dist/redact.js +42 -13
  63. package/dist/sandbox/session-sandbox.d.ts +7 -0
  64. package/dist/sandbox/session-sandbox.js +89 -28
  65. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  66. package/dist/store/postgres/drizzle/0017_wake_outbox.sql +9 -0
  67. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  68. package/dist/store/postgres/schema.d.ts +105 -261
  69. package/dist/store/postgres/schema.js +18 -36
  70. package/dist/store/postgres/store.js +54 -21
  71. package/dist/store/postgres/tenant.d.ts +14 -0
  72. package/dist/store/postgres/tenant.js +22 -2
  73. package/dist/store/types.d.ts +48 -13
  74. package/dist/tenant/advance.js +1 -4
  75. package/dist/tenant/artifact-tool.d.ts +9 -1
  76. package/dist/tenant/artifact-tool.js +113 -9
  77. package/dist/tenant/commands.js +5 -5
  78. package/dist/tenant/context.d.ts +15 -13
  79. package/dist/tenant/effects.d.ts +2 -2
  80. package/dist/tenant/effects.js +20 -5
  81. package/dist/tenant/ephemeral.d.ts +0 -6
  82. package/dist/tenant/ephemeral.js +0 -1
  83. package/dist/tenant/outbound.d.ts +14 -5
  84. package/dist/tenant/outbound.js +21 -9
  85. package/dist/tenant/runtime.js +11 -15
  86. package/dist/tenant/sessions.js +20 -3
  87. package/dist/tenant/store-pg.js +9 -0
  88. package/dist/tenant/sweep.d.ts +14 -0
  89. package/dist/tenant/sweep.js +69 -10
  90. package/dist/tenant/tool-results.d.ts +57 -0
  91. package/dist/tenant/tool-results.js +298 -0
  92. package/dist/tenant/tool-search.d.ts +43 -0
  93. package/dist/tenant/tool-search.js +100 -0
  94. package/dist/tenant/transcript.d.ts +108 -0
  95. package/dist/tenant/transcript.js +13 -0
  96. package/dist/tenant/types.d.ts +0 -14
  97. package/dist/vault/crypto.d.ts +0 -7
  98. package/dist/vault/crypto.js +0 -16
  99. package/dist/vault/headers.d.ts +17 -0
  100. package/dist/vault/headers.js +61 -0
  101. package/dist/vault/service.d.ts +11 -68
  102. package/dist/vault/service.js +83 -438
  103. package/dist/vault/sources.d.ts +20 -48
  104. package/dist/vault/sources.js +25 -189
  105. package/dist/version.d.ts +1 -1
  106. package/dist/version.js +1 -1
  107. package/package.json +3 -3
  108. package/dist/vault/oauth.d.ts +0 -55
  109. package/dist/vault/oauth.js +0 -186
@@ -25,16 +25,18 @@
25
25
  * generation, `streams/basin.ts`), and the cursor is
26
26
  * `base64url("<sessionId>:<seq>")` (see `record/cursor.ts`).
27
27
  * 4. **No external I/O inside `fn`.** No model, tool, MCP, sandbox, Restate or
28
- * S2 call, and no `fetch`, runs inside a transaction. Wakes go
29
- * through `afterCommit`, and events are delivered to commit listeners after
30
- * commit (seam rule 1 and 2).
28
+ * S2 call, and no `fetch`, runs inside a transaction. A wake is written in the
29
+ * transaction (`Tx.wake`, the wake outbox) and delivered after commit, and events
30
+ * are delivered to commit listeners after commit (seam rule 1 and 2).
31
31
  * 5. **No nested transactions.** Calling `store.tx` from inside `fn` rejects.
32
32
  * A `Tx` must not be used after its `tx` call settles.
33
33
  * 6. **Post-commit order.** After a commit, the store first calls every commit
34
34
  * listener once with the transaction's events (in allocation order), then runs the `afterCommit` callbacks in registration
35
35
  * order and awaits them. Listener and callback failures are reported to the
36
36
  * store's error hook; they never reject `tx`, because the commit stands.
37
- * Recovery from a lost post-commit step is the Tenant sweep's job.
37
+ * Recovery from a lost post-commit step is the Tenant sweep's job: a wake's
38
+ * delivery is a callback too, and its outbox row is what the sweep delivers
39
+ * again (`Tx.wake`).
38
40
  * 7. **Documents are values.** `get` and queries return fresh copies; mutating
39
41
  * them changes nothing until `put`.
40
42
  * 8. **Ownership columns are store-managed.** `owner`, `epoch` and
@@ -56,10 +58,11 @@
56
58
  */
57
59
  import type { KeyRole } from "@nylorun/core/compatibility";
58
60
  import type { RecordReader } from "../streams/relay/types.js";
61
+ import type { Wake } from "../execution/types.js";
59
62
  import type { EventPayload, EventType, LiveEvent, SandboxEvent, SandboxEventPayload, SandboxEventType, SandboxKind, SandboxPodDesired, SandboxPodObserved, SessionEventOf } from "@nylorun/core/contracts";
60
63
  import type { SandboxManifest } from "@nylorun/core/define";
61
64
  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";
65
+ import type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, PrincipalRow, ToolCrossingRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow } from "./postgres/schema.js";
63
66
  /** Tables holding JSON documents keyed by `id`. */
64
67
  export type DocTable = "definitions" | "sessions" | "commands" | "effects" | "sandboxes" | "links";
65
68
  export declare const DOC_TABLES: readonly DocTable[];
@@ -195,7 +198,7 @@ export type CommitListener = (commit: Commit) => void;
195
198
  * `bytea` columns, never inside a JSON body), the vault's audit and idempotency records, the
196
199
  * Tenant's signing keys, a model call in the usage ledger and a model budget.
197
200
  */
198
- export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, OAuthPendingRow, };
201
+ export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, };
199
202
  /** Which rows of the usage ledger a total covers. */
200
203
  export interface ModelUsageQuery {
201
204
  scope: "tenant" | "agent" | "turn";
@@ -271,6 +274,24 @@ export interface SessionStore {
271
274
  followSignals(onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
272
275
  close(): Promise<void>;
273
276
  }
277
+ /** A wake as the outbox delivers it: its `dedupeKey` is the request's idempotency key. */
278
+ export type OutboxWake = Wake & {
279
+ dedupeKey: string;
280
+ };
281
+ /**
282
+ * Hands an outbox request to Durable Session Execution (`Tx.wake`). Resolving means the
283
+ * execution accepted it; `false` means it was not delivered and stays in the outbox, as a
284
+ * rejection does, without an error.
285
+ */
286
+ export type WakeDelivery = (sessionId: string, wake: OutboxWake) => boolean | void | Promise<boolean | void>;
287
+ /** A wake outbox request not yet delivered (`Tx.pendingWakes`). */
288
+ export interface PendingWake {
289
+ id: string;
290
+ sessionId: string;
291
+ wake: OutboxWake;
292
+ /** ISO time it was written. */
293
+ createdAt: string;
294
+ }
274
295
  /**
275
296
  * A signal on the control bus (D21, D48): one process tells the others with the Tenant open.
276
297
  * Signals are not events: they are never recorded in a session's log or streamed, and a
@@ -345,8 +366,28 @@ export interface Tx {
345
366
  * cursor is final.
346
367
  */
347
368
  event<T extends EventType>(sessionId: string, turnId: string | null, type: T, payload: EventPayload<T>): Promise<SessionEventOf<T>>;
348
- /** Runs `fn` after a successful commit (never on rollback). Used for wakes. */
369
+ /**
370
+ * Runs `fn` after a successful commit (never on rollback): a sandbox signal or a host
371
+ * revocation. Nothing writes it down, so a crash after commit loses it. A wake goes
372
+ * through `wake`, which does.
373
+ */
349
374
  afterCommit(fn: () => void | Promise<void>): void;
375
+ /**
376
+ * Asks for an advance of `sessionId` once this transaction commits. The request is a row
377
+ * of the wake outbox written in this transaction, so it commits, or rolls back, with what
378
+ * caused it: a commit never loses its wake. After commit, in `afterCommit` order, the
379
+ * store calls `deliver` (`DurableExecution.wake`, through the Tenant) and deletes the row
380
+ * once it resolves to anything but `false`. A request `deliver` declines (`false`) or
381
+ * rejects, or one a crash cut off, stays in the outbox until the Tenant sweep delivers it
382
+ * (`pendingWakes`, `deleteWakes`). `deliver` gets the request's idempotency key as
383
+ * `dedupeKey`: the wake's own, or `wake:<row id>` without one, so a request delivered
384
+ * twice still causes one advance.
385
+ */
386
+ wake(sessionId: string, wake: Wake, deliver: WakeDelivery): Promise<void>;
387
+ /** Outbox requests written before `before`, oldest first. */
388
+ pendingWakes(before: Date, limit: number): Promise<PendingWake[]>;
389
+ /** Deletes delivered outbox requests. */
390
+ deleteWakes(ids: readonly string[]): Promise<void>;
350
391
  /**
351
392
  * Locks the session and takes ownership when it has no owner or the owner's
352
393
  * lease ended at or before `now`; then increments `epoch` and sets the lease
@@ -483,12 +524,6 @@ export interface Tx {
483
524
  getVaultIdempotency(id: string): Promise<VaultIdempotencyRow | undefined>;
484
525
  /** Rejects when the id exists. */
485
526
  insertVaultIdempotency(row: VaultIdempotencyRow): Promise<void>;
486
- /** An MCP OAuth connect's start (F9 C2). Rejects on a duplicate state or an unknown vault. */
487
- insertOAuthPending(row: OAuthPendingRow): Promise<void>;
488
- /** Deletes and returns the pending connect of `stateHash`, expired or not: each is used once. */
489
- takeOAuthPending(stateHash: string): Promise<OAuthPendingRow | undefined>;
490
- /** Deletes the pending connects that expired before `now` (ISO); returns how many. */
491
- deleteExpiredOAuthPending(now: string): Promise<number>;
492
527
  /**
493
528
  * Serializes signing key changes: held until the transaction ends. Take it before reading
494
529
  * the keys a change depends on, so concurrent first uses or rotations do not collide.
@@ -473,10 +473,7 @@ async function settle(ctx, lease, s, result, updates = [], cursor) {
473
473
  await slimModelEffects(t, id, s.activeTurnId);
474
474
  await t.put("sessions", id, current);
475
475
  const segment = finished.segment + 1;
476
- t.afterCommit(() => ctx.wake(id, {
477
- reason: "rollover",
478
- dedupeKey: `rollover:${s.activeTurnId}:${segment}`,
479
- }));
476
+ await t.wake(id, { reason: "rollover", dedupeKey: `rollover:${s.activeTurnId}:${segment}` }, ctx.wake);
480
477
  return siblings;
481
478
  }
482
479
  const flow = isWorkflowManifest(current.manifest);
@@ -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,11 +1,11 @@
1
1
  /**
2
2
  * The session command service: `message`, `approve`/`respond` and `cancel`,
3
3
  * with per-session idempotency keys. A command locks its session and commits its state
4
- * change, events and wakes in one transaction (the store publishes the events and runs the
5
- * `afterCommit` wakes), then aborts a cancelled advance and cascades workflow cancels to the
6
- * linked agent sessions, one transaction each.
4
+ * change, events and wakes in one transaction (the store publishes the events and delivers the
5
+ * wakes from its outbox after commit, `Tx.wake`), then aborts a cancelled advance and cascades
6
+ * workflow cancels to the linked agent sessions, one transaction each.
7
7
  *
8
- * Wakes (`ctx.wake`, architecture §12.3) carry the command type as the reason and a dedupe
8
+ * Wakes (`t.wake`, architecture §12.3) carry the command type as the reason and a dedupe
9
9
  * key naming the cause: `<type>:<turnId>:<segment>` for `message`, `approve` and `respond`
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.
@@ -231,7 +231,7 @@ export async function command(ctx, id, input, scope) {
231
231
  reason: command.type,
232
232
  dedupeKey: `${command.type}:${s.activeTurnId}:${cause}`,
233
233
  };
234
- t.afterCommit(() => ctx.wake(id, commandWake));
234
+ await t.wake(id, commandWake, ctx.wake);
235
235
  event = await t.event(id, s.activeTurnId, `command.${command.type}`, command);
236
236
  }
237
237
  await t.put("sessions", id, s);
@@ -5,9 +5,10 @@
5
5
  *
6
6
  * Business code changes state only inside `ctx.store.tx(async (t) => …)` and follows the
7
7
  * seam rules: events through `t.event(...)` (the relay appends them to Durable Streams after
8
- * commit), and advances through `t.afterCommit(() => ctx.wake(id, { reason, dedupeKey }))`. It never publishes or notifies
9
- * itself; `runtime.ts` wires the streams with `wireStreams()`. No external I/O runs inside a
10
- * tx.
8
+ * commit), and advances through `await t.wake(id, { reason, dedupeKey }, ctx.wake)`, which
9
+ * writes the wake to the outbox in the transaction and delivers it after commit. It never
10
+ * publishes or notifies itself; `runtime.ts` wires the streams with `wireStreams()`. No
11
+ * external I/O runs inside a tx.
11
12
  *
12
13
  * `wake` goes to `DurableExecution.wake`, which calls the Tenant's `advance` (`advance.ts`)
13
14
  * under ownership (§10.6); `abortLocal` aborts an advance running on this process.
@@ -24,7 +25,6 @@ import type { ModelGate } from "../gates/model-gate.js";
24
25
  import type { ToolGate } from "../gates/tool-gate.js";
25
26
  import type { Keys } from "../keys/keys.js";
26
27
  import type { VaultService } from "../vault/service.js";
27
- import type { CredentialSources } from "../vault/sources.js";
28
28
  import type { McpPool } from "../mcp/pool.js";
29
29
  import type { McpDiagnostic, McpSnapshot } from "../mcp/snapshot.js";
30
30
  import type { HarnessApiServer } from "../harness-api/server.js";
@@ -46,6 +46,11 @@ export interface Session {
46
46
  /** Session pin — the manifest the session was created with. */
47
47
  manifest: any;
48
48
  manifestHash: string;
49
+ /**
50
+ * The hash of the definition the session was opened from; `manifestHash` differs when the
51
+ * Runtime pinned its own capabilities beside it (R2b C11). Absent on older sessions.
52
+ */
53
+ definitionHash?: string;
49
54
  /** Validated turn-manifest variants, keyed by hash (loops.md §4.2). */
50
55
  variants?: Record<string, AgentManifest>;
51
56
  implementationVersion: string;
@@ -139,11 +144,6 @@ export interface TenantContext {
139
144
  readonly envelope: TenantEnvelope;
140
145
  readonly store: SessionStore;
141
146
  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
147
  /**
148
148
  * The MCP pool of the Tenant's in-process harness. Absent when harnesses run elsewhere
149
149
  * (`NYLORUN_HARNESS=remote`): each keeps its own (F6.2).
@@ -212,11 +212,13 @@ export interface TenantContext {
212
212
  /** How long an advance's ownership lease lasts; the heartbeat renews it. */
213
213
  readonly ownerLeaseMs: number;
214
214
  /**
215
- * Seam: request an advance of a session (`DurableExecution.wake`). Call it from
216
- * `t.afterCommit`, never inside a tx. Dropped while the Tenant is closing; the sweep
217
- * re-wakes anything left runnable.
215
+ * Seam: hand a wake to `DurableExecution.wake`, outside any tx. Business code passes it to
216
+ * `t.wake` (the outbox), which calls it after commit; the sweep calls it for outbox rows
217
+ * left undelivered and for orphaned sessions. Resolves `true` once the execution accepted
218
+ * the wake, `false` while the Tenant is closing: the wake is not sent, and its outbox row
219
+ * stays for the next sweep.
218
220
  */
219
- wake(sessionId: string, wake: Wake): Promise<void>;
221
+ wake(sessionId: string, wake: Wake): Promise<boolean>;
220
222
  /**
221
223
  * Seam: abort the advance of a session running in this process, if any. With `turnId`, only
222
224
  * an advance of that turn (a cancel signal names the turn it cancelled).
@@ -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>;
@@ -1,10 +1,11 @@
1
1
  import { embeddedAgent, flowDelegateManifest, hashManifest, } from "@nylorun/core/define";
2
2
  import { countActiveFlowWork, deriveAgentEffectSessionId, isFlowToolEffect, isWorkflowManifest, linkedMessageInput, linkedMessageKey, linkedTurnEnd, settleAgentEffect, } from "../core/flow-host.js";
3
3
  import { mayDispatchMore } from "../core/limits.js";
4
+ import { sessionCredentials } from "../vault/sources.js";
4
5
  import { isRemoteMcpCall } from "../harness/calls.js";
5
6
  import { isHttpToolCall } from "../gates/http-tool.js";
6
7
  import { owningSandboxSessionId, sandboxSpecOf, sessionSandboxSpec, } from "../sandbox/share.js";
7
- import { withSandboxCapability } from "../sandbox/session-sandbox.js";
8
+ import { withPlatformTools, withSandboxCapability } from "../sandbox/session-sandbox.js";
8
9
  import { loadSession, ownedSession, sandboxLookup, sessionOf, } from "./context.js";
9
10
  import { fail } from "./http.js";
10
11
  import { turnManifestOf } from "./session.js";
@@ -150,12 +151,15 @@ export async function resolveNewFlowEffect(ctx, request, signal, lease) {
150
151
  : undefined;
151
152
  // A sandbox chosen when the tree was opened reaches every agent in it, with its tools.
152
153
  const inherited = inheritedSandbox(sandboxOwnerId === undefined ? undefined : lookup(sandboxOwnerId) ?? workflow, definition);
154
+ // Without one, an agent with MCP or HTTP tools still reads its stored results (R2b C11).
155
+ const pinned = inherited?.manifest ? inherited : platformTools(definition.manifest);
153
156
  const created = {
154
157
  id: agentSessionId,
155
158
  agentId: body.agentId,
156
159
  ownerUserId: workflow.ownerUserId,
157
- manifest: inherited?.manifest ?? definition.manifest,
158
- manifestHash: inherited?.manifestHash ?? definition.manifestHash,
160
+ manifest: pinned?.manifest ?? definition.manifest,
161
+ manifestHash: pinned?.manifestHash ?? definition.manifestHash,
162
+ definitionHash: definition.manifestHash,
159
163
  implementationVersion: definition.implementationVersion,
160
164
  status: "idle",
161
165
  activeTurnId: null,
@@ -241,13 +245,24 @@ function inheritedSandbox(owner, definition) {
241
245
  throw new Error(sandboxed.message);
242
246
  return { spec, manifest: sandboxed.manifest, manifestHash: sandboxed.manifestHash };
243
247
  }
248
+ /** A linked agent session's `read_artifact` without a sandbox (R2b C11); undefined for none. */
249
+ function platformTools(manifest) {
250
+ if (isWorkflowManifest(manifest))
251
+ return undefined;
252
+ const pinned = withPlatformTools(manifest);
253
+ if (pinned === undefined)
254
+ return undefined;
255
+ if (!pinned.ok)
256
+ throw new Error(pinned.message);
257
+ return { manifest: pinned.manifest, manifestHash: pinned.manifestHash };
258
+ }
244
259
  /**
245
260
  * The credential of an MCP server or HTTP tool request made on behalf of a session: from its
246
- * attached vaults, else the operator's credential resolver (`vault/sources.ts`).
261
+ * attached vaults (`vault/sources.ts`).
247
262
  */
248
263
  export async function authorize(ctx, sessionId, request) {
249
264
  const s = await loadSession(ctx, sessionId);
250
- return ctx.credentials.authorize({ ...s, id: sessionId }, request);
265
+ return sessionCredentials(ctx.vault, { ...s, id: sessionId }, request);
251
266
  }
252
267
  /**
253
268
  * The definition a flow leaf's session runs. A flow agent embeds its leaves, so they come
@@ -2,7 +2,6 @@ import { type PostgresClient } from "../store/postgres/connect.js";
2
2
  import type { TrustedIssuerConfig } from "./identity-file.js";
3
3
  import { type TrustedIssuers } from "./issuers.js";
4
4
  import type { Logger, TenantModelConfig } from "./types.js";
5
- import type { ResolverConfig } from "../vault/sources.js";
6
5
  export interface StartEphemeralRuntimeOptions {
7
6
  /**
8
7
  * Absolute Host root for `host.json`, the admin key and the Tenant's files (vault key,
@@ -37,11 +36,6 @@ export interface StartEphemeralRuntimeOptions {
37
36
  * `createTrustedIssuers`. Default none.
38
37
  */
39
38
  issuers?: readonly TrustedIssuerConfig[] | TrustedIssuers;
40
- /**
41
- * The operator's credential resolver (F9 C1): asked for a person's MCP credential when the
42
- * session's vaults hold none. Default: none (vaults only).
43
- */
44
- resolver?: ResolverConfig;
45
39
  logger?: Logger;
46
40
  /**
47
41
  * The Postgres database of the Tenant (one Tenant per database): a pool, which the caller
@@ -82,7 +82,6 @@ export async function startEphemeralRuntime(options) {
82
82
  sandbox: { backend: sandboxBackend },
83
83
  model,
84
84
  ...(issuers ? { issuers } : {}),
85
- ...(options.resolver ? { resolver: options.resolver } : {}),
86
85
  logger: createTenantLogger({
87
86
  tenantId,
88
87
  logPath: tenant.log,
@@ -58,9 +58,19 @@ export declare class OutboundRefused extends Error {
58
58
  /** A request `guardedFetch` sent that failed: refused connection, reset, timeout, redirect. */
59
59
  export declare class OutboundFailed extends Error {
60
60
  readonly code: string;
61
- constructor(message: string, code: string);
61
+ /**
62
+ * The request was sent in full before it failed, as `post()`'s `lost` (R2b C7): the server
63
+ * may have acted on it. False for a failure to resolve or connect.
64
+ */
65
+ readonly sent: boolean;
66
+ constructor(message: string, code: string,
67
+ /**
68
+ * The request was sent in full before it failed, as `post()`'s `lost` (R2b C7): the server
69
+ * may have acted on it. False for a failure to resolve or connect.
70
+ */
71
+ sent?: boolean);
62
72
  }
63
- /** The largest answer `guardedFetch` reads. OAuth metadata and tokens are small. */
73
+ /** The largest answer `guardedFetch` reads. */
64
74
  export declare const MAX_FETCH_RESPONSE_BYTES: number;
65
75
  /** How long a `guardedFetch` request may take without a signal of its own. */
66
76
  export declare const FETCH_TIMEOUT_MS = 30000;
@@ -68,9 +78,8 @@ export declare const FETCH_TIMEOUT_MS = 30000;
68
78
  * A `fetch` under the Host's address policy (F9 C2): the literal-IP and DNS checks of `post`
69
79
  * on the address actually connected to, `localhost` rewritten for the local stack, no
70
80
  * redirects (`redirect: "error"`; a 3xx answer rejects), a bounded answer and a 30 s timeout
71
- * unless the caller passes a signal. The gateway calls OAuth discovery, registration, the code
72
- * exchange and refresh with it. A refused URL or address rejects with `OutboundRefused`, any
73
- * other failure with `OutboundFailed`.
81
+ * unless the caller passes a signal. A refused URL or address rejects with `OutboundRefused`,
82
+ * any other failure with `OutboundFailed`.
74
83
  *
75
84
  * `stream`: the Response resolves once the headers arrive and its body streams, unbounded and
76
85
  * with no timeout but the caller's signal: remote MCP servers, whose answers and event streams
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The Runtime's requests to developer URLs: HTTP tools (`gates/http-tool.ts`) and, through
3
- * `guardedFetch`, remote MCP servers and OAuth: one request, no redirects, a bounded answer, and
3
+ * `guardedFetch`, remote MCP servers: one request, no redirects, a bounded answer, and
4
4
  * the Host's address policy checked on the address actually connected to, so a DNS answer cannot
5
5
  * steer a request somewhere the Host forbids.
6
6
  *
@@ -153,13 +153,20 @@ export class OutboundRefused extends Error {
153
153
  /** A request `guardedFetch` sent that failed: refused connection, reset, timeout, redirect. */
154
154
  export class OutboundFailed extends Error {
155
155
  code;
156
- constructor(message, code) {
156
+ sent;
157
+ constructor(message, code,
158
+ /**
159
+ * The request was sent in full before it failed, as `post()`'s `lost` (R2b C7): the server
160
+ * may have acted on it. False for a failure to resolve or connect.
161
+ */
162
+ sent = false) {
157
163
  super(message);
158
164
  this.code = code;
165
+ this.sent = sent;
159
166
  this.name = "OutboundFailed";
160
167
  }
161
168
  }
162
- /** The largest answer `guardedFetch` reads. OAuth metadata and tokens are small. */
169
+ /** The largest answer `guardedFetch` reads. */
163
170
  export const MAX_FETCH_RESPONSE_BYTES = 1024 * 1024;
164
171
  /** How long a `guardedFetch` request may take without a signal of its own. */
165
172
  export const FETCH_TIMEOUT_MS = 30_000;
@@ -167,9 +174,8 @@ export const FETCH_TIMEOUT_MS = 30_000;
167
174
  * A `fetch` under the Host's address policy (F9 C2): the literal-IP and DNS checks of `post`
168
175
  * on the address actually connected to, `localhost` rewritten for the local stack, no
169
176
  * redirects (`redirect: "error"`; a 3xx answer rejects), a bounded answer and a 30 s timeout
170
- * unless the caller passes a signal. The gateway calls OAuth discovery, registration, the code
171
- * exchange and refresh with it. A refused URL or address rejects with `OutboundRefused`, any
172
- * other failure with `OutboundFailed`.
177
+ * unless the caller passes a signal. A refused URL or address rejects with `OutboundRefused`,
178
+ * any other failure with `OutboundFailed`.
173
179
  *
174
180
  * `stream`: the Response resolves once the headers arrive and its body streams, unbounded and
175
181
  * with no timeout but the caller's signal: remote MCP servers, whose answers and event streams
@@ -217,6 +223,9 @@ export function guardedFetch(policy, options = {}) {
217
223
  outgoing["content-length"] = String(body.byteLength);
218
224
  return await new Promise((resolve, reject) => {
219
225
  let settled = false;
226
+ // Set when the request is fully sent, as `post()` does: a failure after it may follow
227
+ // the server acting on the request.
228
+ let delivered = false;
220
229
  const settle = (fn) => {
221
230
  if (settled)
222
231
  return;
@@ -225,12 +234,12 @@ export function guardedFetch(policy, options = {}) {
225
234
  };
226
235
  const failed = (error) => settle(() => reject(error instanceof RefusedAddress
227
236
  ? new OutboundRefused(error.message)
228
- : new OutboundFailed(signal?.aborted ? `The request to ${originalHost} was aborted or timed out` : error.message, signal?.aborted ? "ABORTED" : (error.code ?? error.name))));
237
+ : new OutboundFailed(signal?.aborted ? `The request to ${originalHost} was aborted or timed out` : error.message, signal?.aborted ? "ABORTED" : (error.code ?? error.name), delivered)));
229
238
  const sent = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, { method, headers: outgoing, lookup: lookup, ...(signal ? { signal } : {}) }, (response) => {
230
239
  const status = response.statusCode ?? 0;
231
240
  if (status >= 300 && status < 400 && response.headers.location !== undefined) {
232
241
  response.resume();
233
- return settle(() => reject(new OutboundFailed(`${originalHost} answered a redirect, which this Runtime does not follow`, "REDIRECT")));
242
+ return settle(() => reject(new OutboundFailed(`${originalHost} answered a redirect, which this Runtime does not follow`, "REDIRECT", true)));
234
243
  }
235
244
  const noBody = status === 204 || status === 304 || method === "HEAD";
236
245
  const answer = (body) => {
@@ -258,7 +267,7 @@ export function guardedFetch(policy, options = {}) {
258
267
  response.on("data", (chunk) => {
259
268
  size += chunk.length;
260
269
  if (size > limit) {
261
- settle(() => reject(new OutboundFailed(`The answer from ${originalHost} is larger than ${limit} bytes`, "TOO_LARGE")));
270
+ settle(() => reject(new OutboundFailed(`The answer from ${originalHost} is larger than ${limit} bytes`, "TOO_LARGE", true)));
262
271
  response.destroy();
263
272
  return;
264
273
  }
@@ -268,6 +277,9 @@ export function guardedFetch(policy, options = {}) {
268
277
  response.on("error", failed);
269
278
  response.on("aborted", () => failed(Object.assign(new Error("The answer was cut off"), { code: "ECONNRESET" })));
270
279
  });
280
+ sent.on("finish", () => {
281
+ delivered = true;
282
+ });
271
283
  sent.on("error", failed);
272
284
  sent.end(body);
273
285
  });