sealnet-mcp 0.2.4 → 0.3.0

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.
package/dist/index.d.ts CHANGED
@@ -1,108 +1,457 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { WorkloadCredentials } from '@seal/crypto/node';
4
- import { S } from './open-DUqF6AyG.js';
5
- export { B as BackendError, S as SealApi, G as SealApiOptions, a0 as SealMetadata } from './open-DUqF6AyG.js';
6
3
 
7
4
  /**
8
- * MCP server bootstrap — wires the five tools (`seal_share`,
9
- * `seal_request`, `seal_open`, `seal_list`, `seal_revoke`) to a
10
- * stdio-transport `McpServer` instance, and `seal_pro_secret_use`,
11
- * `seal_pro_secret_request` and `seal_pro_file_get` when SEAL Pro workload
12
- * credentials exist (contract `pro_tools`).
13
- *
14
- * The bootstrap is intentionally factored as a `createServer()`
15
- * factory that returns an awaitable handle, NOT a hard-coded
16
- * `main()` entry. That shape:
17
- * - lets `cli.ts` configure passphrase delivery, state path,
18
- * backend base URL, etc. before launching;
19
- * - lets test code spawn an in-process MCP without spawning a
20
- * subprocess;
21
- * - keeps the "where do creds come from?" decision out of the
22
- * pure tool implementations.
23
- *
24
- * Tool `title`, `description` and `annotations` come from
25
- * `shared/contracts/seal_mcp.v1.json` (contract first, §6.2), and so do
26
- * the descriptions of the inputs the contract describes; the zod input
27
- * schemas live here.
5
+ * The `/seal/*` HTTP surface — one implementation for every runtime.
6
+ *
7
+ * Everything the protocol does with bytes (AEON framing, streaming upload,
8
+ * download + decrypt, blobs, key store) already lives in this package. What
9
+ * did not was the thin layer around it: the routes, the wire shapes and the
10
+ * error classification of the SEAL backend's non-upload endpoints. That layer
11
+ * existed six times — the Slack bot's `seal/backend.ts`, the MCP server's
12
+ * `client.ts` (a line-for-line subset of it), the SEAL web's `seal_client.ts`
13
+ * plus a second axios stack in `lib/api.ts`, fifteen inline `fetch` calls
14
+ * across the web's features, and the SDK's `anonymous.ts`. One contract,
15
+ * six readings of it, and every fix landing in one of them.
16
+ *
17
+ * This module is that layer, once. It is deliberately NOT a runtime: it
18
+ * takes a `fetch` and a `baseUrl` and hands back parsed wire objects or a
19
+ * `Response` the caller consumes its own way. That is what lets one file
20
+ * serve a browser (BFF origin, cookie gates, service-worker download), a
21
+ * Slack bot (disk-backed streaming), an MCP server (in-memory) and an SDK
22
+ * (wildcard-CORS, no credentials) without any of them branching on who the
23
+ * others are.
24
+ *
25
+ * Two rules this file follows and the six copies did not:
26
+ *
27
+ * * Wire shapes are exported as they appear on the wire. Callers that
28
+ * want camelCase compose `toSealMetadata` / `toOwnerFiles`; callers that
29
+ * already speak snake_case (the web's views) keep doing so. One route,
30
+ * one method, one type.
31
+ * * Every failure is a `BackendError` carrying BOTH the coarse class the
32
+ * bots switch on (`not_found` / `revoked` / `rate_limited` / `auth` /
33
+ * `transient`) and the backend's own `{detail, code, retry_after}`
34
+ * envelope, which is what the web needs to tell `nda.required` from
35
+ * `otp.required` from a stale blob version.
36
+ */
37
+ /** Coarse failure class. Callers map this onto their own user-facing text. */
38
+ type BackendErrorCode = 'not_found' | 'revoked' | 'rate_limited' | 'auth' | 'transient' | 'unknown';
39
+ /**
40
+ * A non-2xx answer from the SEAL backend.
28
41
  *
29
- * Three stateful concerns the factory owns (all three skipped in
30
- * ephemeral mode, `state: null`, where nothing touches the disk):
31
- * - **Exclusive state lock (ISSUE-0623)**: `acquireStateLock` makes
32
- * this process the ONLY writer of the state dir for its lifetime;
33
- * a second `serve` on the same dir fails fast instead of silently
34
- * clobbering handles. Released in `close()` / on process exit.
35
- * - **State persistence**: each tool mutates `ctx.state` in-place
36
- * and calls `await ctx.persist()`; persist() re-encrypts the
37
- * view and writes atomically via `state.saveState`. A mutex
38
- * serialises persist() calls so concurrent tool calls cannot
39
- * race on the same on-disk envelope. Startup runs a journal
40
- * recovery pass (`recovery.ts`) before any tool registers.
41
- * - **Tool errors**: thrown `ToolError`s are converted to MCP's
42
- * `{ isError: true, content: [...] }` payload with the typed
43
- * `code` echoed back to the model. Other errors bubble as
44
- * internal_error so the model never sees raw stack traces.
42
+ * `code` is the coarse class (from the status). `appCode` is the backend's
43
+ * own machine-readable code from the `{detail, code, retry_after}` envelope
44
+ * — `nda.required`, `otp.required`, `blob.version_mismatch`, `seal.not_found`
45
+ * and friends. Callers that can act on the specific cause read `appCode`;
46
+ * callers that only need "gone vs denied vs try again" read `code`.
45
47
  */
46
-
47
- interface McpServerOptions {
48
- /** Encrypted state on disk; `null` = ephemeral: identity and handles in memory only. */
49
- readonly state: {
50
- readonly path: string;
51
- readonly passphrase: string;
52
- } | null;
53
- readonly backendBaseUrl?: string;
54
- readonly publicHost?: string;
55
- /** SEAL Pro workload; omitted = read the environment or the enrollment file, `null` = none. */
56
- readonly workload?: WorkloadCredentials | null;
48
+ declare class BackendError extends Error {
49
+ readonly code: BackendErrorCode;
50
+ readonly status: number;
51
+ /** Raw response body, when it was read. */
52
+ readonly body?: string;
53
+ /** `detail` from the canonical error envelope, when the body carried one. */
54
+ readonly detail?: string;
55
+ /** `code` from the canonical error envelope (e.g. `nda.required`). */
56
+ readonly appCode?: string;
57
+ /** `retry_after` seconds from the envelope, when present. */
58
+ readonly retryAfter?: number;
59
+ constructor(message: string, code: BackendErrorCode, status: number, extra?: {
60
+ body?: string;
61
+ detail?: string;
62
+ appCode?: string;
63
+ retryAfter?: number;
64
+ });
57
65
  }
58
- interface McpServerHandle {
59
- readonly mcp: McpServer;
60
- readonly transport: StdioServerTransport;
61
- readonly close: () => Promise<void>;
66
+ /** One file row of `GET /seal/{id}` (the public, recipient-facing view). */
67
+ interface SealFileWire {
68
+ id: string;
69
+ size: number;
70
+ sha256_hex: string | null;
71
+ name?: string;
72
+ mime?: string | null;
73
+ name_encrypted?: string;
74
+ name_nonce?: string;
75
+ mime_encrypted?: string;
76
+ mime_nonce?: string;
77
+ is_folder?: boolean;
78
+ parent_folder_id?: string | null;
79
+ revoked_at?: string | null;
62
80
  }
63
81
  /**
64
- * Bootstrap an MCP server. Loads + decrypts the state, instantiates
65
- * the backend client and the five tools, then connects over stdio.
66
- *
67
- * @throws `StatePassphraseInvalid` / `StateCorrupt` from state.ts.
68
- * @throws `Error` if `globalThis.fetch` is missing (Node <18).
82
+ * `GET /seal/{id}` — public metadata. No counter ticks here: `single_use`
83
+ * and `max_opens` are consumed by the ciphertext fetch, not by reading the
84
+ * description of a seal.
69
85
  */
70
- declare function createServer(options: McpServerOptions): Promise<McpServerHandle>;
71
-
86
+ interface SealMetaWire {
87
+ id: string;
88
+ expires_at: string;
89
+ revoked_at: string | null;
90
+ e2ee: boolean;
91
+ nda_required: boolean;
92
+ otp_required: boolean;
93
+ single_use: boolean;
94
+ max_opens?: number | null;
95
+ total_bytes?: number;
96
+ /**
97
+ * P1-1: the server's own verdict on the caller's gate session. Absent on
98
+ * pre-P1-1 backends, which is why the web keeps a cookie heuristic behind
99
+ * an explicit "legacy" branch rather than treating absence as "invalid".
100
+ */
101
+ nda_session_valid?: boolean;
102
+ otp_session_valid?: boolean;
103
+ files: SealFileWire[];
104
+ }
105
+ /** One file row of `GET /seal/{id}/owner` (includes revoked files). */
106
+ interface OwnerFileWire extends SealFileWire {
107
+ revoked_at?: string | null;
108
+ }
109
+ /** `GET /seal/{id}/owner` — the owner-authenticated view of a seal. */
110
+ interface OwnerSealWire {
111
+ id: string;
112
+ created_at: string;
113
+ expires_at: string;
114
+ revoked_at?: string | null;
115
+ e2ee: boolean;
116
+ nda_required: boolean;
117
+ otp_required?: boolean;
118
+ single_use: boolean;
119
+ geo_allowlist: string[] | null;
120
+ max_opens: number | null;
121
+ tier?: string;
122
+ total_bytes?: number;
123
+ files: OwnerFileWire[];
124
+ }
125
+ /** One row of the access log. IPs arrive truncated; the full value never leaves the DB. */
126
+ interface AuditEventWire {
127
+ id: string;
128
+ event: string;
129
+ ts: string;
130
+ country: string | null;
131
+ ip_truncated: string | null;
132
+ file_id: string | null;
133
+ filename: string | null;
134
+ range_request?: boolean;
135
+ }
136
+ /** Per-file aggregation of the access log. */
137
+ interface AuditSummaryWire {
138
+ file_id: string | null;
139
+ filename: string | null;
140
+ downloads: number;
141
+ countries: string[];
142
+ subnets: string[];
143
+ last_download_ts: string | null;
144
+ }
145
+ /** `GET /seal/{id}/audit` — the human-readable access log. */
146
+ interface AuditWire {
147
+ seal_id: string;
148
+ revoked_at: string | null;
149
+ events: AuditEventWire[];
150
+ summary: AuditSummaryWire[];
151
+ }
152
+ /** `POST /seal/{id}/subscriptions` — owner notification webhook. */
153
+ interface SubscriptionWire {
154
+ id: string;
155
+ secret: string;
156
+ }
157
+ /** What an intake request asks for (SPEC-AGENTS §4.4). */
158
+ type IntakeKind = 'secret' | 'file' | 'any';
72
159
  /**
73
- * Single URL resolver for the two origins the MCP server talks about
74
- * (ISSUE-0622).
75
- *
76
- * The two origins are INDEPENDENT in production:
77
- *
78
- * - backend → `https://api.seal.net` — FastAPI, where uploads /
79
- * metadata / revoke calls go;
80
- * - public → `https://seal.net` — the frontend that share
81
- * URLs must point at so a recipient's browser can open them.
82
- *
83
- * Before this module the public host was DERIVED from the backend URL
84
- * ("strip a trailing `/api`"), which worked for single-origin
85
- * self-hosted setups but produced `https://api.seal.net/s/…` links in
86
- * the default production configuration — the API origin serves no
87
- * frontend, so the link was dead on arrival.
88
- *
89
- * Resolution precedence (same for both origins):
90
- * 1. explicit value (CLI flag / `McpServerOptions` / library caller)
91
- * 2. environment variable (`SEAL_MCP_BACKEND_URL` / `SEAL_MCP_PUBLIC_HOST`)
92
- * 3. production default
160
+ * `/intake/{id}` — a person hands an agent a file or a secret through
161
+ * seal.net. `share_path` (`/s/{seal}/{code}#x25519:…`) appears once the page
162
+ * reported its seal, and only until the request expires; it opens only with
163
+ * the key named by `recipient_pub`.
164
+ */
165
+ interface IntakeWire {
166
+ id: string;
167
+ status: 'pending' | 'fulfilled' | 'expired';
168
+ label: string;
169
+ what: string;
170
+ where_url: string | null;
171
+ kind: IntakeKind;
172
+ recipient_pub: string;
173
+ expires_at: string;
174
+ share_path: string | null;
175
+ }
176
+ interface IntakeCreate {
177
+ /** The agent's X25519 public key, base64url without padding. */
178
+ readonly recipientPub: string;
179
+ readonly label: string;
180
+ readonly what: string;
181
+ readonly whereUrl?: string;
182
+ readonly kind?: IntakeKind;
183
+ readonly ttlSeconds?: number;
184
+ }
185
+ /** camelCase file row, for callers that prefer it (Slack, MCP). */
186
+ interface SealFileMeta {
187
+ readonly id: string;
188
+ readonly size: number;
189
+ readonly sha256_hex: string | null;
190
+ readonly name_encrypted?: string;
191
+ readonly name_nonce?: string;
192
+ readonly mime_encrypted?: string;
193
+ readonly mime_nonce?: string;
194
+ }
195
+ /** camelCase projection of `SealMetaWire`. */
196
+ interface SealMetadata {
197
+ readonly id: string;
198
+ readonly expiresAt: string;
199
+ readonly revokedAt: string | null;
200
+ readonly e2ee: boolean;
201
+ readonly ndaRequired: boolean;
202
+ readonly otpRequired: boolean;
203
+ readonly singleUse: boolean;
204
+ readonly maxOpens: number | null;
205
+ readonly files: SealFileMeta[];
206
+ }
207
+ interface SealApiOptions {
208
+ /**
209
+ * Origin (or origin + prefix) every path is appended to. The browser
210
+ * passes its BFF prefix (`/api`) so gate cookies land on the SPA origin;
211
+ * a bot passes the API origin directly.
212
+ */
213
+ readonly baseUrl: string;
214
+ /** Injected `fetch`. Defaults to the global one. */
215
+ readonly fetch?: typeof fetch;
216
+ /**
217
+ * Credentials mode for every request.
218
+ *
219
+ * The browser needs `'include'` for the OTP/NDA session cookies. The
220
+ * public send-only SDK needs `'omit'`: the wildcard-CORS send endpoints
221
+ * answer `Access-Control-Allow-Origin: *`, which browsers refuse for a
222
+ * credentialed request — send auth is the `X-Owner-Token` header, never
223
+ * a cookie. Node ignores the field.
224
+ */
225
+ readonly credentials?: RequestCredentials;
226
+ }
227
+ /** Per-call knobs shared by every method. */
228
+ interface RequestOptions {
229
+ readonly signal?: AbortSignal;
230
+ }
231
+ /** Knobs of `openDownload`. */
232
+ interface DownloadRequestOptions extends RequestOptions {
233
+ /**
234
+ * `false` hands `file.not_ready` to the caller at once. Default: ask again
235
+ * a few times (see `openDownload`).
236
+ */
237
+ readonly retryNotReady?: boolean;
238
+ }
239
+ /** Query parameters accepted by the download endpoint. */
240
+ interface DownloadTarget {
241
+ readonly fileId: string;
242
+ /** Public hand-out of a single file (SEAL Pro decision 2). */
243
+ readonly publicToken?: string;
244
+ /** Public hand-out of a whole seal. */
245
+ readonly viewToken?: string;
246
+ /**
247
+ * The caller is the seal's OWNER. A password, an NDA and a country list are
248
+ * what the owner asks of recipients, so a verified token passes them; it
249
+ * does not lift `single_use` / `max_opens`, which count every download
250
+ * (F-221). A token that does not verify is refused, not ignored.
251
+ */
252
+ readonly ownerToken?: string;
253
+ }
254
+ /** OTP verification input. `fileId`+`publicToken` scope the gate to one file. */
255
+ interface OtpVerification {
256
+ readonly code: string;
257
+ readonly fileId?: string;
258
+ readonly publicToken?: string;
259
+ }
260
+ /** NDA acceptance input, mirroring the backend's snake_case payload. */
261
+ interface NdaAcceptance {
262
+ readonly signature: string;
263
+ readonly timestamp: string;
264
+ readonly firstName: string;
265
+ readonly lastName: string;
266
+ readonly company?: string;
267
+ readonly fileId?: string;
268
+ readonly publicToken?: string;
269
+ /**
270
+ * The exact text the recipient saw. Transient — the backend renders it
271
+ * into the receipt PDF and never stores it.
272
+ */
273
+ readonly ndaText?: string;
274
+ }
275
+ /**
276
+ * The `/seal/*` client.
93
277
  *
94
- * Self-hosted deployments where frontend and backend live on other
95
- * origins must set BOTH knobs — the resolver never guesses one from
96
- * the other.
278
+ * Methods either return a parsed wire object or throw `BackendError`. Two
279
+ * of them (`openDownload`, `openAuditDownload`) return the raw `Response`
280
+ * instead: their bodies are bytes, and how bytes are consumed is a property
281
+ * of the runtime — the browser hands them to a service worker, the Slack
282
+ * bot streams them to disk, the MCP server buffers them.
97
283
  */
98
- declare const DEFAULT_BACKEND_URL = "https://api.seal.net";
99
- declare const DEFAULT_PUBLIC_HOST = "https://seal.net";
100
- declare const BACKEND_URL_ENV_VAR = "SEAL_MCP_BACKEND_URL";
101
- declare const PUBLIC_HOST_ENV_VAR = "SEAL_MCP_PUBLIC_HOST";
102
- /** Backend base URL: explicit → `SEAL_MCP_BACKEND_URL` → production default. */
103
- declare function resolveBackendUrl(explicit?: string): string;
104
- /** Public share-URL origin: explicit → `SEAL_MCP_PUBLIC_HOST` → production default. */
105
- declare function resolvePublicHost(explicit?: string): string;
284
+ declare class SealApi {
285
+ readonly baseUrl: string;
286
+ private readonly injectedFetch;
287
+ private readonly credentials;
288
+ constructor(options: SealApiOptions);
289
+ /**
290
+ * The transport for one request.
291
+ *
292
+ * Called with `globalThis` as receiver, ALWAYS. Storing `globalThis.fetch`
293
+ * on an instance and calling it as `this.fetchImpl(...)` hands the browser a
294
+ * receiver that is not `window`, and it answers "Failed to execute 'fetch'
295
+ * on 'Window': Illegal invocation". Node does not care, which is exactly why
296
+ * the previous copies of this client — all of them Node-only — could carry
297
+ * the same line for a year without anyone noticing.
298
+ *
299
+ * Resolved per call rather than captured in the constructor: a browser host
300
+ * may replace `window.fetch` after this module is evaluated (SEAL Pro does,
301
+ * to re-anchor root-absolute paths onto its `/pro` base path), and clients
302
+ * built as module-level singletons are constructed before that happens.
303
+ */
304
+ private fetchImpl;
305
+ /** Absolute (or origin-relative) URL for a path that must start with `/`. */
306
+ url(path: string): string;
307
+ private request;
308
+ private static ownerHeaders;
309
+ /** `GET /seal/{id}` — public metadata. Ticks no counters. */
310
+ getMetadata(sealId: string, opts?: RequestOptions): Promise<SealMetaWire>;
311
+ /**
312
+ * `GET /seal/{id}/share/{code}` — the recipient's encrypted key blob.
313
+ *
314
+ * Public by construction: the blob is opaque ciphertext, so the server has
315
+ * nothing to gate on. Reading it ticks no counter, which is what lets a
316
+ * client show the file list before the recipient commits to a download.
317
+ */
318
+ getShareBlob(sealId: string, shareCode: string, opts?: RequestOptions): Promise<Uint8Array<ArrayBuffer>>;
319
+ /**
320
+ * `POST /seal/{id}/otp` — verify the recipient password.
321
+ *
322
+ * The backend answers with `Set-Cookie: otp_{sealId}=…`. A browser stores
323
+ * it and cannot read it back (`sessionCookie` is null there, by design);
324
+ * Node has no cookie jar, so the pair is extracted and returned for the
325
+ * caller to forward on the download.
326
+ */
327
+ verifyOtp(sealId: string, input: OtpVerification, opts?: RequestOptions): Promise<{
328
+ status: string;
329
+ sessionCookie: string | null;
330
+ }>;
331
+ /** `POST /seal/{id}/nda` — record the recipient's signature. */
332
+ acceptNda(sealId: string, input: NdaAcceptance, opts?: RequestOptions): Promise<{
333
+ status: string;
334
+ }>;
335
+ /** The ciphertext-download URL. Building it is separate from fetching it. */
336
+ downloadUrl(sealId: string, target: DownloadTarget): string;
337
+ /**
338
+ * `GET /seal/{id}/download` — the ciphertext, as a live `Response`.
339
+ *
340
+ * THIS is the call that consumes `single_use` / `max_opens`, so every
341
+ * policy check a caller wants to make belongs before it: once a counter
342
+ * ticks it stays ticked, whatever the caller does with the bytes.
343
+ *
344
+ * The backend answers 307 to a signed storage URL for EVERY seal — it never
345
+ * serves a byte itself (audit/SPEC-UPLOAD-SCALE.md §6). A runtime whose
346
+ * `fetch` follows redirects (Node: Slack, MCP, SDK) gets the ciphertext
347
+ * from storage here; the browser talks to its BFF, which hands the same
348
+ * URL back as JSON because script cannot read `Location`. The body is left
349
+ * unread on purpose: how bytes are consumed is the runtime's business.
350
+ *
351
+ * `file.not_ready` (409, "clients should retry after retry_after") is
352
+ * asked again, up to NOT_READY_MAX_RETRIES times, pausing `retry_after`
353
+ * seconds. It is a SAFETY NET, not the mechanism: the sender waits for
354
+ * verification before handing out a link, and recipients are only listed
355
+ * verified files, so this is met by a client that kept an older listing or
356
+ * by a verification slower than the sender's wait. Few and spaced, on
357
+ * purpose — every refused attempt writes a line to the owner's access log
358
+ * and spends the download rate limit (F-162). Asking again is safe: the
359
+ * backend refuses an unverified file BEFORE it counts an open
360
+ * (`seal/download.py`, pinned by `test_download_gate_order.py`).
361
+ *
362
+ * Its terminal twin, `file.verification_failed`, is never retried — the
363
+ * bytes were rejected and are being burned. It is recognisable only by
364
+ * `appCode`: its HTTP status is 410, which `code` folds into `revoked`.
365
+ */
366
+ openDownload(sealId: string, target: DownloadTarget & {
367
+ readonly sessionCookie?: string;
368
+ }, opts?: DownloadRequestOptions): Promise<Response>;
369
+ /** `GET /seal/{id}/owner` — the owner's view, revoked files included. */
370
+ getOwnerSeal(sealId: string, ownerToken: string, opts?: RequestOptions): Promise<OwnerSealWire>;
371
+ /**
372
+ * `GET /seal/{id}/blob` — the owner's encrypted key blob.
373
+ *
374
+ * Public endpoint (opaque ciphertext). The `ETag` travels back because a
375
+ * writer must send it as `If-Match`: the blob is read-modify-written by
376
+ * every append, and two tabs doing that without CAS silently lose keys.
377
+ */
378
+ readOwnerBlob(sealId: string, opts?: RequestOptions): Promise<{
379
+ blob: Uint8Array<ArrayBuffer>;
380
+ etag: string | null;
381
+ }>;
382
+ /**
383
+ * `PATCH /seal/{id}/blob` — replace the owner's encrypted key blob.
384
+ *
385
+ * `ifMatch` opts into the CAS contract; without it the backend takes the
386
+ * legacy last-write-wins path. A stale version comes back as a
387
+ * `BackendError` with `status === 412` and `appCode === 'blob.version_mismatch'`.
388
+ */
389
+ writeOwnerBlob(sealId: string, ownerToken: string, encryptedBlobB64: string, opts?: RequestOptions & {
390
+ readonly ifMatch?: string | null;
391
+ }): Promise<{
392
+ etag: string | null;
393
+ }>;
394
+ /** `POST /seal/{id}/share` — mint a share link, returns its code. */
395
+ createShareLink(sealId: string, ownerToken: string, encryptedBlobB64: string, opts?: RequestOptions): Promise<string>;
396
+ /**
397
+ * `POST /seal/{id}/share/{code}/revoke` — revoke one share link.
398
+ * Idempotent: an already-revoked link is success, not a failure.
399
+ */
400
+ revokeShareLink(sealId: string, shareCode: string, ownerToken: string, opts?: RequestOptions): Promise<void>;
401
+ /**
402
+ * `POST /seal/{id}/revoke` — revoke the whole seal.
403
+ *
404
+ * Idempotent, including at the extremes: a seal that is already gone (404)
405
+ * or already revoked (410) satisfies the caller's intent, so both count as
406
+ * success. `revokedAt` is null when the server had nothing left to report.
407
+ */
408
+ revoke(sealId: string, ownerToken: string, opts?: RequestOptions): Promise<{
409
+ status: string;
410
+ revokedAt: string | null;
411
+ }>;
412
+ /**
413
+ * `POST /seal/{id}/files/{fileId}/revoke` — revoke a single file.
414
+ * Idempotent for the same reason as the share-link revoke.
415
+ */
416
+ revokeFile(sealId: string, fileId: string, ownerToken: string, opts?: RequestOptions): Promise<void>;
417
+ /** `GET /seal/{id}/audit` — the access log, PII already truncated server-side. */
418
+ getAudit(sealId: string, ownerToken: string, opts?: RequestOptions): Promise<AuditWire>;
419
+ /**
420
+ * `GET /seal/{id}/audit/download` — the signed JSONL export, as a live
421
+ * `Response`. It is an evidentiary artifact (Ed25519 over each line), so
422
+ * the bytes are handed over untouched.
423
+ */
424
+ openAuditDownload(sealId: string, ownerToken: string, opts?: RequestOptions): Promise<Response>;
425
+ /**
426
+ * `POST /seal/{id}/subscriptions` — register an owner-notification
427
+ * webhook. The derived HMAC secret is returned exactly once.
428
+ */
429
+ createSubscription(sealId: string, ownerToken: string, body?: {
430
+ readonly reference?: string;
431
+ }, opts?: RequestOptions): Promise<SubscriptionWire>;
432
+ /**
433
+ * `POST /billing/checkout` — the Stripe payment page for a seal registered
434
+ * above the free limit (SPEC-AGENTS §5.2). A repeat call within the
435
+ * session's life returns the same page.
436
+ */
437
+ createCheckout(sealId: string, ownerToken: string, tier: string, opts?: RequestOptions): Promise<string>;
438
+ /** `POST /intake` — open a request; the person answers at `/i/{id}`. */
439
+ createIntake(input: IntakeCreate, opts?: RequestOptions): Promise<IntakeWire>;
440
+ /**
441
+ * `GET /intake/{id}` — the request as it stands. `waitSeconds` (at most
442
+ * `limits.json::INTAKE_WAIT_MAX_SECONDS`) holds the answer until the page
443
+ * reports or the wait runs out.
444
+ */
445
+ getIntake(intakeId: string, opts?: RequestOptions & {
446
+ readonly waitSeconds?: number;
447
+ }): Promise<IntakeWire>;
448
+ /** `POST /intake/{id}/fulfil` — the page reports the seal it made; once. */
449
+ fulfilIntake(intakeId: string, link: {
450
+ readonly sealId: string;
451
+ readonly shareCode: string;
452
+ readonly fragment: string;
453
+ }, opts?: RequestOptions): Promise<IntakeWire>;
454
+ }
106
455
 
107
456
  /**
108
457
  * Streaming Uploader Types
@@ -419,6 +768,110 @@ interface SingleFileUploadResult {
419
768
  */
420
769
  declare function uploadSingleFile(ctx: UploadContext, file: File, policy?: UploadPolicy, callbacks?: UploadCallbacks): Promise<SingleFileUploadResult>;
421
770
 
771
+ /** What a workload needs to be itself: which workload, which API, and its private seed. */
772
+ interface WorkloadCredentials {
773
+ workloadId: string;
774
+ /** API base URL including `/api/v1`; in production `https://api.seal.net/pro-api/api/v1`. */
775
+ baseUrl: string;
776
+ /** The 64-byte seed (base64) both private keys derive from. */
777
+ seedB64: string;
778
+ }
779
+
780
+ /**
781
+ * MCP server bootstrap — wires the five tools (`seal_share`,
782
+ * `seal_request`, `seal_open`, `seal_list`, `seal_revoke`) to a
783
+ * stdio-transport `McpServer` instance, and `seal_pro_secret_use`,
784
+ * `seal_pro_secret_request` and `seal_pro_file_get` when SEAL Pro workload
785
+ * credentials exist (contract `pro_tools`).
786
+ *
787
+ * The bootstrap is intentionally factored as a `createServer()`
788
+ * factory that returns an awaitable handle, NOT a hard-coded
789
+ * `main()` entry. That shape:
790
+ * - lets `cli.ts` open the state with the agent's key, resolve the
791
+ * backend base URL, etc. before launching;
792
+ * - lets test code spawn an in-process MCP without spawning a
793
+ * subprocess;
794
+ * - keeps the "where do creds come from?" decision out of the
795
+ * pure tool implementations.
796
+ *
797
+ * Tool `title`, `description` and `annotations` come from
798
+ * `shared/contracts/seal_mcp.v1.json` (contract first, §6.2), and so do
799
+ * the descriptions of the inputs the contract describes; the zod input
800
+ * schemas live here.
801
+ *
802
+ * Two stateful concerns the factory owns:
803
+ * - **State (SPEC-ADDRESS F1 §4)**: every MCP host on the machine runs
804
+ * its own `serve` on the same state, so each tool change goes through
805
+ * `ctx.update` = `state.updateState`: under the state lock, read the
806
+ * file anew, apply the change, save. Startup runs a journal recovery
807
+ * pass (`recovery.ts`) the same way, before any tool registers. In
808
+ * ephemeral mode (`state: null`) the change applies to memory only.
809
+ * - **Tool errors**: thrown `ToolError`s (and `state_busy`) are
810
+ * converted to MCP's `{ isError: true, content: [...] }` payload with
811
+ * the typed `code` echoed back to the model. Other errors bubble as
812
+ * internal_error so the model never sees raw stack traces.
813
+ */
814
+
815
+ interface McpServerOptions {
816
+ /** Encrypted state on disk (`seed.ts::openAgentState`); `null` = ephemeral: a new identity, handles in memory only. */
817
+ readonly state: {
818
+ readonly path: string;
819
+ readonly passphrase: string;
820
+ } | null;
821
+ readonly backendBaseUrl?: string;
822
+ readonly publicHost?: string;
823
+ /** SEAL Pro workload; omitted = read the environment or the enrollment file, `null` = none. */
824
+ readonly workload?: WorkloadCredentials | null;
825
+ }
826
+ interface McpServerHandle {
827
+ readonly mcp: McpServer;
828
+ readonly transport: StdioServerTransport;
829
+ readonly close: () => Promise<void>;
830
+ }
831
+ /**
832
+ * Bootstrap an MCP server. Loads + decrypts the state, instantiates
833
+ * the backend client and the five tools, then connects over stdio.
834
+ *
835
+ * @throws `StatePassphraseInvalid` / `StateCorrupt` from state.ts.
836
+ * @throws `Error` if `globalThis.fetch` is missing (Node <18).
837
+ */
838
+ declare function createServer(options: McpServerOptions): Promise<McpServerHandle>;
839
+
840
+ /**
841
+ * Single URL resolver for the two origins the MCP server talks about
842
+ * (ISSUE-0622).
843
+ *
844
+ * The two origins are INDEPENDENT in production:
845
+ *
846
+ * - backend → `https://api.seal.net` — FastAPI, where uploads /
847
+ * metadata / revoke calls go;
848
+ * - public → `https://seal.net` — the frontend that share
849
+ * URLs must point at so a recipient's browser can open them.
850
+ *
851
+ * Before this module the public host was DERIVED from the backend URL
852
+ * ("strip a trailing `/api`"), which worked for single-origin
853
+ * self-hosted setups but produced `https://api.seal.net/s/…` links in
854
+ * the default production configuration — the API origin serves no
855
+ * frontend, so the link was dead on arrival.
856
+ *
857
+ * Resolution precedence (same for both origins):
858
+ * 1. explicit value (CLI flag / `McpServerOptions` / library caller)
859
+ * 2. environment variable (`SEAL_MCP_BACKEND_URL` / `SEAL_MCP_PUBLIC_HOST`)
860
+ * 3. production default
861
+ *
862
+ * Self-hosted deployments where frontend and backend live on other
863
+ * origins must set BOTH knobs — the resolver never guesses one from
864
+ * the other.
865
+ */
866
+ declare const DEFAULT_BACKEND_URL = "https://api.seal.net";
867
+ declare const DEFAULT_PUBLIC_HOST = "https://seal.net";
868
+ declare const BACKEND_URL_ENV_VAR = "SEAL_MCP_BACKEND_URL";
869
+ declare const PUBLIC_HOST_ENV_VAR = "SEAL_MCP_PUBLIC_HOST";
870
+ /** Backend base URL: explicit → `SEAL_MCP_BACKEND_URL` → production default. */
871
+ declare function resolveBackendUrl(explicit?: string): string;
872
+ /** Public share-URL origin: explicit → `SEAL_MCP_PUBLIC_HOST` → production default. */
873
+ declare function resolvePublicHost(explicit?: string): string;
874
+
422
875
  /**
423
876
  * Out-of-band delivery of the share link to the human user. The link is
424
877
  * the whole capability (key in the fragment); there is no password.
@@ -595,6 +1048,15 @@ interface PendingOperation {
595
1048
  * backend — see `HandleEntry.backend_url`.
596
1049
  */
597
1050
  backend_url?: string;
1051
+ /**
1052
+ * The process running the operation (SPEC-ADDRESS F1 §4): several MCP
1053
+ * hosts share this state, and recovery leaves an entry alone while its
1054
+ * owner may still be running. Absent on 0.2.x entries.
1055
+ */
1056
+ readonly owner?: {
1057
+ readonly pid: number;
1058
+ readonly hostname: string;
1059
+ };
598
1060
  }
599
1061
  interface AuditEntry {
600
1062
  readonly ts: string;
@@ -649,6 +1111,19 @@ declare function loadState(passphrase: string, statePath: string): Promise<State
649
1111
  * is non-adaptive — but cheap and follows defence-in-depth).
650
1112
  */
651
1113
  declare function saveState(state: StateView, passphrase: string, statePath: string): Promise<void>;
1114
+ /**
1115
+ * A change to the state: insertions, and deletions as explicit operations
1116
+ * (a key missing from one process's view must not delete what another
1117
+ * process wrote). `false` = nothing to write, only read the file anew.
1118
+ */
1119
+ type StateMutation = (state: StateView) => boolean | void | Promise<boolean | void>;
1120
+ /**
1121
+ * The one way to change the state on disk (SPEC-ADDRESS F1 §4): under the
1122
+ * state lock, read `state.json` anew, apply `mutate`, save. Several MCP hosts
1123
+ * share the file, so a process never writes its own copy of the whole view.
1124
+ * Returns the state as it now is.
1125
+ */
1126
+ declare function updateState(passphrase: string, statePath: string, mutate: StateMutation): Promise<StateView>;
652
1127
 
653
1128
  /**
654
1129
  * X25519 identity for the MCP server (incoming targeted-share recipient).
@@ -658,9 +1133,10 @@ declare function saveState(state: StateView, passphrase: string, statePath: stri
658
1133
  * `seal keygen`, the SDK and `sealnet` use — byte-for-byte compatible by
659
1134
  * construction, not by spec.
660
1135
  *
661
- * The `Identity` view is held only in process memory; it is reconstructed
662
- * from `state.identity` on every server start. The serialised form lives
663
- * inside the AES-256-GCM ciphertext of `state.ts`.
1136
+ * The secret is the agent's root secret (`seed.ts`, SPEC-ADDRESS F1); the
1137
+ * `Identity` view is reconstructed from `state.identity`, which must equal
1138
+ * it, on every server start. The serialised form lives inside the
1139
+ * AES-256-GCM ciphertext of `state.ts`.
664
1140
  */
665
1141
 
666
1142
  interface Identity {
@@ -669,6 +1145,8 @@ interface Identity {
669
1145
  }
670
1146
  /** A fresh X25519 keypair for this server. */
671
1147
  declare function generateIdentity(): Promise<Identity>;
1148
+ /** The keypair of a root secret (`seed.ts`): the seed is the X25519 secret. */
1149
+ declare function identityFromSeed(seed: Uint8Array): Promise<Identity>;
672
1150
  /**
673
1151
  * Project an `Identity` into the serialised form that lives in
674
1152
  * `state.identity` (base64-encoded raw bytes; matches contract
@@ -683,22 +1161,22 @@ declare function identityToFields(id: Identity): IdentityFields;
683
1161
  declare function loadIdentity(fields: IdentityFields): Promise<Identity>;
684
1162
 
685
1163
  /**
686
- * Exclusive lifetime lock for the seal-mcp state directory (ISSUE-0623).
1164
+ * The seal-mcp state lock, taken for each write (ISSUE-0623, SPEC-ADDRESS F1 §4).
687
1165
  *
688
1166
  * # Why
689
1167
  *
690
- * `state.json` is a whole-envelope read-modify-write store: the server
691
- * loads the decrypted view once at boot and every `persist()` re-encrypts
692
- * and atomically replaces the file. Two MCP processes pointed at the same
693
- * state dir (the DEFAULT for every MCP host on the machine — Cursor +
694
- * Claude Desktop both spawn `seal-mcp serve`) would silently overwrite
695
- * each other's handles and audit entries: last writer wins, the other
696
- * process's owner tokens are gone forever.
1168
+ * `state.json` is a whole-envelope read-modify-write store. Every MCP host on
1169
+ * the machine (Claude Desktop, Claude Code, Cursor) spawns its own
1170
+ * `sealnet-mcp serve` on the same state dir, and all of them must work: one
1171
+ * seed, one address. So a process does not own the state for its lifetime;
1172
+ * each change re-reads the file, applies this process's change and saves
1173
+ * (`state.ts::updateState`), and the lock makes that sequence exclusive. Two
1174
+ * writers without it would overwrite each other's handles: last writer wins,
1175
+ * the other process's owner tokens are gone forever.
697
1176
  *
698
- * The fix is a single-writer discipline: exactly one process may hold
699
- * the state open for writing for its lifetime. Second `serve` (or a
700
- * concurrent `init`/`reset`) fails fast with a legible error instead of
701
- * corrupting capability state.
1177
+ * Waiting for the lock ends after `LOCK_WAIT_MS` with `StateBusyError`
1178
+ * (`state_busy`). Inside one process the writes queue (`withStateLock`): the
1179
+ * lock file names a pid, and the process would otherwise wait for itself.
702
1180
  *
703
1181
  * # Mechanism (DR-mcp: TOCTOU-hardened)
704
1182
  *
@@ -719,10 +1197,11 @@ declare function loadIdentity(fields: IdentityFields): Promise<Identity>;
719
1197
  * fresh holder slipped in between probe and rename), and only then
720
1198
  * creates its own lock. A lock from a different hostname (network
721
1199
  * volume) is never broken automatically — we cannot probe liveness
722
- * across hosts.
1200
+ * across hosts. The same rule (`holderMayRun`) tells journal recovery
1201
+ * whose upload it must leave alone.
723
1202
  *
724
1203
  * PID-recycling is the accepted residual risk: the probe window is
725
- * milliseconds and the failure mode (refusing to start) is safe.
1204
+ * milliseconds and the failure mode (waiting, then `state_busy`) is safe.
726
1205
  *
727
1206
  * Release verifies ownership: the lock is unlinked only if it still
728
1207
  * carries OUR pid+hostname, so a process can never release a lock a
@@ -741,6 +1220,11 @@ declare class StateLockedError extends Error {
741
1220
  readonly lockPath: string;
742
1221
  constructor(lockPath: string, holder: LockHolderInfo | null);
743
1222
  }
1223
+ /** The lock stayed taken for `LOCK_WAIT_MS`; nothing was written (contract `state_busy`). */
1224
+ declare class StateBusyError extends Error {
1225
+ readonly code = "state_busy";
1226
+ constructor(locked: StateLockedError);
1227
+ }
744
1228
  interface StateLock {
745
1229
  readonly lockPath: string;
746
1230
  /** Idempotent. Removes the lock file (if still ours) and the process-exit hook. */
@@ -754,6 +1238,11 @@ interface AcquireLockDeps {
754
1238
  /** Override hostname for tests. */
755
1239
  readonly host?: string;
756
1240
  }
1241
+ /**
1242
+ * Whether the process a lock or a journal entry names may still be running:
1243
+ * alive on this host, or on another host, which cannot be probed.
1244
+ */
1245
+ declare function holderMayRun(holder: Pick<LockHolderInfo, 'pid' | 'hostname'>, deps?: AcquireLockDeps): boolean;
757
1246
  /**
758
1247
  * Acquire the exclusive state-dir lock or throw `StateLockedError`.
759
1248
  *
@@ -763,6 +1252,11 @@ interface AcquireLockDeps {
763
1252
  * `mode` does not repair an existing directory).
764
1253
  */
765
1254
  declare function acquireStateLock(stateDir: string, deps?: AcquireLockDeps): Promise<StateLock>;
1255
+ /**
1256
+ * Run `fn` holding the state lock: waits up to `LOCK_WAIT_MS` for another
1257
+ * process to finish its write, then fails with `StateBusyError`.
1258
+ */
1259
+ declare function withStateLock<T>(stateDir: string, fn: () => Promise<T>, deps?: AcquireLockDeps): Promise<T>;
766
1260
  /**
767
1261
  * Read-only probe for `doctor`: report who (if anyone) holds the lock
768
1262
  * and whether it looks stale from this host's perspective.
@@ -776,8 +1270,10 @@ declare function probeStateLock(stateDir: string, deps?: AcquireLockDeps): Promi
776
1270
  /**
777
1271
  * Startup recovery for the durable operation journal (ISSUE-0623).
778
1272
  *
779
- * Runs once in `createServer()` after the state lock is held and the
780
- * envelope is decrypted, BEFORE any tool can execute. Each surviving
1273
+ * Runs once in `createServer()`, under the state lock (`updateState`),
1274
+ * BEFORE any tool can execute. Several MCP hosts share the state
1275
+ * (SPEC-ADDRESS F1 §4), so an entry whose `owner` may still be running
1276
+ * is a neighbour's upload in progress and is left alone. Every other
781
1277
  * `state.pending` entry is a `seal_share` that crashed (or lost its
782
1278
  * process) mid-flight; the step recorded at the last checkpoint tells
783
1279
  * us exactly what is safe to do:
@@ -827,7 +1323,7 @@ interface RecoveryReport {
827
1323
  }
828
1324
  /**
829
1325
  * Reconcile `view.pending` in place. Returns the report + whether the
830
- * view changed (caller persists once at the end — a crash during
1326
+ * view changed (the caller writes once at the end — a crash during
831
1327
  * recovery just means the same idempotent pass reruns next start).
832
1328
  */
833
1329
  declare function recoverPendingOperations(view: StateView, client: RevokeClient, opts?: {
@@ -877,33 +1373,29 @@ interface ResolveOptions {
877
1373
  }
878
1374
  declare function resolveStateDir(opts?: ResolveOptions): PathResolution;
879
1375
 
880
- /**
881
- * Passphrase resolution + hidden TTY prompt for `seal-mcp`.
882
- *
883
- * Priority chain (per `shared/contracts/seal_mcp.v1.json::passphrase_delivery`):
884
- *
885
- * 1. OS keychain via `@napi-rs/keyring` (service `'seal-mcp'`, account `'state'`).
886
- * 2. `SEAL_MCP_PASSPHRASE` environment variable. Logs a warning to
887
- * stderr because env vars are visible via `/proc/<pid>/environ`
888
- * on Linux, `ps -E`/`procstat -e` on BSD, and via the process
889
- * object on Windows.
890
- * 3. Cleartext on disk — REFUSED. Listed only to document the
891
- * refusal; the resolver has no codepath that reads from a file.
892
- *
893
- * The keychain module is an optional dependency loaded lazily (same pattern
894
- * as `clipboardy` in `handoff.ts`), so a machine without it, or without a
895
- * keychain, falls back to the env var without crashing at import time.
896
- */
897
- declare const KEYCHAIN_SERVICE: "seal-mcp";
898
- declare const KEYCHAIN_ACCOUNT: "state";
899
- declare const PASSPHRASE_ENV_VAR: "SEAL_MCP_PASSPHRASE";
900
- type PassphraseSource = 'keychain' | 'env';
901
- interface ResolvedPassphrase {
902
- readonly passphrase: string;
903
- readonly source: PassphraseSource;
1376
+ declare const SEED_ENV_VAR = "SEAL_SEED";
1377
+ declare const EPHEMERAL_ENV_VAR = "SEAL_MCP_EPHEMERAL";
1378
+ /** 0.2.x only: read once, to move a state of that version. */
1379
+ declare const PASSPHRASE_ENV_VAR = "SEAL_MCP_PASSPHRASE";
1380
+ declare const SEED_FILE_NAME = "seed";
1381
+ declare const KEYCHAIN_SERVICE = "seal-mcp";
1382
+ declare const KEYCHAIN_ACCOUNT = "seed";
1383
+ type SeedSource = 'env' | 'keychain' | 'file';
1384
+ interface RootSecret {
1385
+ readonly seed: Uint8Array;
1386
+ readonly source: SeedSource;
1387
+ }
1388
+ declare class SeedInvalidError extends Error {
1389
+ readonly code = "seed_invalid";
1390
+ constructor(where: string);
1391
+ }
1392
+ /** The state belongs to another key (contract `root_secret.identity_conflict`). */
1393
+ declare class IdentityConflictError extends Error {
1394
+ readonly code = "identity_conflict";
1395
+ constructor(statePath: string);
904
1396
  }
905
1397
  /**
906
- * Surface used by `passphrase.ts` — `keytar`'s shape, kept narrow on purpose
1398
+ * Surface used by this module — `keytar`'s shape, kept narrow on purpose
907
1399
  * so tests can stub the keychain without pulling in the native module.
908
1400
  */
909
1401
  interface KeytarLike {
@@ -911,43 +1403,35 @@ interface KeytarLike {
911
1403
  setPassword(service: string, account: string, password: string): Promise<void>;
912
1404
  deletePassword(service: string, account: string): Promise<boolean>;
913
1405
  }
914
- declare class KeytarUnavailableError extends Error {
915
- readonly code = "keytar_unavailable";
916
- constructor(detail: string);
917
- }
918
- declare class PassphraseNotFoundError extends Error {
919
- readonly code = "passphrase_not_found";
920
- constructor();
1406
+ /** The seed from the first source that has one; `null` when none has. */
1407
+ declare function readSeed(stateDir: string, env?: NodeJS.ProcessEnv): Promise<RootSecret | null>;
1408
+ /** The state passphrase: base64url(HKDF-SHA256(seed, 'seal-mcp:state:v1', 'passphrase', 32 bytes)). */
1409
+ declare function statePassphrase(seed: Uint8Array): string;
1410
+ interface AgentState {
1411
+ readonly root: RootSecret;
1412
+ readonly passphrase: string;
1413
+ /** This call made the state (a first start). */
1414
+ readonly created: boolean;
1415
+ /** This call moved a 0.2.x state under the seed: where that version's passphrase came from. */
1416
+ readonly moved: 'keychain' | 'env' | null;
921
1417
  }
922
1418
  /**
923
- * Try keychain first, then env var. NEVER reads from a file. Returns
924
- * `null` if neither source has a passphrase — caller decides whether to
925
- * prompt interactively (`init`) or hard-refuse (`serve`).
926
- */
927
- declare function resolvePassphraseFromStores(env?: NodeJS.ProcessEnv): Promise<ResolvedPassphrase | null>;
928
- /**
929
- * Probe whether the OS keychain is usable AND whether the seal-mcp
930
- * entry already exists. Used by `sealnet-mcp doctor`.
1419
+ * The agent's state, opened with its root secret: made on the first start
1420
+ * (with the seed, when there is none), moved once from 0.2.x (SPEC-ADDRESS
1421
+ * F1 §3), refused when it belongs to another key. Under the state lock, so
1422
+ * two hosts starting together make one seed and one state.
931
1423
  */
1424
+ declare function openAgentState(path: PathResolution, env?: NodeJS.ProcessEnv): Promise<AgentState>;
1425
+ /** `reset`: the seed file and the keychain entries (the seed, and 0.2.x's passphrase). */
1426
+ declare function deleteRootSecret(stateDir: string): Promise<{
1427
+ seedFile: boolean;
1428
+ keychain: boolean;
1429
+ }>;
1430
+ /** `doctor`: is there a keychain, and does it hold the seed. */
932
1431
  declare function probeKeychain(): Promise<{
933
1432
  available: boolean;
934
- hasEntry: boolean;
1433
+ hasSeed: boolean;
935
1434
  }>;
936
- declare function setKeychainPassphrase(passphrase: string): Promise<void>;
937
- declare function deleteKeychainPassphrase(): Promise<boolean>;
938
- /**
939
- * Prompt for a passphrase on the TTY without echoing keystrokes.
940
- *
941
- * Requires `process.stdin.isTTY === true` — caller must verify ahead
942
- * of time and route non-TTY callers to the env-var path.
943
- *
944
- * Special keys:
945
- * - `\r` / `\n` → resolve with the accumulated buffer.
946
- * - `\u0003` (Ctrl-C) → reject with `cancelled`.
947
- * - `\u0004` (Ctrl-D) on empty buffer → reject with `cancelled`.
948
- * - `\u007f` / `\b` (Backspace) → delete last char.
949
- */
950
- declare function promptHiddenPassphrase(prompt: string): Promise<string>;
951
1435
 
952
1436
  /**
953
1437
  * Shared `ToolContext` and helpers used by every tool implementation.
@@ -959,20 +1443,20 @@ declare function promptHiddenPassphrase(prompt: string): Promise<string>;
959
1443
  * context) and prevents accidental global state.
960
1444
  */
961
1445
 
962
- /** Mutable, single-source-of-truth state passed across tool calls. */
1446
+ /** What every tool call gets. */
963
1447
  interface ToolContext {
1448
+ /** The state as this process last read or wrote it; `update` replaces it. */
964
1449
  readonly state: StateView;
965
1450
  readonly identity: Identity;
966
- readonly client: S;
1451
+ readonly client: SealApi;
967
1452
  /**
968
- * Persistence hook. The server is responsible for serialising and
969
- * encrypting `ctx.state` to disk after a successful mutation; tools
970
- * mutate `ctx.state` in-place and call `await ctx.persist()` when
971
- * ready. Errors propagate to the tool which surfaces them as a
972
- * structured MCP error to the model.
1453
+ * The one way a tool changes the state (`state.ts::updateState`): the file
1454
+ * is read anew under the lock, `mutate` applied and saved, and `state`
1455
+ * becomes the result. Other MCP hosts on the machine write the same file,
1456
+ * so a tool never mutates `state` itself. Errors propagate to the tool.
973
1457
  */
974
- persist: () => Promise<void>;
975
- /** No keychain, nothing on disk: handles die with the process (receipt says so). */
1458
+ readonly update: (mutate: StateMutation) => Promise<void>;
1459
+ /** `serve --ephemeral`, nothing on disk: handles die with the process (receipt says so). */
976
1460
  readonly ephemeral?: boolean;
977
1461
  /** `seal_request` handles waiting on a link, to their intake id; in memory only. */
978
1462
  readonly requests?: Map<string, string>;
@@ -1320,4 +1804,4 @@ interface SealShareDeps {
1320
1804
  }
1321
1805
  declare function sealShare(ctx: ToolContext, input: SealShareInput, deps?: SealShareDeps): Promise<SealShareOutput>;
1322
1806
 
1323
- export { APP_NAME, type AcquireLockDeps, type AuditEntry, BACKEND_URL_ENV_VAR, DEFAULT_BACKEND_URL, DEFAULT_PUBLIC_HOST, type HandleEntry, HandoffBothChannelsFailedError, type HandoffPayload, type HandoffResult, type Identity, type IdentityFields, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, type KeytarLike, KeytarUnavailableError, LOCK_FILE_NAME, type LockHolderInfo, type McpServerHandle, type McpServerOptions, PASSPHRASE_ENV_VAR, PUBLIC_HOST_ENV_VAR, PassphraseNotFoundError, type PassphraseSource, type PathResolution, type PendingCompensation, type PendingOperation, type PendingStep, type RecoveryReport, type ResolveOptions, type ResolvedPassphrase, type RevokeClient, STATE_FILE_NAME, type SealListInput, type SealListOutput, type SealOpenInput, type SealOpenOutput, type SealRevokeInput, type SealRevokeOutput, type SealShareInput, type SealShareMode, type SealShareOutput, StateCorrupt, type StateDirSource, type StateLock, StateLockedError, StatePassphraseInvalid, type StateView, ToolError, acquireStateLock, createEmptyState, createServer, deleteKeychainPassphrase, generateHandle, generateIdentity, identityToFields, isValidHandle, loadIdentity, loadState, performHandoff, probeKeychain, probeStateLock, promptHiddenPassphrase, recoverPendingOperations, resolveBackendUrl, resolvePassphraseFromStores, resolvePublicHost, resolveStateDir, saveState, sealList, sealOpen, sealRevoke, sealShare, setKeychainPassphrase };
1807
+ export { APP_NAME, type AcquireLockDeps, type AgentState, type AuditEntry, BACKEND_URL_ENV_VAR, BackendError, DEFAULT_BACKEND_URL, DEFAULT_PUBLIC_HOST, EPHEMERAL_ENV_VAR, type HandleEntry, HandoffBothChannelsFailedError, type HandoffPayload, type HandoffResult, type Identity, IdentityConflictError, type IdentityFields, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, type KeytarLike, LOCK_FILE_NAME, type LockHolderInfo, type McpServerHandle, type McpServerOptions, PASSPHRASE_ENV_VAR, PUBLIC_HOST_ENV_VAR, type PathResolution, type PendingCompensation, type PendingOperation, type PendingStep, type RecoveryReport, type ResolveOptions, type RevokeClient, type RootSecret, SEED_ENV_VAR, SEED_FILE_NAME, STATE_FILE_NAME, SealApi, type SealApiOptions, type SealListInput, type SealListOutput, type SealMetadata, type SealOpenInput, type SealOpenOutput, type SealRevokeInput, type SealRevokeOutput, type SealShareInput, type SealShareMode, type SealShareOutput, SeedInvalidError, type SeedSource, StateBusyError, StateCorrupt, type StateDirSource, type StateLock, StateLockedError, type StateMutation, StatePassphraseInvalid, type StateView, ToolError, acquireStateLock, createEmptyState, createServer, deleteRootSecret, generateHandle, generateIdentity, holderMayRun, identityFromSeed, identityToFields, isValidHandle, loadIdentity, loadState, openAgentState, performHandoff, probeKeychain, probeStateLock, readSeed, recoverPendingOperations, resolveBackendUrl, resolvePublicHost, resolveStateDir, saveState, sealList, sealOpen, sealRevoke, sealShare, statePassphrase, updateState, withStateLock };