@nylorun/runtime 0.22.0-beta → 0.24.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 (273) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +95 -30
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +78 -84
  5. package/dist/adapters/replication/pgoutput.d.ts +2 -0
  6. package/dist/adapters/replication/pgoutput.js +6 -3
  7. package/dist/adapters/sandbox/local.d.ts +9 -1
  8. package/dist/adapters/sandbox/local.js +16 -4
  9. package/dist/adapters/sandbox/virtual.js +4 -1
  10. package/dist/adapters/streams/s2.js +1 -13
  11. package/dist/api/a2a/endpoint.js +5 -1
  12. package/dist/api/a2a/routes.d.ts +2 -0
  13. package/dist/api/a2a/routes.js +2 -2
  14. package/dist/api/a2a/tasks.js +2 -1
  15. package/dist/api/ag-ui/endpoint.js +4 -2
  16. package/dist/api/ag-ui/history.js +4 -1
  17. package/dist/api/ag-ui/routes.d.ts +4 -1
  18. package/dist/api/ag-ui/routes.js +11 -10
  19. package/dist/api/ag-ui/translate.js +3 -2
  20. package/dist/api/components.d.ts +4953 -1204
  21. package/dist/api/components.js +25 -16
  22. package/dist/api/gate/keys.js +7 -1
  23. package/dist/api/gate/routes.d.ts +6 -0
  24. package/dist/api/gate/routes.js +39 -3
  25. package/dist/api/http/app.js +4 -4
  26. package/dist/api/http/define.d.ts +6 -0
  27. package/dist/api/http/define.js +13 -1
  28. package/dist/api/http/respond.js +1 -1
  29. package/dist/api/http/routes/artifacts.js +35 -8
  30. package/dist/api/http/routes/coverage.d.ts +9 -0
  31. package/dist/api/http/routes/coverage.js +34 -0
  32. package/dist/api/http/routes/environments.d.ts +11 -0
  33. package/dist/api/http/routes/environments.js +283 -0
  34. package/dist/api/http/routes/mcp.d.ts +1 -1
  35. package/dist/api/http/routes/mcp.js +2 -2
  36. package/dist/api/http/routes/me.js +1 -1
  37. package/dist/api/http/routes/reads.d.ts +45 -9
  38. package/dist/api/http/routes/reads.js +54 -5
  39. package/dist/api/http/routes/sessions.js +11 -6
  40. package/dist/api/http/routes/tenant.js +32 -10
  41. package/dist/api/openapi.js +31 -26
  42. package/dist/artifacts/disk.d.ts +59 -0
  43. package/dist/artifacts/disk.js +563 -0
  44. package/dist/artifacts/export.d.ts +1 -1
  45. package/dist/artifacts/export.js +2 -3
  46. package/dist/artifacts/folders.d.ts +6 -3
  47. package/dist/artifacts/folders.js +17 -12
  48. package/dist/artifacts/service.d.ts +2 -1
  49. package/dist/artifacts/service.js +6 -1
  50. package/dist/artifacts/workspace.d.ts +1 -1
  51. package/dist/artifacts/workspace.js +1 -1
  52. package/dist/core/flow-host.d.ts +3 -22
  53. package/dist/core/flow-host.js +11 -37
  54. package/dist/core/provider.d.ts +3 -2
  55. package/dist/core/provider.js +5 -37
  56. package/dist/core/sandbox-routes.d.ts +2 -21
  57. package/dist/core/sandbox-routes.js +10 -43
  58. package/dist/execution/types.d.ts +11 -9
  59. package/dist/execution/types.js +5 -7
  60. package/dist/gates/contract.d.ts +3 -24
  61. package/dist/gates/contract.js +1 -36
  62. package/dist/gates/egress-credentials.d.ts +9 -0
  63. package/dist/gates/egress-credentials.js +11 -4
  64. package/dist/gates/egress.d.ts +28 -1
  65. package/dist/gates/egress.js +115 -10
  66. package/dist/gates/fixture-model.d.ts +28 -0
  67. package/dist/gates/fixture-model.js +92 -0
  68. package/dist/gates/handler.js +4 -4
  69. package/dist/gates/http-tool.d.ts +6 -2
  70. package/dist/gates/http-tool.js +3 -5
  71. package/dist/gates/in-process.d.ts +5 -0
  72. package/dist/gates/in-process.js +12 -1
  73. package/dist/gates/journal.d.ts +30 -0
  74. package/dist/gates/journal.js +52 -0
  75. package/dist/gates/mcp-handler.d.ts +4 -0
  76. package/dist/gates/mcp-handler.js +5 -1
  77. package/dist/gates/meter.d.ts +10 -2
  78. package/dist/gates/meter.js +20 -5
  79. package/dist/gates/policy.d.ts +49 -0
  80. package/dist/gates/policy.js +134 -0
  81. package/dist/gates/tenant-vaults.d.ts +7 -7
  82. package/dist/gates/tenant-vaults.js +5 -5
  83. package/dist/gates/tool-calls.d.ts +10 -5
  84. package/dist/gates/tool-calls.js +28 -10
  85. package/dist/gates/tool-client.d.ts +8 -0
  86. package/dist/gates/tool-client.js +27 -11
  87. package/dist/gates/tool-contract.d.ts +10 -68
  88. package/dist/gates/tool-contract.js +1 -85
  89. package/dist/gates/tool-gate.d.ts +1 -1
  90. package/dist/harness/calls.d.ts +12 -5
  91. package/dist/harness/calls.js +21 -12
  92. package/dist/harness/executors.js +12 -9
  93. package/dist/harness/main.js +18 -5
  94. package/dist/harness/pod.d.ts +34 -0
  95. package/dist/harness/pod.js +41 -0
  96. package/dist/harness/service.d.ts +0 -1
  97. package/dist/harness/service.js +13 -5
  98. package/dist/harness/setup.d.ts +27 -0
  99. package/dist/harness/setup.js +150 -0
  100. package/dist/harness/ws-client.js +3 -2
  101. package/dist/harness/ws-port.d.ts +1 -5
  102. package/dist/harness/ws-port.js +1 -5
  103. package/dist/harness-api/in-process.js +3 -3
  104. package/dist/harness-api/record.d.ts +15 -1
  105. package/dist/harness-api/record.js +174 -44
  106. package/dist/harness-api/redact.d.ts +3 -0
  107. package/dist/harness-api/redact.js +45 -0
  108. package/dist/harness-api/server.d.ts +8 -0
  109. package/dist/harness-api/server.js +93 -13
  110. package/dist/harness-api/start.d.ts +10 -3
  111. package/dist/harness-api/start.js +40 -10
  112. package/dist/harness-api/tool-journal.d.ts +96 -0
  113. package/dist/harness-api/tool-journal.js +102 -0
  114. package/dist/harness-api/tool-status.d.ts +13 -0
  115. package/dist/harness-api/tool-status.js +26 -0
  116. package/dist/harness-api/workspace.d.ts +6 -3
  117. package/dist/harness-api/workspace.js +27 -11
  118. package/dist/harness-api/ws-server.d.ts +11 -3
  119. package/dist/harness-api/ws-server.js +94 -15
  120. package/dist/host/execution.d.ts +8 -6
  121. package/dist/host/gates.d.ts +2 -1
  122. package/dist/host/gates.js +10 -2
  123. package/dist/host/main.js +64 -21
  124. package/dist/host/operate.js +67 -5
  125. package/dist/host/stack-config.d.ts +70 -0
  126. package/dist/host/stack-config.js +67 -1
  127. package/dist/infra/execution.d.ts +18 -1
  128. package/dist/infra/execution.js +28 -4
  129. package/dist/infra/index.d.ts +1 -1
  130. package/dist/keys/client.js +4 -1
  131. package/dist/keys/keys.d.ts +5 -5
  132. package/dist/keys/keys.js +5 -7
  133. package/dist/management-openapi.json +999 -2211
  134. package/dist/mcp/connect.d.ts +25 -20
  135. package/dist/mcp/connect.js +29 -30
  136. package/dist/mcp/pool.d.ts +2 -2
  137. package/dist/mcp/pool.js +1 -1
  138. package/dist/mcp/preview.d.ts +3 -3
  139. package/dist/mcp/preview.js +10 -20
  140. package/dist/mcp/snapshot.d.ts +5 -11
  141. package/dist/mcp/snapshot.js +6 -7
  142. package/dist/model/pi-model.js +8 -0
  143. package/dist/openapi.json +6955 -2526
  144. package/dist/reads/types.d.ts +16 -3
  145. package/dist/record/append.d.ts +3 -1
  146. package/dist/record/append.js +1 -0
  147. package/dist/record/envelope.d.ts +5 -1
  148. package/dist/record/envelope.js +7 -3
  149. package/dist/record/environment.d.ts +25 -0
  150. package/dist/record/environment.js +44 -0
  151. package/dist/record/index.d.ts +1 -1
  152. package/dist/record/index.js +1 -1
  153. package/dist/retry.d.ts +21 -0
  154. package/dist/retry.js +34 -0
  155. package/dist/sandbox/disk-fs.d.ts +95 -0
  156. package/dist/sandbox/disk-fs.js +206 -0
  157. package/dist/sandbox/join.d.ts +17 -18
  158. package/dist/sandbox/join.js +46 -20
  159. package/dist/sandbox/manager.d.ts +16 -8
  160. package/dist/sandbox/manager.js +33 -17
  161. package/dist/sandbox/placement.d.ts +13 -12
  162. package/dist/sandbox/placement.js +29 -12
  163. package/dist/sandbox/pods/client.d.ts +6 -1
  164. package/dist/sandbox/pods/lifecycle.d.ts +28 -27
  165. package/dist/sandbox/pods/lifecycle.js +14 -26
  166. package/dist/sandbox/pods/reconcile.js +21 -15
  167. package/dist/sandbox/pods/spec.d.ts +15 -13
  168. package/dist/sandbox/pods/spec.js +16 -20
  169. package/dist/sandbox/records.d.ts +2 -2
  170. package/dist/sandbox/records.js +3 -3
  171. package/dist/sandbox/resolve.d.ts +35 -27
  172. package/dist/sandbox/resolve.js +66 -41
  173. package/dist/sandbox/session-sandbox.js +1 -1
  174. package/dist/sandbox/setup.d.ts +61 -0
  175. package/dist/sandbox/setup.js +123 -0
  176. package/dist/sandbox/share.d.ts +11 -21
  177. package/dist/sandbox/share.js +12 -41
  178. package/dist/sandbox/tenant-config.d.ts +12 -14
  179. package/dist/sandbox/tenant-config.js +12 -17
  180. package/dist/sandbox/types.d.ts +6 -0
  181. package/dist/store/postgres/control.js +8 -1
  182. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  183. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  184. package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
  185. package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
  186. package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
  187. package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
  188. package/dist/store/postgres/drizzle/meta/_journal.json +42 -0
  189. package/dist/store/postgres/reads.js +117 -17
  190. package/dist/store/postgres/record-writer.d.ts +4 -4
  191. package/dist/store/postgres/record-writer.js +9 -9
  192. package/dist/store/postgres/record.d.ts +7 -0
  193. package/dist/store/postgres/record.js +23 -2
  194. package/dist/store/postgres/schema.d.ts +1213 -779
  195. package/dist/store/postgres/schema.js +136 -76
  196. package/dist/store/postgres/store.js +283 -117
  197. package/dist/store/types.d.ts +206 -88
  198. package/dist/streams/relay/core.js +1 -13
  199. package/dist/tenant/advance.d.ts +7 -6
  200. package/dist/tenant/advance.js +121 -49
  201. package/dist/tenant/artifact-tool.js +3 -4
  202. package/dist/tenant/commands.d.ts +15 -2
  203. package/dist/tenant/commands.js +94 -14
  204. package/dist/tenant/context.d.ts +67 -33
  205. package/dist/tenant/context.js +4 -15
  206. package/dist/tenant/control.d.ts +8 -1
  207. package/dist/tenant/control.js +2 -0
  208. package/dist/tenant/effects.d.ts +1 -1
  209. package/dist/tenant/effects.js +37 -20
  210. package/dist/tenant/environments.d.ts +167 -0
  211. package/dist/tenant/environments.js +823 -0
  212. package/dist/tenant/harness-tokens.d.ts +21 -0
  213. package/dist/tenant/harness-tokens.js +61 -0
  214. package/dist/tenant/host-token.d.ts +1 -1
  215. package/dist/tenant/host-token.js +2 -4
  216. package/dist/tenant/identity-file.d.ts +4 -4
  217. package/dist/tenant/identity-file.js +17 -8
  218. package/dist/tenant/issuers.js +3 -3
  219. package/dist/tenant/principals.js +1 -1
  220. package/dist/tenant/reset.js +3 -3
  221. package/dist/tenant/runtime.d.ts +6 -1
  222. package/dist/tenant/runtime.js +30 -4
  223. package/dist/tenant/scheduler.d.ts +7 -0
  224. package/dist/tenant/scheduler.js +38 -7
  225. package/dist/tenant/session-streams.d.ts +0 -1
  226. package/dist/tenant/session-streams.js +1 -13
  227. package/dist/tenant/session.d.ts +3 -3
  228. package/dist/tenant/session.js +0 -14
  229. package/dist/tenant/sessions.d.ts +13 -13
  230. package/dist/tenant/sessions.js +61 -138
  231. package/dist/tenant/signing-keys.d.ts +5 -1
  232. package/dist/tenant/status.js +3 -3
  233. package/dist/tenant/streams.js +14 -29
  234. package/dist/tenant/sweep.d.ts +39 -12
  235. package/dist/tenant/sweep.js +168 -63
  236. package/dist/tenant/transcript.d.ts +210 -20
  237. package/dist/tenant/transcript.js +62 -11
  238. package/dist/tenant/turn-timing.d.ts +43 -0
  239. package/dist/tenant/turn-timing.js +39 -0
  240. package/dist/tenant/types.d.ts +2 -0
  241. package/dist/traces/ids.d.ts +42 -0
  242. package/dist/traces/ids.js +85 -0
  243. package/dist/traces/key.d.ts +6 -0
  244. package/dist/traces/key.js +37 -0
  245. package/dist/traces/map.d.ts +113 -0
  246. package/dist/traces/map.js +317 -0
  247. package/dist/traces/options.d.ts +7 -0
  248. package/dist/traces/options.js +1 -0
  249. package/dist/traces/otlp.d.ts +42 -0
  250. package/dist/traces/otlp.js +84 -0
  251. package/dist/traces/projector.d.ts +58 -0
  252. package/dist/traces/projector.js +200 -0
  253. package/dist/traces/read.d.ts +23 -0
  254. package/dist/traces/read.js +16 -0
  255. package/dist/vault/coverage.d.ts +26 -0
  256. package/dist/vault/coverage.js +64 -0
  257. package/dist/vault/error.d.ts +11 -1
  258. package/dist/vault/error.js +5 -1
  259. package/dist/vault/service.d.ts +47 -48
  260. package/dist/vault/service.js +321 -382
  261. package/dist/vault/sources.d.ts +5 -16
  262. package/dist/vault/sources.js +6 -8
  263. package/dist/version.d.ts +1 -1
  264. package/dist/version.js +2 -2
  265. package/package.json +14 -13
  266. package/dist/api/http/routes/sandboxes.d.ts +0 -9
  267. package/dist/api/http/routes/sandboxes.js +0 -153
  268. package/dist/api/http/routes/vaults.d.ts +0 -10
  269. package/dist/api/http/routes/vaults.js +0 -139
  270. package/dist/record/sandbox.d.ts +0 -25
  271. package/dist/record/sandbox.js +0 -43
  272. package/dist/tenant/sandboxes.d.ts +0 -122
  273. package/dist/tenant/sandboxes.js +0 -562
@@ -11,8 +11,14 @@
11
11
  *
12
12
  * Rows store the request's hash (`requestHash`) and, for a model call, the request without its
13
13
  * prompt: drift is a hash compare. Rows written before carry their full request, hashed on read.
14
+ *
15
+ * Every row a run journals keeps when it was journaled (`startedAt`, R7.1), and a rejoin keeps
16
+ * it: an outcome's `durationMs` is the journal's window, from the intent to the outcome. A model
17
+ * call's outcome event also carries what the meter recorded for it (`modelOutcomeFacts`). A tool
18
+ * row keeps its span's context (`traceparent`, R7.4), which the `execute` answer carries to the
19
+ * harness and the Tool Gate reads with the row.
14
20
  */
15
- import { HarnessApiError, effectRequestHash, } from "@nylorun/core/harness-api";
21
+ import { HARNESS_TOOLS_CAPABILITY, HarnessApiError, effectRequestHash, isEffectIdOf, } from "@nylorun/core/harness-api";
16
22
  import { isFlowEffect, isFlowToolEffect, recordVerdict, settleAgentEffect, } from "../core/flow-host.js";
17
23
  import { sandboxCapabilityOf } from "../sandbox/capability.js";
18
24
  import { abortOn, isReadArtifactCall, isSaveArtifactCall } from "../harness/calls.js";
@@ -26,8 +32,13 @@ import { manifestFor, mcpToolOf } from "../mcp/snapshot.js";
26
32
  import { isHttpToolCall } from "../gates/http-tool.js";
27
33
  import { ownedSession } from "../tenant/context.js";
28
34
  import { isGateToolEffect, linkedOutcome, recoversModelCalls, recoversToolCalls, resolveNewFlowEffect, } from "../tenant/effects.js";
29
- import { assistantMessage, contextCompacted, modelFailed, toolCompleted, } from "../tenant/transcript.js";
35
+ import { assistantMessage, contextCompacted, modelFailed, modelOutcomeFacts, } from "../tenant/transcript.js";
30
36
  import { abortKind } from "../tenant/worker.js";
37
+ import { redactTokens } from "./redact.js";
38
+ import { toolCallDecision } from "../gates/policy.js";
39
+ import { traceKey } from "../traces/key.js";
40
+ import { effectSpanOf, traceparentOf, turnTraceIdOf } from "../traces/ids.js";
41
+ import { MAX_TOOL_ATTEMPTS, firstSend, interrupted, settleToolRow, toolKindOf, } from "./tool-journal.js";
31
42
  const TURN_CANCELLED = () => new HarnessApiError("turn_cancelled", "Turn cancelled");
32
43
  /** The hash a journal row's request was recorded with. */
33
44
  export function hashOf(row) {
@@ -35,10 +46,9 @@ export function hashOf(row) {
35
46
  }
36
47
  /** The completed outcomes of a segment, which a run replays without asking. */
37
48
  export async function bulkOutcomes(t, session, checkpoint) {
38
- const prefix = `${checkpoint.turnId}:${checkpoint.segment}:`;
39
49
  const rows = await t.effectsForTurn(session.id, checkpoint.turnId, ["completed"]);
40
50
  return rows
41
- .filter((row) => row.request.effectId.startsWith(prefix) && row.outcome)
51
+ .filter((row) => isEffectIdOf(row.request.effectId, checkpoint.turnId, checkpoint.segment) && row.outcome)
42
52
  .map((row) => ({ effectId: row.request.effectId, requestHash: hashOf(row), outcome: row.outcome }));
43
53
  }
44
54
  /**
@@ -48,10 +58,14 @@ export async function bulkOutcomes(t, session, checkpoint) {
48
58
  export async function recordIntent(scope, effect, requestHash) {
49
59
  const { ctx, lease, signal } = scope;
50
60
  const request = effect;
61
+ // Read before the transaction: the first read of the Tenant may create the key.
62
+ const key = await traceKey(ctx.store);
51
63
  const answer = await ctx.store.tx(async (t) => {
52
64
  const s = await ownedSession(t, lease, request.sessionId);
53
65
  if (s.status === "cancelled" || s.activeTurnId !== request.turnId)
54
66
  throw TURN_CANCELLED();
67
+ // A tool call's span context (R7.4), kept on its row.
68
+ const traced = () => ({ traceparent: toolTraceparent(key, ctx.store.tenantId, s, request) });
55
69
  // An aborted advance starts no effect; the advance decides what the abort means.
56
70
  signal.throwIfAborted();
57
71
  const existing = await t.get("effects", request.effectId);
@@ -61,12 +75,12 @@ export async function recordIntent(scope, effect, requestHash) {
61
75
  if (existing.status === "completed")
62
76
  return { status: "completed", outcome: existing.outcome };
63
77
  // A call its previous owner left running at the gate: re-send it, same key and request,
64
- // to join it or collect its outcome (P1.2 for model calls, F4.1 for remote MCP and HTTP
65
- // tool calls).
78
+ // to join it or collect its outcome (P1.2).
66
79
  if (existing.status === "invoking" && request.kind === "model" && recoversModelCalls(ctx))
67
80
  return { status: "execute", rejoin: true };
68
- if (existing.status === "invoking" && recoversToolCalls(ctx) && isGateToolEffect(s, existing.request))
69
- return { status: "execute", rejoin: true };
81
+ // A tool call a crash, a shutdown or a lost gate hop interrupted (R5.3, R5.4).
82
+ if (existing.status === "invoking" && existing.request.kind === "tool")
83
+ return resend(t, s, existing, recoversToolCalls(ctx));
70
84
  if (request.kind === "agent" && existing.status === "pending" && existing.agentSessionId) {
71
85
  const agent = await t.get("sessions", existing.agentSessionId);
72
86
  const outcome = await linkedOutcome(t, existing, agent);
@@ -86,26 +100,38 @@ export async function recordIntent(scope, effect, requestHash) {
86
100
  if (request.kind !== "model" && effectRequestHash(request) !== requestHash)
87
101
  throw new HarnessApiError("invalid", "The request hash does not match the request");
88
102
  if (flowHttp) {
89
- await t.put("effects", request.effectId, { request, requestHash, status: "invoking" });
103
+ const row = { request, requestHash, status: "invoking", ...firstSend({ toolKind: "http" }), ...traced() };
90
104
  await t.event(s.id, s.activeTurnId, "node.started", {
91
105
  path: request.path,
92
106
  kind: "http",
93
107
  key: request.key,
94
108
  ...(request.iterations !== undefined ? { iterations: request.iterations } : {}),
95
- });
96
- return { status: "execute" };
109
+ }, scope.writer);
110
+ const decided = await bypolicy(t, s, row, "http");
111
+ if (decided)
112
+ return decided;
113
+ await t.put("effects", request.effectId, row);
114
+ return { status: "execute", traceparent: row.traceparent };
97
115
  }
98
116
  if (request.kind === "delegation") {
99
117
  // Lifecycle points of an agent used as a tool: journaled once, so replays never re-emit.
100
118
  const outcome = { value: null };
101
- await t.put("effects", request.effectId, { request, requestHash, status: "completed", outcome });
119
+ const at = new Date().toISOString();
120
+ await t.put("effects", request.effectId, {
121
+ request,
122
+ requestHash,
123
+ status: "completed",
124
+ outcome,
125
+ startedAt: at,
126
+ settledAt: at,
127
+ });
102
128
  const settled = request.effectId.endsWith(":settled");
103
129
  const callId = request.context?.callId;
104
130
  await t.event(s.id, s.activeTurnId, settled ? "delegation.completed" : "delegation.started", {
105
131
  agent: request.agent,
106
132
  ...(typeof callId === "string" ? { callId } : {}),
107
133
  ...request.input,
108
- });
134
+ }, scope.writer);
109
135
  return { status: "completed", outcome };
110
136
  }
111
137
  const agentManifest = manifestFor(s.manifest, request.agent);
@@ -124,24 +150,41 @@ export async function recordIntent(scope, effect, requestHash) {
124
150
  isReadArtifactCall(agentManifest, request) ||
125
151
  isSkillToolCall(agentManifest, request) ||
126
152
  // `tool_search` (R2b C10) reads the session's pinned MCP snapshot: core serves it.
127
- isToolSearchCall(agentManifest, request)));
153
+ isToolSearchCall(agentManifest, request) ||
154
+ // A tool the run's harness declared and runs itself (R4.2, Q3).
155
+ (request.capabilityId === HARNESS_TOOLS_CAPABILITY &&
156
+ scope.nativeTools?.has(request.toolName ?? "") === true)));
128
157
  if (!executed) {
129
158
  // A tool that would run the developer's code: refused at save (a turn's manifest only
130
159
  // removes tools), so this is a backstop, as is an MCP tool the turn disabled, which the
131
160
  // model was never offered. The model sees the failure; nothing runs.
132
161
  const disabled = request.kind === "tool" && mcpToolOf(s.mcpSnapshot, request) !== undefined;
133
- const outcome = { value: disabled ? disabledTool(request) : unrunnableTool(request) };
134
- await t.put("effects", request.effectId, { request, requestHash, status: "completed", outcome });
135
- const transcript = toolCompleted(request, outcome.value);
136
- if (transcript)
137
- await t.event(s.id, request.turnId, "tool.completed", transcript);
162
+ const row = {
163
+ request,
164
+ requestHash,
165
+ status: "invoking",
166
+ ...toolKindOf(s, agentManifest, request),
167
+ attempts: 0,
168
+ };
169
+ const outcome = await settleToolRow(t, s, row, disabled ? disabledTool(request) : unrunnableTool(request), { writer: coreIn(scope) });
138
170
  return { status: "completed", outcome };
139
171
  }
140
- await t.put("effects", request.effectId, {
172
+ const row = {
173
+ // A model row keeps no prompt; a tool row its whole request.
141
174
  request: storedRequest(request),
142
175
  requestHash,
143
176
  status: "invoking",
144
- });
177
+ ...(request.kind === "tool"
178
+ ? { ...firstSend(toolKindOf(s, agentManifest, request)), ...traced() }
179
+ : { startedAt: new Date().toISOString() }),
180
+ };
181
+ // The Tenant's tool policy decides an MCP or HTTP tool call as it is journaled (R5 step 2).
182
+ if (row.toolKind === "mcp" || row.toolKind === "http") {
183
+ const decided = await bypolicy(t, s, row, row.toolKind);
184
+ if (decided)
185
+ return decided;
186
+ }
187
+ await t.put("effects", request.effectId, row);
145
188
  // `save_artifact` writes the Tenant's artifacts: core runs it, reading the file through the
146
189
  // workspace capability, wherever the sandbox is.
147
190
  if (request.kind === "tool" && isSaveArtifactCall(agentManifest, request))
@@ -154,7 +197,7 @@ export async function recordIntent(scope, effect, requestHash) {
154
197
  return { skill: agentManifest };
155
198
  if (isToolSearchCall(agentManifest, request))
156
199
  return "search";
157
- return { status: "execute" };
200
+ return { status: "execute", ...(row.traceparent ? { traceparent: row.traceparent } : {}) };
158
201
  });
159
202
  if (answer === "save")
160
203
  return runInCore(scope, request, () => callSaveArtifact(scope.ctx, request, scope.signal));
@@ -177,6 +220,9 @@ export async function recordIntent(scope, effect, requestHash) {
177
220
  * the outcome as the intent's answer.
178
221
  */
179
222
  async function runInCore(scope, request, call) {
223
+ // Core ran the call: its outcome is what core saw, not the harness's claim (R4.9).
224
+ const writer = coreIn(scope);
225
+ const core = { ctx: scope.ctx, lease: scope.lease, signal: scope.signal, ...(writer ? { writer } : {}) };
180
226
  let value;
181
227
  try {
182
228
  value = await call();
@@ -184,11 +230,70 @@ async function runInCore(scope, request, call) {
184
230
  catch (error) {
185
231
  if (isOwnershipLost(error))
186
232
  throw error;
187
- return recordOutcome(scope, request.effectId, {
233
+ return recordOutcome(core, request.effectId, {
188
234
  error: error instanceof Error ? error.message : String(error),
189
235
  });
190
236
  }
191
- return recordOutcome(scope, request.effectId, { value });
237
+ return recordOutcome(core, request.effectId, { value });
238
+ }
239
+ /** Core's own write during the scope's run (R4.9): core saw it, in that run. */
240
+ function coreIn(scope) {
241
+ const runId = scope.writer?.runId;
242
+ return runId === undefined ? undefined : { source: { kind: "core", id: "runtime" }, runId };
243
+ }
244
+ /**
245
+ * The answer to a tool row still `invoking` (R5.3, R5.4): a crash, a shutdown or a lost gate
246
+ * hop interrupted its send. An MCP or HTTP call (it crosses the Tool Gate, which hands the tool
247
+ * its key) is sent again, one more attempt, until `MAX_TOOL_ATTEMPTS`; then it fails to the
248
+ * model. Any other call (sandbox, core-served) has no key to dedupe on: it fails to the model
249
+ * now, and is not run again (Q2).
250
+ */
251
+ async function resend(t, s, row, rejoin) {
252
+ if (!isGateToolEffect(s, row.request)) {
253
+ const outcome = await settleToolRow(t, s, row, interrupted("A crash interrupted this call before its outcome was recorded. It may have run; it is not run again."));
254
+ return { status: "completed", outcome };
255
+ }
256
+ const attempts = (row.attempts ?? 1) + 1;
257
+ if (attempts > MAX_TOOL_ATTEMPTS) {
258
+ const outcome = await settleToolRow(t, s, row, interrupted(`The call was interrupted ${MAX_TOOL_ATTEMPTS} times and may have run.`));
259
+ return { status: "completed", outcome };
260
+ }
261
+ row.attempts = attempts;
262
+ await t.put("effects", row.request.effectId, row);
263
+ const traced = row.traceparent ? { traceparent: row.traceparent } : {};
264
+ return rejoin ? { status: "execute", rejoin: true, ...traced } : { status: "execute", ...traced };
265
+ }
266
+ /** The span context of tool call `request` (R7.4): its turn's trace, and its own span. */
267
+ function toolTraceparent(key, tenantId, s, request) {
268
+ const traceId = turnTraceIdOf(key, { tenantId, sessionId: s.id, turnId: request.turnId }, s.turnTrace);
269
+ return traceparentOf(traceId, effectSpanOf(key, request.effectId));
270
+ }
271
+ /**
272
+ * The Tenant's tool policy on a tool call being journaled (R5 step 2, `gates/policy.ts`), or
273
+ * undefined when it runs. `deny`: the call fails to the model as `tool.denied`, unsent. `ask`:
274
+ * the row waits as `held` and the effect is pending; the segment ends waiting, the turn pauses,
275
+ * and `approve` answers it (`tenant/commands.ts`). The Tool Gate refuses a row not `invoking`, so
276
+ * neither runs whichever harness sends it. Writes `row`.
277
+ */
278
+ async function bypolicy(t, s, row, kind) {
279
+ const decision = await toolCallDecision(t, s, row.request, kind);
280
+ if (decision.effect === "allow")
281
+ return undefined;
282
+ // Nothing was sent.
283
+ row.attempts = 0;
284
+ if (decision.effect === "deny") {
285
+ const outcome = await settleToolRow(t, s, row, {
286
+ kind: "failed",
287
+ code: "tool.denied",
288
+ message: decision.reason,
289
+ retryable: false,
290
+ });
291
+ return { status: "completed", outcome };
292
+ }
293
+ row.status = "held";
294
+ row.hold = { reason: decision.reason, at: new Date().toISOString() };
295
+ await t.put("effects", row.request.effectId, row);
296
+ return { status: "pending" };
192
297
  }
193
298
  /** The failed outcome of an MCP tool the turn's manifest disables (R2b C9). */
194
299
  function disabledTool(request) {
@@ -203,7 +308,7 @@ function unrunnableTool(request) {
203
308
  return {
204
309
  kind: "failed",
205
310
  code: "tool.unavailable",
206
- message: `Tool '${request.toolName ?? ""}' runs your code, and the Runtime runs no code of yours during a session. Make it an http() tool or serve it from a remote MCP server.`,
311
+ message: `Tool '${request.toolName ?? ""}' runs your code, but the Runtime runs agents from their manifests alone. Make it an http() tool or serve it from a remote MCP server.`,
207
312
  };
208
313
  }
209
314
  /**
@@ -211,24 +316,38 @@ function unrunnableTool(request) {
211
316
  * `error` that left it uncertain. Only a cancel discards an outcome in hand (§10.7): after any
212
317
  * other abort (shutdown, deadline) it is recorded, so the next advance replays it.
213
318
  */
214
- export async function recordOutcome(scope, effectId, result) {
319
+ export async function recordOutcome(scope, effectId, reported) {
215
320
  const { ctx, lease, signal } = scope;
321
+ const result = "error" in reported ? { error: redactTokens(reported.error) } : reported;
216
322
  if ("error" in result) {
217
- await ctx.store.tx(async (t) => {
323
+ return ctx.store.tx(async (t) => {
218
324
  const s = await t.assertEpoch(lease.sessionId, lease.epoch);
219
325
  const effect = await t.get("effects", effectId);
220
326
  if (!effect)
221
- return;
327
+ return { status: "uncertain" };
222
328
  const { request } = effect;
329
+ const active = s && s.status !== "cancelled" && s.activeTurnId === request.turnId;
330
+ // A tool call whose fate the harness could not learn fails to the model (R5.3): it may
331
+ // have run, and nothing sends it again. A cancel already settled it.
332
+ if (request.kind === "tool") {
333
+ if (effect.status === "completed")
334
+ return { status: "completed", outcome: effect.outcome };
335
+ if (effect.status !== "invoking" || !active)
336
+ return { status: "uncertain" };
337
+ effect.error = result.error;
338
+ const outcome = await settleToolRow(t, s, effect, interrupted(`${result.error} (the call may have run; it is not sent again)`), { writer: coreIn(scope) });
339
+ return { status: "completed", outcome };
340
+ }
223
341
  effect.status = "uncertain";
224
342
  effect.error = result.error;
225
343
  await t.put("effects", effectId, effect);
226
- if (s && s.status !== "cancelled" && s.activeTurnId === request.turnId)
227
- await t.event(s.id, request.turnId, "effect.uncertain", { effectId, message: result.error });
344
+ if (active)
345
+ await t.event(s.id, request.turnId, "effect.uncertain", { effectId, message: result.error }, scope.writer);
346
+ return { status: "uncertain" };
228
347
  });
229
- return { status: "uncertain" };
230
348
  }
231
- const value = await shaped(scope, effectId, result.value);
349
+ // What a harness reports never carries the Runtime's own tokens into the record (R4).
350
+ const value = await shaped(scope, effectId, redactTokens(result.value));
232
351
  return ctx.store.tx(async (t) => {
233
352
  const s = await t.assertEpoch(lease.sessionId, lease.epoch);
234
353
  const effect = await t.get("effects", effectId);
@@ -237,29 +356,40 @@ export async function recordOutcome(scope, effectId, result) {
237
356
  const { request } = effect;
238
357
  if (s.status === "cancelled" || s.activeTurnId !== request.turnId || abortKind(signal) === "cancel")
239
358
  throw TURN_CANCELLED();
359
+ if (request.kind === "tool") {
360
+ // Settled already: a re-send after a lost hop collected what the first send recorded.
361
+ if (effect.status === "completed")
362
+ return { status: "completed", outcome: effect.outcome };
363
+ // What the call returned is the harness's claim, or, for a tool core ran, core's (R4.9).
364
+ const outcome = await settleToolRow(t, s, effect, value, { writer: scope.writer });
365
+ if (request.context?.role === "verify-http")
366
+ await recordVerdict(t, request, value);
367
+ return { status: "completed", outcome };
368
+ }
240
369
  effect.status = "completed";
241
370
  effect.outcome = { value };
371
+ effect.settledAt = new Date().toISOString();
242
372
  await t.put("effects", effectId, effect);
243
373
  const model = request.kind === "model";
244
374
  const failed = model ? modelFailed(request, value) : undefined;
245
- // A summary call is never an assistant message; the last one publishes context.compacted.
375
+ // A summary call is never an assistant message; it publishes context.compacted, `partial`
376
+ // unless it is the last.
246
377
  const summarizing = model && Boolean(request.context.compaction);
247
378
  const compacted = summarizing && !failed ? contextCompacted(request, value) : undefined;
248
- const transcript = failed || summarizing
249
- ? undefined
250
- : model
251
- ? assistantMessage(request, value)
252
- : toolCompleted(request, value);
379
+ const transcript = failed || summarizing || !model ? undefined : assistantMessage(request, value);
253
380
  if (request.context?.role === "verify-http")
254
381
  await recordVerdict(t, request, value);
382
+ const writer = scope.writer;
383
+ // The call's span (R7.1): the journal's window, and the meter's row for it when a gate ran it.
384
+ const facts = failed || compacted || transcript
385
+ ? modelOutcomeFacts(effectId, effect, await t.modelUsageOf(effectId))
386
+ : undefined;
255
387
  if (failed)
256
- await t.event(s.id, request.turnId, "model.failed", failed);
388
+ await t.event(s.id, request.turnId, "model.failed", { ...failed, ...facts }, writer);
257
389
  if (compacted)
258
- await t.event(s.id, request.turnId, "context.compacted", compacted);
259
- if (transcript && model)
260
- await t.event(s.id, request.turnId, "message.assistant", transcript);
261
- else if (transcript)
262
- await t.event(s.id, request.turnId, "tool.completed", transcript);
390
+ await t.event(s.id, request.turnId, "context.compacted", { ...compacted, ...facts }, writer);
391
+ if (transcript)
392
+ await t.event(s.id, request.turnId, "message.assistant", { ...transcript, ...facts }, writer);
263
393
  return { status: "completed", outcome: effect.outcome };
264
394
  });
265
395
  }
@@ -0,0 +1,3 @@
1
+ export declare const REDACTED_TOKEN = "[nylorun token redacted]";
2
+ /** `value` with every Nylorun-signed token in its strings replaced; the same value when there is none. */
3
+ export declare function redactTokens<T>(value: T): T;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keeps the Runtime's own tokens out of the record (track R4). A harness runs
3
+ * model-written commands with the egress token in their proxy variables, and holds its run and
4
+ * host tokens; a command that prints its environment, or a model that repeats it, would put
5
+ * them into a tool result, an assistant message or a transcript edit. Core replaces every
6
+ * Nylorun-signed token (a compact JWT whose `typ` is `nylorun-…`) in what a harness reports with
7
+ * `[nylorun token redacted]` before recording it, whichever harness reported it. Other values
8
+ * pass through unchanged, and the walk keeps the value's shape.
9
+ */
10
+ import { tokenType } from "../tenant/jwt.js";
11
+ export const REDACTED_TOKEN = "[nylorun token redacted]";
12
+ /** A compact JWT: three base64url parts, the first two JSON objects. */
13
+ const COMPACT_JWT = /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}/g;
14
+ function redactString(text) {
15
+ if (!text.includes("eyJ"))
16
+ return text;
17
+ return text.replace(COMPACT_JWT, (token) => (tokenType(token)?.startsWith("nylorun-") ? REDACTED_TOKEN : token));
18
+ }
19
+ /** `value` with every Nylorun-signed token in its strings replaced; the same value when there is none. */
20
+ export function redactTokens(value) {
21
+ if (typeof value === "string")
22
+ return redactString(value);
23
+ if (Array.isArray(value)) {
24
+ let changed = false;
25
+ const next = value.map((item) => {
26
+ const redacted = redactTokens(item);
27
+ if (redacted !== item)
28
+ changed = true;
29
+ return redacted;
30
+ });
31
+ return (changed ? next : value);
32
+ }
33
+ if (value !== null && typeof value === "object") {
34
+ let changed = false;
35
+ const next = {};
36
+ for (const [key, item] of Object.entries(value)) {
37
+ const redacted = redactTokens(item);
38
+ if (redacted !== item)
39
+ changed = true;
40
+ next[key] = redacted;
41
+ }
42
+ return (changed ? next : value);
43
+ }
44
+ return value;
45
+ }
@@ -5,6 +5,12 @@ import type { RunOf } from "../tenant/run-grants.js";
5
5
  export interface HarnessPeer {
6
6
  /** For logs. */
7
7
  readonly name: string;
8
+ /**
9
+ * The harness id its credential is for (R4.1): `nylorun` for the harness token, a custom
10
+ * harness's id for its own token. Its `hello` must name it. Absent for an in-process
11
+ * harness, which may name any id; a pod's host is checked against its Environment's harness.
12
+ */
13
+ readonly harness?: string;
8
14
  /** A pod sandbox's engine (F7.2): the sandbox it hosts, at the host epoch of its token. */
9
15
  readonly host?: {
10
16
  readonly sandboxId: string;
@@ -28,6 +34,8 @@ export interface RunOffer {
28
34
  * only a harness that hosts no sandbox does.
29
35
  */
30
36
  readonly pod?: string;
37
+ /** The session's harness (R4.1): only a connection that said this id takes it. Default ours. */
38
+ readonly harness?: string;
31
39
  /** How long to wait for a harness to take it. Default the server's `offerWaitMs`. */
32
40
  readonly waitMs?: number;
33
41
  }
@@ -25,10 +25,10 @@
25
25
  * `connection.lost`.
26
26
  */
27
27
  import { randomUUID } from "node:crypto";
28
- import { HARNESS_API_VERSION, HARNESS_CLAIMS, HarnessApiError, } from "@nylorun/core/harness-api";
28
+ import { DEFAULT_HARNESS, HARNESS_API_VERSION, HARNESS_CLAIMS, MAX_HARNESS_STATE_BYTES, isHarnessId, HarnessApiError, } from "@nylorun/core/harness-api";
29
29
  import { definitionFilesOf } from "@nylorun/core/contracts";
30
30
  import { isOwnershipLost } from "../store/ownership.js";
31
- import { ownedSession } from "../tenant/context.js";
31
+ import { harnessOf, ownedSession } from "../tenant/context.js";
32
32
  import { mcpDiscovered, pinDeferral, sessionToolsOf, } from "../mcp/snapshot.js";
33
33
  import { contextWindowOf } from "../model/catalog.js";
34
34
  import { turnManifestOf } from "../tenant/session.js";
@@ -36,6 +36,7 @@ import { sandboxWorkspaceKey, workspacePrefix } from "../sandbox/records.js";
36
36
  import { abortKind } from "../tenant/worker.js";
37
37
  import { readDefinitionFile } from "../tenant/definition-files.js";
38
38
  import { recordIntent, recordOutcome } from "./record.js";
39
+ import { redactTokens } from "./redact.js";
39
40
  import { beat, renewEveryMs, tokenOf } from "./renew.js";
40
41
  /** No attached harness serves workspaces (or, with `pod`, that pod sandbox's). */
41
42
  export class NoWorkspaceHarness extends Error {
@@ -48,6 +49,8 @@ export class NoWorkspaceHarness extends Error {
48
49
  this.pod = pod;
49
50
  }
50
51
  }
52
+ /** What our harness said in `hello` before harness ids (R4.1): refused, with the new id. */
53
+ const LEGACY_NAME = "@nylorun/harness";
51
54
  const OUTPUTS = [
52
55
  "turn.completed",
53
56
  "turn.paused",
@@ -62,8 +65,11 @@ export function createHarnessApiServer(ctx, options = {}) {
62
65
  const queued = [];
63
66
  const runs = new Map();
64
67
  let closed = false;
65
- /** A harness may take a run: a pod sandbox's host its sandbox's runs, any other the rest. */
66
- const takes = (connection, run) => connection.host?.sandboxId === run.offer.pod;
68
+ /**
69
+ * A harness may take a run of its own harness id (R4.1): a pod sandbox's host its sandbox's
70
+ * runs, any other the rest.
71
+ */
72
+ const takes = (connection, run) => connection.harness === (run.offer.harness ?? DEFAULT_HARNESS) && connection.host?.sandboxId === run.offer.pod;
67
73
  /** Hands queued runs to waiting harnesses that may take them, in order. */
68
74
  const match = () => {
69
75
  for (let i = 0; i < queued.length;) {
@@ -89,16 +95,30 @@ export function createHarnessApiServer(ctx, options = {}) {
89
95
  throw new HarnessApiError("run_not_held", `Run ${runId} is not held by this harness`);
90
96
  return run;
91
97
  };
98
+ /** What a harness reports is its claim (R4.9): its harness id and, in a run, the run. */
99
+ const writerOf = (connection, runId) => ({
100
+ source: { kind: "harness", id: connection.harness ?? connection.peer.name },
101
+ evidence: "claimed",
102
+ ...(runId === undefined ? {} : { runId }),
103
+ });
92
104
  const scope = (run) => ({
93
105
  ctx,
94
106
  lease: run.offer.lease,
95
107
  signal: run.offer.controller.signal,
108
+ ...(run.connection?.tools ? { nativeTools: run.connection.tools } : {}),
109
+ ...(run.connection ? { writer: writerOf(run.connection, run.grant.runId) } : {}),
96
110
  });
97
111
  const serve = (connection) => async (method, params, signal) => {
98
112
  const p = params;
99
113
  if (method === "hello") {
100
- const { capabilities } = p;
114
+ const { api, name, capabilities } = p;
115
+ // R3's next version adds a window of supported versions; until then there is one.
116
+ if (api !== HARNESS_API_VERSION)
117
+ throw new HarnessApiError("invalid", `unsupported Harness API version ${api}: this Runtime speaks ${HARNESS_API_VERSION}`);
118
+ connection.harness = await harnessIdOf(connection, name);
101
119
  connection.workspace = capabilities.workspace !== undefined;
120
+ if (capabilities.tools?.length)
121
+ connection.tools = new Set(capabilities.tools.map((tool) => tool.name));
102
122
  return {
103
123
  api: HARNESS_API_VERSION,
104
124
  tenantId: ctx.config.tenantId,
@@ -109,6 +129,8 @@ export function createHarnessApiServer(ctx, options = {}) {
109
129
  }
110
130
  if (method === "event")
111
131
  return claim(connection, p);
132
+ if (method === "lease" && connection.harness === undefined)
133
+ throw new HarnessApiError("invalid", "Say hello first: a harness leases runs of the id it names");
112
134
  if (method === "lease")
113
135
  return new Promise((resolve, reject) => {
114
136
  const entry = { connection, resolve };
@@ -121,8 +143,12 @@ export function createHarnessApiServer(ctx, options = {}) {
121
143
  });
122
144
  match();
123
145
  });
124
- if (OUTPUTS.includes(method))
125
- return output(held(connection, String(p.runId), method), method, p);
146
+ if (OUTPUTS.includes(method)) {
147
+ const run = held(connection, String(p.runId), method);
148
+ if (connection.harness !== DEFAULT_HARNESS)
149
+ checkCustomOutput(run, method, p);
150
+ return output(run, method, p);
151
+ }
126
152
  const run = held(connection, String(p.runId));
127
153
  switch (method) {
128
154
  case "lease.renew":
@@ -150,6 +176,27 @@ export function createHarnessApiServer(ctx, options = {}) {
150
176
  throw new HarnessApiError("invalid", `Unknown request ${method}`);
151
177
  }
152
178
  };
179
+ /**
180
+ * The harness id a `hello` names (R4.1): a harness id, and the one the connection's credential
181
+ * is for. A pod's host must be its Environment's harness, so an image that is not the harness
182
+ * its recipe names never takes the Environment's runs.
183
+ */
184
+ const harnessIdOf = async (connection, name) => {
185
+ if (!isHarnessId(name))
186
+ throw new HarnessApiError("invalid", name === LEGACY_NAME
187
+ ? `A harness names its harness id in hello: ours is ${DEFAULT_HARNESS}. Update the harness image (it said ${LEGACY_NAME}).`
188
+ : `hello.name ${JSON.stringify(name)} is not a harness id: lowercase letters, digits and dashes, starting with a letter`);
189
+ const bound = connection.peer.harness;
190
+ if (bound !== undefined && bound !== name)
191
+ throw new HarnessApiError("invalid", `This credential is for the ${bound} harness, not ${name}`);
192
+ if (connection.host) {
193
+ const environment = await ctx.store.tx((t) => t.environment(connection.host.sandboxId));
194
+ const expected = environment?.applied.harness ?? DEFAULT_HARNESS;
195
+ if (expected !== name)
196
+ throw new HarnessApiError("invalid", `Environment ${connection.host.sandboxId} runs the ${expected} harness, and this pod says ${name}`);
197
+ }
198
+ return name;
199
+ };
153
200
  /**
154
201
  * A `sandbox.*` event the harness claims: for a run it holds (the run's session, or the
155
202
  * session owning its sandbox), or a session one of its workspace requests acts for. A
@@ -169,7 +216,7 @@ export function createHarnessApiServer(ctx, options = {}) {
169
216
  throw new HarnessApiError("run_not_held", `This harness may not claim events of ${event.sessionId}`);
170
217
  const record = event.record;
171
218
  if (record &&
172
- (event.type !== "sandbox.state" ||
219
+ (event.type !== "environment.state" ||
173
220
  record.sessionId !== event.sessionId ||
174
221
  !record.key.startsWith(workspacePrefix(ctx.config.tenantId)) ||
175
222
  (connection.host !== undefined &&
@@ -179,7 +226,7 @@ export function createHarnessApiServer(ctx, options = {}) {
179
226
  await ctx.store.tx(async (t) => {
180
227
  if (record)
181
228
  await t.put("sandboxes", record.key, record);
182
- await t.event(event.sessionId, event.turnId, event.type, event.payload);
229
+ await t.event(event.sessionId, event.turnId, event.type, redactTokens(event.payload), writerOf(connection, event.runId));
183
230
  });
184
231
  }
185
232
  catch (error) {
@@ -211,7 +258,7 @@ export function createHarnessApiServer(ctx, options = {}) {
211
258
  current.mcpSnapshot = pinDeferral(snapshot, current.manifest, contextWindow);
212
259
  current.mcpDiagnostics = diagnostics;
213
260
  await t.put("sessions", current.id, current);
214
- await t.event(current.id, run.grant.turnId, "mcp.discovered", mcpDiscovered(current.mcpSnapshot, diagnostics));
261
+ await t.event(current.id, run.grant.turnId, "mcp.discovered", mcpDiscovered(current.mcpSnapshot, diagnostics), run.connection ? writerOf(run.connection, run.grant.runId) : undefined);
215
262
  }
216
263
  else if (diagnostics.length > 0) {
217
264
  const prior = [...(current.mcpDiagnostics ?? [])];
@@ -241,6 +288,27 @@ export function createHarnessApiServer(ctx, options = {}) {
241
288
  throw new HarnessApiError("unavailable", `The Object store has no bytes for ${sha256}`);
242
289
  return { base64: Buffer.from(bytes).toString("base64") };
243
290
  };
291
+ /**
292
+ * A custom harness's output (R4.2): its state is its own, stored as sent and never read, so it
293
+ * must be JSON and at most `MAX_HARNESS_STATE_BYTES`; a larger one fails the turn, as an engine
294
+ * that threw would. It cannot pause for approvals in v2 (Q14): approvals read our engine's plan
295
+ * until R5 makes them core holds.
296
+ */
297
+ const checkCustomOutput = (run, method, params) => {
298
+ if (method === "turn.paused")
299
+ throw new HarnessApiError("invalid", "A custom harness cannot pause for approvals in Harness API 2");
300
+ if (params.state === undefined || run.outputs.has(method))
301
+ return;
302
+ const bytes = Buffer.byteLength(JSON.stringify(params.state) ?? "");
303
+ if (bytes <= MAX_HARNESS_STATE_BYTES)
304
+ return;
305
+ output(run, "turn.failed", {
306
+ runId: run.grant.runId,
307
+ status: "failed",
308
+ thrown: { code: "invalid", message: `The harness's state is ${bytes} bytes, over ${MAX_HARNESS_STATE_BYTES}` },
309
+ }).catch(() => undefined);
310
+ throw new HarnessApiError("invalid", `state over 256 KiB (${bytes} bytes): the turn failed`);
311
+ };
244
312
  const output = (run, method, params) => {
245
313
  const known = run.outputs.get(method);
246
314
  if (known)
@@ -262,7 +330,10 @@ export function createHarnessApiServer(ctx, options = {}) {
262
330
  status() {
263
331
  return {
264
332
  connected: connections.size,
265
- workspace: [...connections].some((connection) => connection.workspace && !connection.host && !connection.channel.closed),
333
+ workspace: [...connections].some((connection) => connection.workspace &&
334
+ !connection.host &&
335
+ connection.harness === DEFAULT_HARNESS &&
336
+ !connection.channel.closed),
266
337
  };
267
338
  },
268
339
  hosting(sandboxId) {
@@ -275,10 +346,19 @@ export function createHarnessApiServer(ctx, options = {}) {
275
346
  },
276
347
  async workspace(method, params, signal, target) {
277
348
  const pod = target?.pod;
278
- const connection = [...connections].find((item) => item.workspace && !item.channel.closed && item.host?.sandboxId === pod);
349
+ const session = params.session?.ownerId;
350
+ // A pod's workspace is its host's, whatever harness it is. Any other session's is served
351
+ // by its own harness, if that harness serves workspaces: our harness's workspaces are not
352
+ // a custom harness's files (R4.1). Sweeps and removes go to ours.
353
+ const harness = pod !== undefined || session === undefined
354
+ ? DEFAULT_HARNESS
355
+ : harnessOf((await ctx.store.tx((t) => t.get("sessions", session))) ?? {});
356
+ const connection = [...connections].find((item) => item.workspace &&
357
+ !item.channel.closed &&
358
+ item.host?.sandboxId === pod &&
359
+ (pod !== undefined || item.harness === harness));
279
360
  if (!connection)
280
361
  throw new NoWorkspaceHarness(pod);
281
- const session = params.session?.ownerId;
282
362
  if (session)
283
363
  connection.claims.set(session, (connection.claims.get(session) ?? 0) + 1);
284
364
  else