@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
@@ -1,34 +1,36 @@
1
1
  /**
2
- * The Runtime vault (docs/design/runtime/vault.md, architecture §12.9):
3
- * administer vaults and credentials, attach them to sessions, and authorize
4
- * outbound MCP calls. Storage is the async Session Store (`store/types.ts`);
5
- * this module holds no SQL.
2
+ * The Runtime vault (docs/design/runtime/vault.md, D56): one vault per Environment, created and
3
+ * deleted with it, holding the Environment's secrets; and the host vault, which holds the model
4
+ * credential. Storage is the async Session Store (`store/types.ts`); this module holds no SQL.
6
5
  *
7
6
  * ## Public surface
8
7
  *
9
8
  * ```ts
10
9
  * new VaultService({ store: SessionStore, kek: () => Buffer })
11
10
  *
12
- * // Administration: each opens its own transaction and writes its audit rows
13
- * // in that transaction. Creates and rotations replay by idempotency key.
14
- * createVault(body: CreateVaultRequest): Promise<VaultInfo>
15
- * listVaults(ownerUserId: string | undefined, options?: { installation?: boolean }): Promise<VaultInfo[]>
16
- * getVault(id: string): Promise<VaultInfo>
17
- * deleteVault(id: string): Promise<{ id: string }>
18
- * createCredential(vaultId: string, body: CreateCredentialRequest): Promise<CredentialInfo>
19
- * listCredentials(vaultId: string): Promise<CredentialInfo[]>
20
- * getCredential(vaultId: string, id: string): Promise<CredentialInfo>
21
- * rotateCredential(vaultId: string, id: string, body: RotateCredentialRequest): Promise<CredentialInfo>
22
- * deleteCredential(vaultId: string, id: string): Promise<{ id: string }>
11
+ * // An Environment's vault, in the caller's transaction (`tenant/environments.ts`).
12
+ * createEnvironmentVault(t: Tx, environmentId: string, actor: string): Promise<void>
13
+ * deleteEnvironmentVault(t: Tx, environmentId: string, actor: string): Promise<void>
23
14
  *
24
- * // Attachment: run inside the caller's session PUT transaction.
25
- * assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[], options?: { opaque?: boolean }): Promise<void>
26
- * recordAttachment(t: Tx, sessionId: string, vaultIds: readonly string[]): Promise<void>
15
+ * // Its secrets (`/v1/environments/{id}/secrets`): each opens its own transaction and writes its
16
+ * // audit rows in it. A put needs the vault key, so callers reach it through `Keys`.
17
+ * putEnvironmentSecret(environmentId, name, body: PutEnvironmentSecretRequest, actor): Promise<EnvironmentSecretView>
18
+ * listEnvironmentSecrets(environmentId): Promise<EnvironmentSecretView[]>
19
+ * getEnvironmentSecret(environmentId, name): Promise<EnvironmentSecretView>
20
+ * deleteEnvironmentSecret(environmentId, name, actor): Promise<{ id: string }>
21
+ *
22
+ * // The shell's variables of a session's sandbox, in the caller's transaction (R2c).
23
+ * environmentVariables(t: Tx, environmentId: string | undefined): Promise<Record<string, string>>
27
24
  *
28
25
  * // Use: opens its own transactions; never call it inside one. `sessionCredentials`
29
26
  * // (`vault/sources.ts`) calls it for a session's MCP servers and HTTP tools; a tool preview
30
- * // (R2b C12, `mcp/preview.ts`) for no session.
31
- * authorize(input: { sessionId?; vaultIds; credentialSelections; url; serverName? }): Promise<AuthorizeResult>
27
+ * // (R2b C12, `mcp/preview.ts`) for no session; egress-gate releases shell secrets.
28
+ * authorize(input: { sessionId?; environmentId?; url }): Promise<AuthorizeResult>
29
+ * releaseEnvironmentSecret(input: { environmentId; host }): Promise<…>
30
+ *
31
+ * // Coverage: what `authorize` would decide for each URL a manifest names, in one transaction,
32
+ * // with no secret read and no audit row.
33
+ * coverage(manifest: AgentManifest | WorkflowManifest, request: CredentialCoverageRequest): Promise<CredentialCoverage>
32
34
  *
33
35
  * // Host model credential: each opens its own transaction.
34
36
  * getHostModel(): Promise<HostModelView>
@@ -41,12 +43,12 @@
41
43
  * (`vault/host-model.ts`), which only the Model Gate uses: this service seals the host model's
42
44
  * credential but never reads it back.
43
45
  *
44
- * ## Scopes
46
+ * ## One credential per host and per URL
45
47
  *
46
- * - `user`: one person's vault (owner `ownerUserId`), attachable only to that person's sessions.
47
- * - `installation` (`INSTALLATION_OWNER`): the installation's own, created by application keys
48
- * only, attachable to any session. Subject and token callers never see one.
49
- * - `host`: the model vault (`HOST_VAULT_ID`), never listed or attached.
48
+ * A secret write is refused (`409 credential_conflict`) when another `environment_secret` of the
49
+ * vault is bound to one of its hosts, or another `bearer` or `headers` credential to its URL, so
50
+ * a release or a tool call never has two to choose from. Names are unique per vault: for the
51
+ * shell kinds the name is the variable, so no variable is set twice.
50
52
  *
51
53
  * ## Transactions and I/O
52
54
  *
@@ -54,30 +56,27 @@
54
56
  * filesystem (it creates the key file on first use), so methods that
55
57
  * encrypt or decrypt resolve the KEK before opening their transaction and
56
58
  * do only in-memory crypto inside it.
57
- * - `authorize` reads the attached vaults and the matching credential rows
58
- * (ciphertext only) in one transaction and decides there. A refusal decided
59
- * from those rows (missing vault, ambiguous, selection mismatch) writes its
60
- * audit row in that same transaction. Decryption happens after it commits,
61
- * so the plaintext never enters a transaction. The `use`/`approved` audit
62
- * row is written in a final transaction before the header is returned: if
63
- * that write fails, `authorize` rejects and no header leaves the vault, so
64
- * every approved use has an audit row. Unreadable ciphertext is audited in
65
- * its own transaction before the refusal is returned.
59
+ * - `authorize` reads the Environment's vault and the credential bound to the URL (ciphertext
60
+ * only) in one transaction. Decryption happens after it commits, so the plaintext never
61
+ * enters a transaction. The `use`/`approved` audit row is written in a final transaction
62
+ * before the header is returned: if that write fails, `authorize` rejects and no header
63
+ * leaves the vault, so every approved use has an audit row. Unreadable ciphertext is audited
64
+ * in its own transaction before the refusal is returned.
66
65
  *
67
66
  * ## Credentials (R2b C2)
68
67
  *
69
- * A credential is bound to the URL a manifest names and sends a `bearer` token or a `headers`
70
- * map, both sealed. Its `via` (where requests go, such as a gateway) and `identity` (the header
71
- * that names the session owner) are not secret: they live unsealed in the binding, beside the
72
- * URL and the header names, and a rotation keeps them unless it changes them. The seal's AAD
73
- * binds the type and URL only, and matching is by URL only, so `via` never decides which
74
- * credential is chosen.
68
+ * A tool credential is bound to the URL a manifest names and sends a `bearer` token or a
69
+ * `headers` map, both sealed. Its `via` (where requests go, such as a gateway) and `identity`
70
+ * (the header that names the session owner) are not secret: they live unsealed in the binding,
71
+ * beside the URL and the header names. The seal's AAD binds the vault, credential id, type and
72
+ * URL only, and matching is by URL only, so `via` never decides which credential is chosen.
75
73
  */
76
74
  import { createHash, randomUUID } from "node:crypto";
77
- import { ENVIRONMENT_SECRET_DEFAULT_INJECT, ENVIRONMENT_SECRET_SENTINEL, INSTALLATION_OWNER, renderInjectFormat, } from "@nylorun/core/contracts";
75
+ import { ENVIRONMENT_SECRET_DEFAULT_INJECT, ENVIRONMENT_SECRET_SENTINEL, environmentSecretNameError, renderInjectFormat, } from "@nylorun/core/contracts";
78
76
  import { canonical } from "../store/canonical.js";
79
77
  import { decryptSecret, encryptSecret, VaultCryptoError } from "./crypto.js";
80
78
  import { VaultError } from "./error.js";
79
+ import { credentialNeeds } from "./coverage.js";
81
80
  import { checkCredentialHeaders } from "./headers.js";
82
81
  import { hostModelCatalog } from "../model/catalog.js";
83
82
  import { normalizeVaultUrl } from "./url.js";
@@ -90,242 +89,164 @@ export class VaultService {
90
89
  this.store = options.store;
91
90
  this.kek = options.kek;
92
91
  }
93
- // --- administration --------------------------------------------------------
94
- async createVault(body) {
95
- return this.store.tx((t) => this.replay(t, `create-vault:${body.idempotencyKey}`, body, async () => {
96
- const installation = body.scope === "installation";
97
- const ownerUserId = installation ? INSTALLATION_OWNER : body.ownerUserId;
98
- if (!ownerUserId)
99
- throw new VaultError(400, "ownerUserId is required for a user vault");
100
- const row = {
101
- id: randomUUID(),
102
- name: body.name,
103
- ownerUserId,
104
- metadataJson: body.metadata ? JSON.stringify(body.metadata) : null,
105
- createdAt: new Date().toISOString(),
106
- scope: installation ? "installation" : "user",
107
- };
108
- await t.insertVault(row);
109
- await this.audit(t, {
110
- actor: "application",
111
- action: "create",
112
- vaultId: row.id,
113
- outcome: "created",
114
- });
115
- return vaultInfoOf(row);
116
- }));
92
+ // --- an Environment's vault (caller's transaction) ------------------------------
93
+ /** Creates Environment `environmentId`'s vault (D56), in the transaction that creates it. */
94
+ async createEnvironmentVault(t, environmentId, actor) {
95
+ const row = {
96
+ id: randomUUID(),
97
+ name: environmentId,
98
+ metadataJson: null,
99
+ createdAt: new Date().toISOString(),
100
+ environmentId,
101
+ };
102
+ await t.insertVault(row);
103
+ await this.audit(t, { actor, action: "create", vaultId: row.id, target: environmentId, outcome: "created" });
117
104
  }
118
105
  /**
119
- * One person's vaults, then (with `installation`) the installation vaults. No owner lists
120
- * the installation vaults only.
106
+ * Deletes Environment `environmentId`'s vault and its credentials, with their audit rows, in
107
+ * the transaction that deletes the Environment: no release or tool call finds them after it
108
+ * commits.
121
109
  */
122
- async listVaults(ownerUserId, options = {}) {
123
- return this.store.tx(async (t) => {
124
- const own = ownerUserId === undefined ? [] : await t.vaultsByOwner(ownerUserId);
125
- const shared = options.installation ? await t.installationVaults() : [];
126
- return [...own, ...shared].map(vaultInfoOf);
127
- });
128
- }
129
- async getVault(id) {
130
- return this.store.tx((t) => this.vaultInfo(t, id));
131
- }
132
- async deleteVault(id) {
133
- return this.store.tx(async (t) => {
134
- await this.vaultInfo(t, id);
135
- for (const credential of await t.credentialsForVault(id)) {
136
- await this.audit(t, {
137
- actor: "application",
138
- action: "delete",
139
- vaultId: id,
140
- credentialId: credential.id,
141
- outcome: "deleted",
142
- });
143
- }
144
- await t.deleteVault(id);
110
+ async deleteEnvironmentVault(t, environmentId, actor) {
111
+ const vault = await t.vaultOfEnvironment(environmentId);
112
+ if (!vault)
113
+ return;
114
+ for (const credential of await t.credentialsForVault(vault.id))
145
115
  await this.audit(t, {
146
- actor: "application",
116
+ actor,
147
117
  action: "delete",
148
- vaultId: id,
118
+ vaultId: vault.id,
119
+ credentialId: credential.id,
120
+ target: environmentId,
149
121
  outcome: "deleted",
150
122
  });
151
- return { id };
152
- });
123
+ await t.deleteVault(vault.id);
124
+ await this.audit(t, { actor, action: "delete", vaultId: vault.id, target: environmentId, outcome: "deleted" });
153
125
  }
154
- async createCredential(vaultId, body) {
126
+ // --- an Environment's secrets ----------------------------------------------------
127
+ /**
128
+ * `PUT /v1/environments/{id}/secrets/{name}`: creates or replaces one credential. The same
129
+ * name and type keeps the credential's id and re-seals its value and binding; another type
130
+ * replaces it. Refused (`409 credential_conflict`) when another credential of the vault is
131
+ * bound to one of its hosts or to its URL. Runs under the Environment row's lock, which also
132
+ * orders it with session opens and deletes, and counts as the Environment's use.
133
+ */
134
+ async putEnvironmentSecret(environmentId, name, body, actor) {
135
+ const invalid = environmentSecretNameError(body.type, name);
136
+ if (invalid)
137
+ throw new VaultError(400, invalid);
138
+ let payload;
139
+ let binding;
140
+ if (body.type === "environment_secret" || body.type === "environment_variable")
141
+ ({ payload, binding } = environmentCredential(name, body));
142
+ else {
143
+ const url = normalizeVaultUrl(body.url);
144
+ payload = payloadOf(body);
145
+ binding = bindingOf(url, payload, body);
146
+ }
155
147
  const kek = this.kek();
156
- return this.store.tx((t) => this.replay(t, `create-credential:${vaultId}:${body.idempotencyKey}`, body, async () => {
157
- await this.vaultInfo(t, vaultId);
158
- const id = randomUUID();
159
- const type = body.auth.type;
160
- let payload;
161
- let binding;
162
- if (body.auth.type === "environment_secret" || body.auth.type === "environment_variable") {
163
- ({ payload, binding } = environmentCredential(body.auth));
148
+ return this.store.tx(async (t) => {
149
+ const vault = await this.environmentVault(t, environmentId, { lock: true });
150
+ const now = new Date().toISOString();
151
+ const others = (await t.credentialsForVault(vault.id))
152
+ .filter(isUserCredential)
153
+ .filter((row) => row.name !== name);
154
+ conflictOf(body.type, binding, others);
155
+ const existing = await t.credentialByName(vault.id, name);
156
+ let row;
157
+ if (existing && isUserCredential(existing) && existing.type === body.type) {
158
+ row = await this.writePayload(t, kek, existing, payload, binding, now);
159
+ await this.audit(t, { actor, action: "rotate", vaultId: vault.id, credentialId: row.id, outcome: "rotated" });
164
160
  }
165
161
  else {
166
- const url = normalizeVaultUrl(body.auth.url);
167
- payload = payloadOf(body.auth);
168
- binding = bindingOf(url, payload, body.auth);
162
+ if (existing) {
163
+ await t.deleteCredential(vault.id, existing.id);
164
+ await this.audit(t, { actor, action: "delete", vaultId: vault.id, credentialId: existing.id, outcome: "deleted" });
165
+ }
166
+ const id = randomUUID();
167
+ const sealed = encryptSecret(kek, credentialAad(vault.id, id, body.type, binding), Buffer.from(JSON.stringify(payload), "utf8"));
168
+ row = {
169
+ id,
170
+ vaultId: vault.id,
171
+ name,
172
+ type: body.type,
173
+ bindingJson: JSON.stringify(binding),
174
+ expiresAt: null,
175
+ createdAt: now,
176
+ rotatedAt: null,
177
+ ...sealed,
178
+ };
179
+ await t.insertCredential(row);
180
+ await this.audit(t, { actor, action: "create", vaultId: vault.id, credentialId: id, outcome: "created" });
169
181
  }
170
- const sealed = encryptSecret(kek, credentialAad(vaultId, id, type, binding), Buffer.from(JSON.stringify(payload), "utf8"));
171
- const row = {
172
- id,
173
- vaultId,
174
- name: body.name,
175
- type,
176
- bindingJson: JSON.stringify(binding),
177
- expiresAt: null,
178
- createdAt: new Date().toISOString(),
179
- rotatedAt: null,
180
- ...sealed,
181
- };
182
- await t.insertCredential(row);
183
- await this.audit(t, {
184
- actor: "application",
185
- action: "create",
186
- vaultId,
187
- credentialId: id,
188
- outcome: "created",
189
- });
190
- return credentialInfoOf(row);
191
- }));
182
+ await t.updateEnvironment(environmentId, { lastUsedAt: now }, now);
183
+ return secretViewOf(row);
184
+ });
192
185
  }
193
- async listCredentials(vaultId) {
186
+ /** The Environment's secrets: names, types and bindings, never values. */
187
+ async listEnvironmentSecrets(environmentId) {
194
188
  return this.store.tx(async (t) => {
195
- await this.vaultInfo(t, vaultId);
196
- return (await t.credentialsForVault(vaultId))
189
+ const vault = await this.environmentVault(t, environmentId);
190
+ return (await t.credentialsForVault(vault.id))
197
191
  .filter(isUserCredential)
198
- .map(credentialInfoOf);
192
+ .sort((a, b) => a.name.localeCompare(b.name))
193
+ .map(secretViewOf);
199
194
  });
200
195
  }
201
- async getCredential(vaultId, id) {
202
- return this.store.tx(async (t) => credentialInfoOf(await this.credentialRow(t, vaultId, id)));
203
- }
204
- async rotateCredential(vaultId, id, body) {
205
- const kek = this.kek();
206
- return this.store.tx((t) => this.replay(t, `rotate:${vaultId}:${id}:${body.idempotencyKey}`, body, async () => {
207
- const row = await this.credentialRow(t, vaultId, id);
208
- if (row.type !== body.auth.type)
209
- throw new VaultError(409, "Credential type cannot change");
210
- const current = credentialBinding(row);
211
- let next;
212
- let binding;
213
- if (body.auth.type === "environment_secret") {
214
- next = { value: body.auth.secretValue };
215
- binding = current;
216
- }
217
- else if (body.auth.type === "environment_variable") {
218
- next = {};
219
- binding = { ...current, variableValue: body.auth.variableValue };
220
- }
221
- else {
222
- next = payloadOf(body.auth);
223
- binding = bindingOf(current.url, next, {
224
- via: body.auth.via === undefined ? current.via : body.auth.via,
225
- identity: body.auth.identity === undefined ? current.identity : body.auth.identity,
226
- });
227
- }
228
- const updated = await this.writePayload(t, kek, row, next, binding, new Date().toISOString());
229
- await this.audit(t, {
230
- actor: "application",
231
- action: "rotate",
232
- vaultId,
233
- credentialId: id,
234
- outcome: "rotated",
235
- });
236
- return credentialInfoOf(updated);
237
- }));
238
- }
239
- async deleteCredential(vaultId, id) {
196
+ async getEnvironmentSecret(environmentId, name) {
240
197
  return this.store.tx(async (t) => {
241
- await this.credentialRow(t, vaultId, id);
242
- await t.deleteCredential(vaultId, id);
243
- await this.audit(t, {
244
- actor: "application",
245
- action: "delete",
246
- vaultId,
247
- credentialId: id,
248
- outcome: "deleted",
249
- });
250
- return { id };
198
+ const vault = await this.environmentVault(t, environmentId);
199
+ const row = await t.credentialByName(vault.id, name);
200
+ if (!row || !isUserCredential(row))
201
+ throw new VaultError(404, "Secret not found");
202
+ return secretViewOf(row);
251
203
  });
252
204
  }
253
- // --- attachment (caller's transaction) --------------------------------------
254
- /**
255
- * Checks a session's vaults and selections. Run in the session PUT transaction. `opaque`
256
- * (a request acting for a subject) reports another owner's vault as the 404 of a missing one.
257
- */
258
- async assertAttachment(t, ownerUserId, vaultIds, selections, options = {}) {
259
- if (new Set(vaultIds).size !== vaultIds.length)
260
- throw new VaultError(400, "Duplicate vault id");
261
- if (new Set(selections.map((item) => item.serverName)).size !== selections.length)
262
- throw new VaultError(400, "Duplicate credential selection");
263
- for (const id of vaultIds) {
264
- const vault = await t.getVault(id);
265
- if (!vault)
266
- throw new VaultError(404, "Vault not found");
267
- if (vault.scope === "host")
268
- throw new VaultError(400, "Host vault cannot be attached to a session");
269
- // Any session may use the installation's own vaults (F9-D13).
270
- if (vault.scope === "installation")
271
- continue;
272
- if (vault.ownerUserId !== ownerUserId)
273
- throw options.opaque
274
- ? new VaultError(404, "Vault not found")
275
- : new VaultError(403, "Vault belongs to another user");
276
- }
277
- for (const selection of selections) {
278
- const row = await t.getCredential(selection.credentialId);
279
- if (!row || !vaultIds.includes(row.vaultId))
280
- throw new VaultError(400, "Credential is not in an attached vault");
281
- }
282
- }
283
- /** Writes the `attach` audit row. Run in the session PUT transaction. */
284
- async recordAttachment(t, sessionId, vaultIds) {
285
- await this.audit(t, {
286
- actor: "application",
287
- action: "attach",
288
- sessionId,
289
- target: vaultIds.join(","),
290
- outcome: "attached",
205
+ async deleteEnvironmentSecret(environmentId, name, actor) {
206
+ return this.store.tx(async (t) => {
207
+ const vault = await this.environmentVault(t, environmentId, { lock: true });
208
+ const row = await t.credentialByName(vault.id, name);
209
+ if (!row || !isUserCredential(row))
210
+ throw new VaultError(404, "Secret not found");
211
+ await t.deleteCredential(vault.id, row.id);
212
+ await this.audit(t, { actor, action: "delete", vaultId: vault.id, credentialId: row.id, outcome: "deleted" });
213
+ const now = new Date().toISOString();
214
+ await t.updateEnvironment(environmentId, { lastUsedAt: now }, now);
215
+ return { id: name };
291
216
  });
292
217
  }
293
218
  /**
294
219
  * The variables a session's sandbox commands get (R2c, D50): each `environment_secret`'s name
295
- * set to the sentinel, each `environment_variable`'s value, and each host a secret is bound to
296
- * with that secret's credential id. `conflict` says why they are ambiguous: two credentials with one variable name, or two
297
- * secrets bound to one host. Reads rows only; no plaintext. Run in the caller's transaction.
220
+ * set to the sentinel, and each `environment_variable`'s value, from the Environment's vault.
221
+ * Reads rows only; no plaintext. Run in the caller's transaction.
298
222
  */
299
- async sessionEnvironment(t, vaultIds) {
223
+ async environmentVariables(t, environmentId) {
224
+ if (environmentId === undefined)
225
+ return {};
226
+ const vault = await t.vaultOfEnvironment(environmentId);
227
+ if (!vault)
228
+ return {};
300
229
  const environment = {};
301
- const hosts = new Map();
302
- let conflict;
303
- for (const vaultId of vaultIds)
304
- for (const row of await t.credentialsForVault(vaultId)) {
305
- if (row.type !== "environment_secret" && row.type !== "environment_variable")
306
- continue;
307
- const binding = JSON.parse(row.bindingJson);
308
- const name = row.type === "environment_secret" ? binding.secretName : binding.variableName;
309
- if (name in environment)
310
- conflict ??= `Two attached credentials set the variable ${name}`;
311
- environment[name] = row.type === "environment_secret" ? ENVIRONMENT_SECRET_SENTINEL : binding.variableValue;
312
- for (const host of row.type === "environment_secret" ? binding.allowedHosts ?? [] : []) {
313
- if (hosts.has(host) && hosts.get(host) !== row.id)
314
- conflict ??= `Two attached environment_secret credentials are bound to ${host}`;
315
- hosts.set(host, row.id);
316
- }
317
- }
318
- return { environment, secretHosts: Object.fromEntries([...hosts].sort()), ...(conflict ? { conflict } : {}) };
230
+ for (const row of await t.credentialsForVault(vault.id)) {
231
+ if (row.type !== "environment_secret" && row.type !== "environment_variable")
232
+ continue;
233
+ const binding = JSON.parse(row.bindingJson);
234
+ if (row.type === "environment_secret")
235
+ environment[binding.secretName] = ENVIRONMENT_SECRET_SENTINEL;
236
+ else
237
+ environment[binding.variableName] = binding.variableValue;
238
+ }
239
+ return environment;
319
240
  }
320
241
  /**
321
- * The header egress-gate sets on a request from `sandboxId` to `host` (R2c, D50): the
322
- * `environment_secret` bound to `host` among the vaults of the sessions using the sandbox, its
323
- * value unsealed and rendered into its format. `none` when no secret is bound to the host;
324
- * `refused` when two are, or the sealed value cannot be read. One query and one unseal per
325
- * call; nothing is cached (E3). Plaintext exists only in the returned header.
242
+ * The header egress-gate sets on a request from Environment `environmentId`'s sandbox to
243
+ * `host` (R2c, D50): the `environment_secret` of its vault bound to `host`, its value unsealed
244
+ * and rendered into its format. `none` when no secret is bound to the host; `refused` when the
245
+ * sealed value cannot be read. One query and one unseal per call; nothing is cached (E3).
246
+ * Plaintext exists only in the returned header.
326
247
  */
327
248
  async releaseEnvironmentSecret(input) {
328
- const found = await this.store.tx((t) => sandboxSecretsFor(t, input.sandboxId, input.host));
249
+ const found = await this.store.tx((t) => environmentSecretsFor(t, input.environmentId, input.host));
329
250
  if (found.length === 0)
330
251
  return { status: "none" };
331
252
  if (found.length > 1)
@@ -345,21 +266,23 @@ export class VaultService {
345
266
  const inject = credentialBinding(row).inject ?? ENVIRONMENT_SECRET_DEFAULT_INJECT;
346
267
  return { status: "released", header: inject.header, value: renderInjectFormat(inject.format, payload.value), credentialId: row.id };
347
268
  }
348
- /** Whether any `environment_secret` of the sessions using `sandboxId` is bound to `host`. */
349
- async environmentSecretBound(sandboxId, host) {
350
- return (await this.store.tx((t) => sandboxSecretsFor(t, sandboxId, host))).length > 0;
269
+ /** Whether an `environment_secret` of Environment `environmentId` is bound to `host`. */
270
+ async environmentSecretBound(environmentId, host) {
271
+ return (await this.store.tx((t) => environmentSecretsFor(t, environmentId, host))).length > 0;
351
272
  }
352
273
  // --- use ---------------------------------------------------------------------
274
+ /**
275
+ * The headers a request to `url` carries: the credential of the Environment's vault bound to
276
+ * it, or none (`unauthenticated`) without an Environment or such a credential.
277
+ */
353
278
  async authorize(input) {
354
279
  const url = normalizeVaultUrl(input.url);
280
+ const environmentId = input.environmentId;
281
+ if (environmentId === undefined)
282
+ return { status: "unauthenticated", url, headers: {} };
355
283
  const decided = await this.store.tx(async (t) => {
356
- const scopes = new Map();
357
- for (const id of input.vaultIds) {
358
- const found = await t.getVault(id);
359
- if (found) {
360
- scopes.set(id, found.scope);
361
- continue;
362
- }
284
+ const vault = await t.vaultOfEnvironment(environmentId);
285
+ if (!vault) {
363
286
  await this.audit(t, {
364
287
  actor: "host",
365
288
  action: "use",
@@ -367,48 +290,29 @@ export class VaultService {
367
290
  target: url,
368
291
  outcome: "refused",
369
292
  });
370
- return {
371
- result: {
372
- status: "refused",
373
- url,
374
- credentialIds: [],
375
- reason: "vault_missing",
376
- },
377
- };
293
+ return { result: { status: "refused", url, credentialIds: [], reason: "vault_missing" } };
378
294
  }
379
- const matches = [];
380
- for (const vaultId of input.vaultIds)
381
- for (const row of await t.credentialsForVault(vaultId))
382
- if (bindingUrl(row) === url)
383
- matches.push(row);
384
- matches.sort((a, b) => a.id.localeCompare(b.id));
295
+ const matches = (await t.credentialsForVault(vault.id))
296
+ .filter(isUserCredential)
297
+ .filter((row) => bindingUrl(row) === url)
298
+ .sort((a, b) => a.id.localeCompare(b.id));
385
299
  if (matches.length === 0)
386
300
  return { result: { status: "unauthenticated", url, headers: {} } };
387
- const selection = input.serverName
388
- ? input.credentialSelections.find((item) => item.serverName === input.serverName)
389
- : undefined;
390
- const chosen = choose(matches, selection);
391
- if (chosen.kind === "refused") {
301
+ if (matches.length > 1) {
302
+ // Refused at write (D56); kept as a guard.
392
303
  await this.audit(t, {
393
304
  actor: "host",
394
305
  action: "use",
306
+ vaultId: vault.id,
395
307
  sessionId: input.sessionId,
396
308
  target: url,
397
309
  outcome: "refused",
398
310
  });
399
311
  return {
400
- result: {
401
- status: "refused",
402
- url,
403
- credentialIds: chosen.credentialIds,
404
- reason: chosen.reason,
405
- },
312
+ result: { status: "refused", url, credentialIds: matches.map((row) => row.id), reason: "ambiguous" },
406
313
  };
407
314
  }
408
- return {
409
- row: chosen.row,
410
- vault: scopes.get(chosen.row.vaultId) === "installation" ? "installation" : "user",
411
- };
315
+ return { row: matches[0] };
412
316
  });
413
317
  if ("result" in decided)
414
318
  return decided.result;
@@ -431,22 +335,11 @@ export class VaultService {
431
335
  target: url,
432
336
  outcome: "refused",
433
337
  }));
434
- return {
435
- status: "refused",
436
- url,
437
- credentialIds: [row.id],
438
- reason: "unreadable",
439
- };
338
+ return { status: "refused", url, credentialIds: [row.id], reason: "unreadable" };
440
339
  }
441
340
  const headers = sendHeaders(row, payload);
442
- if (!headers) {
443
- return {
444
- status: "refused",
445
- url,
446
- credentialIds: [row.id],
447
- reason: "unreadable",
448
- };
449
- }
341
+ if (!headers)
342
+ return { status: "refused", url, credentialIds: [row.id], reason: "unreadable" };
450
343
  await this.store.tx((t) => this.audit(t, {
451
344
  actor: "host",
452
345
  action: "use",
@@ -463,9 +356,45 @@ export class VaultService {
463
356
  headers,
464
357
  ...(binding.via === undefined ? {} : { via: binding.via }),
465
358
  ...(binding.identity === undefined ? {} : { identity: binding.identity }),
466
- vault: decided.vault,
467
359
  };
468
360
  }
361
+ // --- coverage --------------------------------------------------------------------
362
+ /**
363
+ * What a session that names `request.environmentId` would send for each URL `manifest` names
364
+ * (`credentialNeeds`), decided as `authorize` decides a call: the credential of the
365
+ * Environment's vault bound to the URL. Reads no secret, writes no audit row.
366
+ */
367
+ async coverage(manifest, request) {
368
+ const environmentId = request.environmentId;
369
+ return this.store.tx(async (t) => {
370
+ const credentials = [];
371
+ if (environmentId !== undefined) {
372
+ const vault = await this.environmentVault(t, environmentId);
373
+ for (const row of await t.credentialsForVault(vault.id))
374
+ if (isUserCredential(row))
375
+ credentials.push({ row, url: bindingUrl(row) });
376
+ }
377
+ const entries = credentialNeeds(manifest).map((need) => {
378
+ let url;
379
+ try {
380
+ url = normalizeVaultUrl(need.url);
381
+ }
382
+ catch (error) {
383
+ if (!(error instanceof VaultError))
384
+ throw error;
385
+ return coverageEntry(need, need.url, undefined, environmentId, error.message);
386
+ }
387
+ const bound = credentials.find((item) => item.url === url);
388
+ return coverageEntry(need, url, bound && coverageCredential(environmentId, bound.row), environmentId);
389
+ });
390
+ return {
391
+ agentId: request.agentId,
392
+ environmentId: environmentId ?? null,
393
+ complete: entries.every((entry) => entry.status === "covered"),
394
+ entries,
395
+ };
396
+ });
397
+ }
469
398
  // --- host model ----------------------------------------------------------------
470
399
  async getHostModel() {
471
400
  return this.store.tx((t) => this.hostModelView(t));
@@ -586,25 +515,28 @@ export class VaultService {
586
515
  throw new VaultError(404, "Credential not found");
587
516
  return { ...row, ...patch };
588
517
  }
589
- async vaultInfo(t, id) {
590
- const row = await t.getVault(id);
591
- if (!row || row.scope === "host")
592
- throw new VaultError(404, "Vault not found");
593
- return vaultInfoOf(row);
518
+ /**
519
+ * The vault of a live Environment (not being deleted), or the 404 of a missing one. With
520
+ * `lock`, the Environment's row is locked until the transaction ends.
521
+ */
522
+ async environmentVault(t, environmentId, options = {}) {
523
+ const environment = await t.environment(environmentId, options);
524
+ const vault = environment && environment.pod?.desired !== "deleted"
525
+ ? await t.vaultOfEnvironment(environmentId)
526
+ : undefined;
527
+ if (!vault)
528
+ throw new VaultError(404, "Environment not found");
529
+ return vault;
594
530
  }
595
531
  async ensureHostVault(t) {
596
- const existing = await t.getVault(HOST_VAULT_ID);
597
- if (existing?.scope === "host")
532
+ if (await t.getVault(HOST_VAULT_ID))
598
533
  return;
599
- if (existing)
600
- throw new VaultError(409, "Host vault id is already used");
601
534
  await t.insertVault({
602
535
  id: HOST_VAULT_ID,
603
536
  name: "Host",
604
- ownerUserId: "host",
605
537
  metadataJson: null,
606
538
  createdAt: new Date().toISOString(),
607
- scope: "host",
539
+ environmentId: null,
608
540
  });
609
541
  }
610
542
  validateHostModel(body) {
@@ -680,12 +612,6 @@ export class VaultService {
680
612
  for (const id of new Set(ids))
681
613
  await t.deleteCredential(HOST_VAULT_ID, id);
682
614
  }
683
- async credentialRow(t, vaultId, id) {
684
- const row = await t.getCredential(id);
685
- if (!row || row.vaultId !== vaultId || !isUserCredential(row))
686
- throw new VaultError(404, "Credential not found");
687
- return row;
688
- }
689
615
  async audit(t, entry) {
690
616
  await t.insertVaultAudit({
691
617
  id: randomUUID(),
@@ -706,35 +632,80 @@ function isUserCredential(row) {
706
632
  function bindingUrl(row) {
707
633
  return JSON.parse(row.bindingJson).url;
708
634
  }
709
- function vaultInfoOf(row) {
635
+ /** Where a credential is, for coverage: never its value. */
636
+ function coverageCredential(environmentId, row) {
637
+ const via = JSON.parse(row.bindingJson).via;
638
+ return { environmentId, name: row.name, ...(via === undefined ? {} : { via }) };
639
+ }
640
+ /** One coverage entry, with the sentence that says what the session would do. */
641
+ function coverageEntry(need, url, credential, environmentId, invalid) {
642
+ const required = need.kind === "http";
643
+ const where = environmentId === undefined
644
+ ? "Without an Environment, no credential is sent"
645
+ : `Environment ${environmentId} holds no credential for this URL`;
646
+ const message = invalid !== undefined
647
+ ? `No credential can be bound to this URL (${invalid}).`
648
+ : credential !== undefined
649
+ ? `Sends '${credential.name}' from Environment ${credential.environmentId}${credential.via ? ` through ${credential.via}` : ""}.`
650
+ : required
651
+ ? `${where}: each call fails with http.credential.`
652
+ : `${where}: the server is called without one, which only a public server accepts.`;
710
653
  return {
711
- id: row.id,
712
- name: row.name,
713
- ownerUserId: row.ownerUserId,
714
- ...(row.metadataJson
715
- ? { metadata: JSON.parse(row.metadataJson) }
716
- : {}),
717
- createdAt: row.createdAt,
654
+ kind: need.kind,
655
+ ...(need.agentId === undefined ? {} : { agentId: need.agentId }),
656
+ ...(need.stage === undefined ? {} : { stage: need.stage }),
657
+ name: need.name,
658
+ serverName: need.serverName,
659
+ url,
660
+ required,
661
+ status: credential === undefined ? "missing" : "covered",
662
+ ...(credential === undefined ? {} : { credential }),
663
+ message,
718
664
  };
719
665
  }
666
+ /**
667
+ * Refuses a write that would bind a second credential to a host or a URL (D56): `others` are
668
+ * the vault's other credentials.
669
+ */
670
+ function conflictOf(type, binding, others) {
671
+ if (type === "environment_secret") {
672
+ for (const row of others) {
673
+ if (row.type !== "environment_secret")
674
+ continue;
675
+ const hosts = credentialBinding(row).allowedHosts ?? [];
676
+ const host = (binding.allowedHosts ?? []).find((item) => hosts.includes(item));
677
+ if (host !== undefined)
678
+ throw new VaultError(409, `${row.name} is already bound to ${host}`, {
679
+ code: "credential_conflict",
680
+ details: { host, existing: row.name },
681
+ });
682
+ }
683
+ }
684
+ else if (type === "bearer" || type === "headers") {
685
+ const existing = others.find((row) => (row.type === "bearer" || row.type === "headers") && credentialBinding(row).url === binding.url);
686
+ if (existing)
687
+ throw new VaultError(409, `${existing.name} is already bound to ${binding.url}`, {
688
+ code: "credential_conflict",
689
+ details: { url: binding.url, existing: existing.name },
690
+ });
691
+ }
692
+ }
720
693
  function credentialBinding(row) {
721
694
  return JSON.parse(row.bindingJson);
722
695
  }
723
- function credentialInfoOf(row) {
696
+ /** A secret as `GET .../secrets/{name}` shows it: never its value. */
697
+ function secretViewOf(row) {
724
698
  const binding = credentialBinding(row);
725
699
  return {
726
- id: row.id,
727
- vaultId: row.vaultId,
728
700
  name: row.name,
729
701
  type: row.type,
730
702
  binding: row.type === "environment_secret"
731
703
  ? {
732
- secretName: binding.secretName,
733
704
  allowedHosts: binding.allowedHosts,
734
705
  inject: binding.inject ?? { ...ENVIRONMENT_SECRET_DEFAULT_INJECT },
735
706
  }
736
707
  : row.type === "environment_variable"
737
- ? { variableName: binding.variableName, variableValue: binding.variableValue }
708
+ ? {}
738
709
  : { url: binding.url },
739
710
  ...(binding.headerNames === undefined ? {} : { headerNames: binding.headerNames }),
740
711
  ...(binding.via === undefined ? {} : { via: binding.via }),
@@ -819,33 +790,10 @@ export function readHostPayload(kek, row, binding) {
819
790
  plaintext.fill(0);
820
791
  }
821
792
  }
822
- function choose(matches, selection) {
823
- const ids = matches.map((row) => row.id);
824
- if (matches.length === 1) {
825
- const only = matches[0];
826
- if (selection && selection.credentialId !== only.id)
827
- return {
828
- kind: "refused",
829
- credentialIds: ids,
830
- reason: "selection_mismatch",
831
- };
832
- return { kind: "use", row: only };
833
- }
834
- if (!selection)
835
- return { kind: "refused", credentialIds: ids, reason: "ambiguous" };
836
- const chosen = matches.find((row) => row.id === selection.credentialId);
837
- if (!chosen)
838
- return {
839
- kind: "refused",
840
- credentialIds: ids,
841
- reason: "selection_mismatch",
842
- };
843
- return { kind: "use", row: chosen };
844
- }
845
- /** The sealed part of a create or rotate: a token, or a header map with lower-cased names. */
793
+ /** The sealed part of a tool credential: a token, or a header map with lower-cased names. */
846
794
  function payloadOf(auth) {
847
795
  if (auth.type === "bearer")
848
- return { token: auth.token };
796
+ return { token: auth.value };
849
797
  return {
850
798
  headers: Object.fromEntries(Object.entries(auth.headers).map(([name, value]) => [name.toLowerCase(), value])),
851
799
  };
@@ -907,18 +855,18 @@ function credentialAad(vaultId, credentialId, type, binding) {
907
855
  }
908
856
  : { vaultId, credentialId, type, url: binding.url }), "utf8");
909
857
  }
910
- /** The sealed payload and unsealed binding of an environment credential (R2c). */
911
- function environmentCredential(auth) {
912
- if (auth.type === "environment_variable")
913
- return { payload: {}, binding: { variableName: auth.variableName, variableValue: auth.variableValue } };
914
- if (auth.inject)
915
- checkCredentialHeaders([auth.inject.header.toLowerCase()], undefined);
858
+ /** The sealed payload and unsealed binding of a shell credential named `name` (R2c). */
859
+ function environmentCredential(name, body) {
860
+ if (body.type === "environment_variable")
861
+ return { payload: {}, binding: { variableName: name, variableValue: body.value } };
862
+ if (body.inject)
863
+ checkCredentialHeaders([body.inject.header.toLowerCase()], undefined);
916
864
  return {
917
- payload: { value: auth.secretValue },
865
+ payload: { value: body.value },
918
866
  binding: {
919
- secretName: auth.secretName,
920
- allowedHosts: auth.allowedHosts.map((host) => host.toLowerCase()),
921
- ...(auth.inject ? { inject: { header: auth.inject.header, format: auth.inject.format } } : {}),
867
+ secretName: name,
868
+ allowedHosts: body.allowedHosts.map((host) => host.toLowerCase()),
869
+ ...(body.inject ? { inject: { header: body.inject.header, format: body.inject.format } } : {}),
922
870
  },
923
871
  };
924
872
  }
@@ -926,21 +874,12 @@ function requestHash(body) {
926
874
  const { requestId: _requestId, ...rest } = body;
927
875
  return canonical(rest);
928
876
  }
929
- /**
930
- * The `environment_secret` credentials bound to `host` in the vaults of every session on the
931
- * sandbox (R2c). A session between turns counts: it can run again, and what it started may
932
- * still run. Session open keeps them to one owner and one credential per host.
933
- */
934
- async function sandboxSecretsFor(t, sandboxId, host) {
877
+ /** The `environment_secret` credentials of Environment `environmentId` bound to `host` (R2c). */
878
+ async function environmentSecretsFor(t, environmentId, host) {
935
879
  const lower = host.toLowerCase();
936
- const vaultIds = new Set();
937
- for (const session of await t.sessionsOnSandbox(sandboxId))
938
- for (const vaultId of session.vaultIds ?? [])
939
- vaultIds.add(vaultId);
940
- const matches = [];
941
- for (const vaultId of vaultIds)
942
- for (const row of await t.credentialsForVault(vaultId))
943
- if (row.type === "environment_secret" && (credentialBinding(row).allowedHosts ?? []).includes(lower))
944
- matches.push(row);
945
- return matches;
880
+ const vault = await t.vaultOfEnvironment(environmentId);
881
+ if (!vault)
882
+ return [];
883
+ return (await t.credentialsForVault(vault.id)).filter((row) => row.type === "environment_secret" &&
884
+ (credentialBinding(row).allowedHosts ?? []).includes(lower));
946
885
  }