sealnet-mcp 0.2.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/LICENSE +7 -0
- package/README.md +106 -0
- package/dist/cli.cjs +14641 -0
- package/dist/cli.d.cts +36 -0
- package/dist/cli.d.ts +36 -0
- package/dist/cli.js +14612 -0
- package/dist/index.cjs +14384 -0
- package/dist/index.d.cts +1325 -0
- package/dist/index.d.ts +1325 -0
- package/dist/index.js +14311 -0
- package/package.json +71 -0
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
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
|
+
|
|
7
|
+
/**
|
|
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.
|
|
28
|
+
*
|
|
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.
|
|
45
|
+
*/
|
|
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;
|
|
57
|
+
}
|
|
58
|
+
interface McpServerHandle {
|
|
59
|
+
readonly mcp: McpServer;
|
|
60
|
+
readonly transport: StdioServerTransport;
|
|
61
|
+
readonly close: () => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
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).
|
|
69
|
+
*/
|
|
70
|
+
declare function createServer(options: McpServerOptions): Promise<McpServerHandle>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
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
|
|
93
|
+
*
|
|
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.
|
|
97
|
+
*/
|
|
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;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Streaming Uploader Types
|
|
109
|
+
*
|
|
110
|
+
* CANON: All upload logic is in internal/upload-engine.ts
|
|
111
|
+
* This file exports ONLY types for backwards compatibility.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Progress of one upload call, in ONE base.
|
|
115
|
+
*
|
|
116
|
+
* It used to come in two: the single-request path reported a percentage
|
|
117
|
+
* WITHIN each phase, over plaintext bytes, re-zeroing at every phase
|
|
118
|
+
* boundary; the multipart path reported one global percentage over delivered
|
|
119
|
+
* ciphertext. Nothing in the event said which, so every consumer guessed from
|
|
120
|
+
* the file size and carried its own projection — six of them, all different
|
|
121
|
+
* (F-150, F-159, F-165, F-165a, F-177). The emitter is fixed instead, and the
|
|
122
|
+
* projections are gone.
|
|
123
|
+
*/
|
|
124
|
+
interface UploadProgress {
|
|
125
|
+
phase: "hashing" | "encrypting" | "uploading" | "verifying" | "done";
|
|
126
|
+
/**
|
|
127
|
+
* 0–100 for the whole call, and it never decreases — on either path,
|
|
128
|
+
* across phases, across retries. 100 only with `phase: 'done'`.
|
|
129
|
+
*/
|
|
130
|
+
percent: number;
|
|
131
|
+
/**
|
|
132
|
+
* Ciphertext bytes that storage has CONFIRMED. Never bytes handed to a
|
|
133
|
+
* socket: `xhr.upload.onprogress` reports what the OS buffered, which runs
|
|
134
|
+
* ahead of the wire and then freezes — the lie the owner banned. So this
|
|
135
|
+
* moves in steps of one part, and a single-request file is 0 until it
|
|
136
|
+
* lands.
|
|
137
|
+
*/
|
|
138
|
+
deliveredBytes: number;
|
|
139
|
+
/** Ciphertext bytes this call will deliver in all. */
|
|
140
|
+
totalBytes: number;
|
|
141
|
+
/**
|
|
142
|
+
* Ciphertext bytes ENCRYPTED so far, delivered or not.
|
|
143
|
+
*
|
|
144
|
+
* Local work, and honest about being that: it is not a claim that anything
|
|
145
|
+
* left the machine (`deliveredBytes` is the only such claim). A screen uses
|
|
146
|
+
* it for the stretch before the first part lands, which on a large file is
|
|
147
|
+
* seconds of a bar that would otherwise sit still — the first thing the
|
|
148
|
+
* owner called broken when he looked at the extension's popup.
|
|
149
|
+
*/
|
|
150
|
+
preparedBytes?: number;
|
|
151
|
+
/** Confirmed bytes per second over the recent past; absent before the first delivery. */
|
|
152
|
+
speedBps?: number;
|
|
153
|
+
/** Time left at `speedBps`; absent when there is no speed yet. */
|
|
154
|
+
etaMs?: number;
|
|
155
|
+
/**
|
|
156
|
+
* `stalled`: the upload has been failing and retrying for a while with
|
|
157
|
+
* nothing delivered — a screen says "no connection, retrying" instead of
|
|
158
|
+
* showing a bar that stopped for no visible reason.
|
|
159
|
+
*/
|
|
160
|
+
state: "running" | "stalled";
|
|
161
|
+
/** 1 on a clean run; grows with each retry since the last delivery. */
|
|
162
|
+
attempt: number;
|
|
163
|
+
/** Set when one callback covers several files (append): which one this is. */
|
|
164
|
+
fileIndex?: number;
|
|
165
|
+
totalFiles?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Transport policy: when a failed network step is tried again, and when an
|
|
170
|
+
* upload gives up.
|
|
171
|
+
*
|
|
172
|
+
* Every network step of an upload goes through here — multipart init, the
|
|
173
|
+
* presign calls, each part PUT, complete, and the single-request leg's
|
|
174
|
+
* initiate / PUT / confirm. Before this the rules were literals scattered
|
|
175
|
+
* over two files and they disagreed: a part got three attempts inside four
|
|
176
|
+
* seconds, the single-request leg and `init` / `complete` got one, and a
|
|
177
|
+
* five-second network dip killed a 490 MB upload that had already delivered
|
|
178
|
+
* 480 MB of it (F-146…F-148, F-179).
|
|
179
|
+
*
|
|
180
|
+
* The rule is a STALL CLOCK for the whole upload, not a budget per step:
|
|
181
|
+
*
|
|
182
|
+
* * a failure is retried for as long as the upload as a whole keeps
|
|
183
|
+
* making progress — every confirmed part (or confirmed file) stops the
|
|
184
|
+
* clock, and the next failure starts it from zero;
|
|
185
|
+
* * the clock is only ever read when something FAILS. A part that is
|
|
186
|
+
* merely slow — 16 MiB on a 256 KB/s link takes minutes — is never
|
|
187
|
+
* timed by it: no watchdog may kill a healthy slow transfer. A DEAD
|
|
188
|
+
* socket is another matter and is not this clock's business either — see
|
|
189
|
+
* "liveness" in `multipart-uploader.ts` (audit/SPEC-UPLOAD-SCALE.md §4);
|
|
190
|
+
* * attempts are not counted at all. What ends an upload is `maxStallMs`
|
|
191
|
+
* with nothing delivered, a failure that cannot heal (most 4xx), or the
|
|
192
|
+
* caller's cancel.
|
|
193
|
+
*
|
|
194
|
+
* The numbers live in `shared/contracts/upload.json` so the other clients
|
|
195
|
+
* can read the same ones (F-167).
|
|
196
|
+
*
|
|
197
|
+
* @internal - NOT exported from SDK public API
|
|
198
|
+
*/
|
|
199
|
+
|
|
200
|
+
interface TransportPolicy {
|
|
201
|
+
/** Delay before retry N (the last entry repeats). */
|
|
202
|
+
retryBackoffMs: readonly number[];
|
|
203
|
+
/** Each delay is scaled by a random factor in `1 ± ratio`. */
|
|
204
|
+
retryJitterRatio: number;
|
|
205
|
+
/** Failing with nothing delivered for this long ⇒ state `stalled`. */
|
|
206
|
+
stallNoticeMs: number;
|
|
207
|
+
/**
|
|
208
|
+
* Failing with nothing delivered for this long ⇒ the upload gives up.
|
|
209
|
+
* `Infinity` for a caller whose user has a Cancel button and would
|
|
210
|
+
* rather wait out an outage than lose the upload.
|
|
211
|
+
*/
|
|
212
|
+
maxStallMs: number;
|
|
213
|
+
/** Slowest link a single PUT is still allowed to finish on. */
|
|
214
|
+
putFloorBytesPerSecond: number;
|
|
215
|
+
putMinDeadlineMs: number;
|
|
216
|
+
controlTimeoutMs: number;
|
|
217
|
+
completeTimeoutMs: number;
|
|
218
|
+
/**
|
|
219
|
+
* Parallel part PUTs: where the pool starts, and how wide it may get.
|
|
220
|
+
* Storage speaks HTTP/1.1 only, so every PUT is a connection of its own and
|
|
221
|
+
* a browser gives a host six: `poolMax` + `hedgeMaxConcurrent` must fit.
|
|
222
|
+
*/
|
|
223
|
+
poolStart: number;
|
|
224
|
+
poolMax: number;
|
|
225
|
+
/**
|
|
226
|
+
* How narrow the pool may get on a line so slow that its flows are near
|
|
227
|
+
* `putFloorBytesPerSecond` each — the speed below which a PUT's deadline
|
|
228
|
+
* calls it dead. Fewer flows there means each one stays clear of it.
|
|
229
|
+
*/
|
|
230
|
+
poolMin: number;
|
|
231
|
+
/** Widen when the aggregate rate grew by this factor since the last resize… */
|
|
232
|
+
poolGrowRatio: number;
|
|
233
|
+
/** …step back when it fell to this fraction of it. */
|
|
234
|
+
poolShrinkRatio: number;
|
|
235
|
+
/** Encrypted parts that may wait for a slot (bounds memory). */
|
|
236
|
+
partQueueLimit: number;
|
|
237
|
+
/** Never duplicate a part before it has been in flight this long. */
|
|
238
|
+
hedgeMinInflightMs: number;
|
|
239
|
+
/** A part is an outlier when it is this many times slower than its peers' median. */
|
|
240
|
+
hedgeLagFactor: number;
|
|
241
|
+
hedgePollMs: number;
|
|
242
|
+
/** Duplicate copies in flight across the whole upload, at most. */
|
|
243
|
+
hedgeMaxConcurrent: number;
|
|
244
|
+
/**
|
|
245
|
+
* No part delivered and no socket of ANY copy taking bytes for this long,
|
|
246
|
+
* while some copy still has bytes to send ⇒ the connections are dead: every
|
|
247
|
+
* part in flight is reconnected. Only where the transport reports socket
|
|
248
|
+
* activity (XHR in a window).
|
|
249
|
+
*/
|
|
250
|
+
livenessSilenceMs: number;
|
|
251
|
+
/**
|
|
252
|
+
* The server verifies a delivered file INSIDE `complete` / `confirm` and says
|
|
253
|
+
* so in its answer (audit/SPEC-UPLOAD-SCALE.md §3). Only when it could not
|
|
254
|
+
* ask storage does it answer `accepted` and leave the verdict to a worker;
|
|
255
|
+
* then the sender polls, with these pauses (the last one repeats), for at
|
|
256
|
+
* most `verifyFallbackWaitMs`. The check is a HEAD, so the wait no longer
|
|
257
|
+
* grows with the size of the file.
|
|
258
|
+
*/
|
|
259
|
+
verifyPollBackoffMs: readonly number[];
|
|
260
|
+
verifyFallbackWaitMs: number;
|
|
261
|
+
/** Window `speedBps` is averaged over. Display only; decides nothing. */
|
|
262
|
+
speedWindowMs: number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* What an upload is told by its caller: where to send, how to authenticate,
|
|
267
|
+
* and the size policy. Shared by the registration flows (`engine.ts`) and the
|
|
268
|
+
* one primitive that moves bytes (`session.ts`), so neither imports the other
|
|
269
|
+
* for a type.
|
|
270
|
+
*
|
|
271
|
+
* @internal - NOT exported from SDK public API
|
|
272
|
+
*/
|
|
273
|
+
|
|
274
|
+
interface UploadContext {
|
|
275
|
+
/** API base URL */
|
|
276
|
+
baseUrl: string;
|
|
277
|
+
/** Authentication */
|
|
278
|
+
auth: AuthConfig;
|
|
279
|
+
/** Seal ID (for file uploads after seal creation) */
|
|
280
|
+
sealId?: string;
|
|
281
|
+
/** Room ID (for Vault uploads) */
|
|
282
|
+
roomId?: string;
|
|
283
|
+
/** TTL (for Seal creation) */
|
|
284
|
+
ttl?: string;
|
|
285
|
+
/** SHORT LINKS: SHA256 hash of client-generated owner_token (64 hex chars) */
|
|
286
|
+
ownerTokenHash?: string;
|
|
287
|
+
/** SHORT LINKS: Client-generated owner token for auth (P2/P5 fix) */
|
|
288
|
+
ownerToken?: string;
|
|
289
|
+
/**
|
|
290
|
+
* Where `appendMany` posts, as a path template containing `{sealId}`.
|
|
291
|
+
*
|
|
292
|
+
* §4.4: the protocol must not know which product is calling it. It used
|
|
293
|
+
* to choose between `/seal/{id}/append` and
|
|
294
|
+
* `/vault/org/seals/{id}/files/append` by looking at the auth kind — and
|
|
295
|
+
* the second route does not exist on any server; it is a rewrite rule in
|
|
296
|
+
* one web's `next.config.js`. A protocol that names a route its server
|
|
297
|
+
* never serves is a protocol guessing about its caller.
|
|
298
|
+
*
|
|
299
|
+
* Defaults to the anonymous seal route, which every SEAL client uses.
|
|
300
|
+
* A caller whose deployment maps append elsewhere passes its own.
|
|
301
|
+
*/
|
|
302
|
+
appendPath?: string;
|
|
303
|
+
/** Backend OTP / "Password". 6-10 chars (backend pattern). */
|
|
304
|
+
otp?: string;
|
|
305
|
+
/** Recipient must accept NDA before downloading. */
|
|
306
|
+
nda?: boolean;
|
|
307
|
+
/** Owner email (required if nda=true; backend Pydantic validator
|
|
308
|
+
* enforces). Used for NDA-PDF receipt delivery. */
|
|
309
|
+
ownerEmail?: string;
|
|
310
|
+
/** Single-use: first download deletes ciphertext. */
|
|
311
|
+
singleUse?: boolean;
|
|
312
|
+
/** Geo allowlist (ISO 3166-1 alpha-2 country codes). */
|
|
313
|
+
geo?: string[];
|
|
314
|
+
/** Max number of total downloads across all recipients. */
|
|
315
|
+
maxOpens?: number;
|
|
316
|
+
/**
|
|
317
|
+
* Paid tier for a seal above the free limit (`5gb` | `50gb` | `500gb`,
|
|
318
|
+
* SPEC-AGENTS §5.2). The seal is born unpaid: its uploads answer 402
|
|
319
|
+
* until the payment lands, and then `UploadSession.resume()` goes on.
|
|
320
|
+
*/
|
|
321
|
+
tier?: string;
|
|
322
|
+
/**
|
|
323
|
+
* fetch credentials mode for API calls. The seal.net web app relies on
|
|
324
|
+
* cookies for its OTP/NDA session, so `'include'` stays the default.
|
|
325
|
+
* The public send-only SDK passes `'omit'`: the wildcard-CORS send
|
|
326
|
+
* endpoints answer `Access-Control-Allow-Origin: *`, which the browser
|
|
327
|
+
* rejects for credentialed requests — send auth is the X-Owner-Token
|
|
328
|
+
* header, never a cookie.
|
|
329
|
+
*/
|
|
330
|
+
credentials?: RequestCredentials;
|
|
331
|
+
}
|
|
332
|
+
interface AuthConfig {
|
|
333
|
+
kind: 'none' | 'ownerToken' | 'bearer';
|
|
334
|
+
value?: string;
|
|
335
|
+
}
|
|
336
|
+
interface UploadPolicy {
|
|
337
|
+
/** Threshold for switching to multipart upload (default: 8 MiB = one fine part) */
|
|
338
|
+
multipartThresholdBytes: number;
|
|
339
|
+
/** Part size for multipart upload (default: 16MB) */
|
|
340
|
+
partSizeBytes: number;
|
|
341
|
+
/** Max size for in-memory ZIP (default: 100MB) */
|
|
342
|
+
zipSmallLimitBytes: number;
|
|
343
|
+
/**
|
|
344
|
+
* Overrides of the contract's transport policy. The one a product sets is
|
|
345
|
+
* `maxStallMs: Infinity`: a screen with a Cancel button would rather sit
|
|
346
|
+
* out an outage than lose the upload; a headless caller keeps the
|
|
347
|
+
* contract's ceiling so it cannot hang forever.
|
|
348
|
+
*/
|
|
349
|
+
transport?: Partial<TransportPolicy>;
|
|
350
|
+
}
|
|
351
|
+
/** Whether every file of a result was seen `verified` before it returned. */
|
|
352
|
+
type VerificationOutcome = 'verified' | 'pending';
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Internal Upload Engine
|
|
356
|
+
*
|
|
357
|
+
* The registration flows: what each entry point tells the server before any
|
|
358
|
+
* byte moves (`POST /seal` with one file, with several, with a folder's
|
|
359
|
+
* paths, or an append to an existing seal). That is the ONLY thing the flows
|
|
360
|
+
* differ in. Moving a registered file's bytes — the small-file-or-multipart
|
|
361
|
+
* choice, the transport, resume and cancel — is `./session`, once.
|
|
362
|
+
*
|
|
363
|
+
* @internal - NOT exported from SDK public API
|
|
364
|
+
*/
|
|
365
|
+
|
|
366
|
+
interface UploadCallbacks {
|
|
367
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
368
|
+
signal?: AbortSignal;
|
|
369
|
+
/**
|
|
370
|
+
* Awaited immediately after `POST /seal` returns an id, BEFORE any
|
|
371
|
+
* bytes are uploaded. Lets journaling callers (seal-mcp) persist the
|
|
372
|
+
* (seal_id, owner_token) pair so the seal can be compensated even if
|
|
373
|
+
* the rest of the upload fails or the process crashes mid-flight —
|
|
374
|
+
* without this the id only surfaces when the whole pipeline resolves,
|
|
375
|
+
* and a lost confirm response orphans a fully-live seal (DR-mcp).
|
|
376
|
+
* A rejection here aborts the upload; the caller owns compensation.
|
|
377
|
+
*/
|
|
378
|
+
onSealCreated?: (sealId: string) => void | Promise<void>;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* SHORT LINKS: Single file upload result
|
|
382
|
+
*
|
|
383
|
+
* NOTE: shareUrl and ownerToken are NOT returned here.
|
|
384
|
+
* They are constructed in use_seal_submit.ts from:
|
|
385
|
+
* - sealId (from response)
|
|
386
|
+
* - linkKey (client-generated)
|
|
387
|
+
* - ownerToken (client-generated, stored in encrypted blob)
|
|
388
|
+
*/
|
|
389
|
+
interface SingleFileUploadResult {
|
|
390
|
+
sealId: string;
|
|
391
|
+
fileId: string;
|
|
392
|
+
encryptionKey: Uint8Array<ArrayBuffer>;
|
|
393
|
+
sha256_hex: string;
|
|
394
|
+
/**
|
|
395
|
+
* Server-assigned expiry from the create-seal response, when the backend
|
|
396
|
+
* sends one. The server is the only authority here: it may clamp the
|
|
397
|
+
* requested TTL (plan limits, policy), and the client's clock may be
|
|
398
|
+
* skewed — a caller that recomputes `now + ttl` locally reports a number
|
|
399
|
+
* the seal does not actually have. Optional because older/other backends
|
|
400
|
+
* may omit the field; callers fall back to their own estimate and should
|
|
401
|
+
* say so.
|
|
402
|
+
*/
|
|
403
|
+
expiresAt?: string;
|
|
404
|
+
/**
|
|
405
|
+
* `verified`: the server re-hashed every file before this returned — the
|
|
406
|
+
* link can be handed out. `pending`: the bytes were accepted but
|
|
407
|
+
* verification outlasted the wait; a recipient is not offered a file
|
|
408
|
+
* until it passes.
|
|
409
|
+
*/
|
|
410
|
+
verification: VerificationOutcome;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Upload a single file into a new seal.
|
|
414
|
+
*
|
|
415
|
+
* A file below the multipart threshold is encrypted FIRST, so its row is
|
|
416
|
+
* registered under the real ciphertext digest rather than a placeholder
|
|
417
|
+
* (the digest is what the signed `seal_created` audit line records). A
|
|
418
|
+
* larger file cannot know its digest before streaming and registers a
|
|
419
|
+
* placeholder, which `/multipart/complete` replaces.
|
|
420
|
+
*/
|
|
421
|
+
declare function uploadSingleFile(ctx: UploadContext, file: File, policy?: UploadPolicy, callbacks?: UploadCallbacks): Promise<SingleFileUploadResult>;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Out-of-band delivery of the share link to the human user. The link is
|
|
425
|
+
* the whole capability (key in the fragment); there is no password.
|
|
426
|
+
*
|
|
427
|
+
* Implements `shared/contracts/seal_mcp.v1.json::handoff_delivery`.
|
|
428
|
+
*
|
|
429
|
+
* # Why handoff at all?
|
|
430
|
+
*
|
|
431
|
+
* The MCP design principle is: **the model never holds capability by
|
|
432
|
+
* default**. If the share link were returned in the
|
|
433
|
+
* `seal_share` tool result, the model would have a copy in its context
|
|
434
|
+
* window — visible to any agent in the chain, exfiltrated by any
|
|
435
|
+
* `chat.history.export` tool, persisted in trace logs forever.
|
|
436
|
+
*
|
|
437
|
+
* Handoff routes the secret around the model entirely: into the OS
|
|
438
|
+
* clipboard (primary) or onto a `0o600` file on the same machine
|
|
439
|
+
* (fallback). The model only sees `{ handle, receipt, handoff_channel,
|
|
440
|
+
* handoff_file? }` — none of which lets it reconstruct the share.
|
|
441
|
+
*
|
|
442
|
+
* # Channels
|
|
443
|
+
*
|
|
444
|
+
* 1. **Clipboard** — `clipboardy.write(\`${url}\n\`)`.
|
|
445
|
+
* Works on macOS (`pbcopy`), Linux X11/Wayland (`xclip`/`xsel`/
|
|
446
|
+
* `wl-copy`), WSL (`clip.exe`), Windows (`clip`). Fails fast in
|
|
447
|
+
* headless SSH with no `DISPLAY`.
|
|
448
|
+
* 2. **File** — atomic `fs.open(path, 'wx', 0o600)` inside
|
|
449
|
+
* `${XDG_RUNTIME_DIR or os.tmpdir()}/seal-mcp/`, content
|
|
450
|
+
* `url\n` plain text (user `cat`s it). Best-effort
|
|
451
|
+
* `setTimeout fs.unlink` after `max(expire_ms, 1 hour)`.
|
|
452
|
+
*
|
|
453
|
+
* On total failure both channels return `handoff_both_channels_failed`
|
|
454
|
+
* — better than silently leaking the secret into the model's output.
|
|
455
|
+
*/
|
|
456
|
+
declare class HandoffBothChannelsFailedError extends Error {
|
|
457
|
+
readonly code = "handoff_both_channels_failed";
|
|
458
|
+
readonly clipboardError?: unknown;
|
|
459
|
+
readonly fileError?: unknown;
|
|
460
|
+
constructor(message: string, clipboardError?: unknown, fileError?: unknown);
|
|
461
|
+
}
|
|
462
|
+
type HandoffChannel = 'clipboard' | 'file';
|
|
463
|
+
interface HandoffPayload {
|
|
464
|
+
readonly handle: string;
|
|
465
|
+
readonly shareUrl: string;
|
|
466
|
+
readonly expireMs: number;
|
|
467
|
+
}
|
|
468
|
+
interface HandoffResult {
|
|
469
|
+
readonly channel: HandoffChannel;
|
|
470
|
+
readonly file?: string;
|
|
471
|
+
}
|
|
472
|
+
interface HandoffDeps {
|
|
473
|
+
readonly clipboardWrite?: (text: string) => Promise<void>;
|
|
474
|
+
readonly tmpdirOverride?: string;
|
|
475
|
+
readonly disableClipboard?: boolean;
|
|
476
|
+
readonly scheduleCleanup?: (path: string, delayMs: number) => void;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Deliver a share link out-of-band.
|
|
480
|
+
*
|
|
481
|
+
* Order: clipboard → file. On total failure throws
|
|
482
|
+
* `HandoffBothChannelsFailedError` and emits NO surface containing
|
|
483
|
+
* the secret — caller MUST NOT log either result. The model gets
|
|
484
|
+
* `{ channel, file? }` per the contract; never the URL.
|
|
485
|
+
*
|
|
486
|
+
* @throws HandoffBothChannelsFailedError if both channels fail.
|
|
487
|
+
*/
|
|
488
|
+
declare function performHandoff(payload: HandoffPayload, deps?: HandoffDeps): Promise<HandoffResult>;
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Encrypted state envelope for `~/.config/seal-mcp/state.json`.
|
|
492
|
+
*
|
|
493
|
+
* Wire format = `shared/contracts/seal_mcp.v1.json::state_envelope`:
|
|
494
|
+
*
|
|
495
|
+
* Argon2id(passphrase, salt) → 32-byte KEK
|
|
496
|
+
* AES-256-GCM(KEK, nonce, plaintext_json) → ciphertext || 16-byte tag
|
|
497
|
+
*
|
|
498
|
+
* Everything except the envelope metadata (algo params, salt, nonce)
|
|
499
|
+
* lives inside the AEAD ciphertext. Identity secret + handle owner-tokens
|
|
500
|
+
* + audit log are all owner-equivalent capability — encrypting only the
|
|
501
|
+
* identity and leaving tokens in plaintext would be a security illusion.
|
|
502
|
+
*
|
|
503
|
+
* No plaintext field is EVER persisted unencrypted: every save() goes
|
|
504
|
+
* through `encrypt()`, every load() through `decrypt()`. Atomic write
|
|
505
|
+
* via `fs.writeFile(tmp) + fs.rename(tmp, final)` so a crash mid-write
|
|
506
|
+
* leaves either the old envelope or the new one, never a half-written
|
|
507
|
+
* one.
|
|
508
|
+
*/
|
|
509
|
+
interface IdentityFields {
|
|
510
|
+
readonly x25519_secret_b64: string;
|
|
511
|
+
readonly x25519_public_b64: string;
|
|
512
|
+
}
|
|
513
|
+
interface HandleEntry {
|
|
514
|
+
readonly seal_id: string;
|
|
515
|
+
readonly owner_token_b64: string;
|
|
516
|
+
readonly created_at: string;
|
|
517
|
+
readonly mode: 'handoff' | 'forward';
|
|
518
|
+
readonly expires_at: string;
|
|
519
|
+
/**
|
|
520
|
+
* Set when `seal_revoke` succeeds. Keeping the row (rather than
|
|
521
|
+
* deleting it) is what makes revoke idempotent per
|
|
522
|
+
* `seal_mcp.v1.json::tools.seal_revoke.output`: a second call on the
|
|
523
|
+
* same handle returns `{revoked: true}` without re-hitting the
|
|
524
|
+
* backend. `seal_list` filters revoked handles unless
|
|
525
|
+
* `include_expired = true`, so the model can't accidentally probe
|
|
526
|
+
* them either.
|
|
527
|
+
*/
|
|
528
|
+
revoked_at?: string;
|
|
529
|
+
/**
|
|
530
|
+
* FINAL out-of-band delivery channel actually used (handoff mode
|
|
531
|
+
* only; ISSUE-0623 requires attempted + final channels to be
|
|
532
|
+
* durable). Attempted channels live on the `PendingOperation`
|
|
533
|
+
* while the op is in flight; the final one is recorded here at
|
|
534
|
+
* completion.
|
|
535
|
+
*/
|
|
536
|
+
handoff_channel?: 'clipboard' | 'file';
|
|
537
|
+
/**
|
|
538
|
+
* Backend base URL the seal was created against (DR-mcp: backend
|
|
539
|
+
* provenance). Revoke refuses to run against a DIFFERENT backend,
|
|
540
|
+
* because that backend's 404 would be misread as idempotent success
|
|
541
|
+
* while the seal stays live on its real authority. Absent on
|
|
542
|
+
* pre-provenance entries — those are treated as belonging to the
|
|
543
|
+
* currently configured backend (the only guess available).
|
|
544
|
+
*/
|
|
545
|
+
backend_url?: string;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Durable operation journal (ISSUE-0623).
|
|
549
|
+
*
|
|
550
|
+
* A `seal_share` writes a `PendingOperation` BEFORE its first remote
|
|
551
|
+
* effect and updates it at every step boundary, so a crash at any
|
|
552
|
+
* point leaves enough encrypted-at-rest context to either revoke an
|
|
553
|
+
* orphaned seal or adopt an already-delivered one at next startup
|
|
554
|
+
* (`recovery.ts`). Completion deletes the entry in the same envelope
|
|
555
|
+
* write that inserts the durable `HandleEntry` — an op is never in
|
|
556
|
+
* both maps.
|
|
557
|
+
*
|
|
558
|
+
* Step semantics:
|
|
559
|
+
* - `preparing` → journaled, upload NOT yet confirmed. No
|
|
560
|
+
* `seal_id`; nothing remotely addressable.
|
|
561
|
+
* - `awaiting_payment` → the seal is over 1 GB and unpaid (SPEC-AGENTS
|
|
562
|
+
* §5.2); what would finish it lives in memory only,
|
|
563
|
+
* so recovery revokes it like `uploaded`.
|
|
564
|
+
* - `uploaded` → ciphertext live on backend, URL has NOT been
|
|
565
|
+
* delivered anywhere. Safe (and correct) to
|
|
566
|
+
* revoke on recovery.
|
|
567
|
+
* - `delivering` → handoff attempt started; the URL may already
|
|
568
|
+
* sit in the user's clipboard / 0o600 file.
|
|
569
|
+
* Recovery must NOT revoke — it adopts the seal
|
|
570
|
+
* as a normal handle so the owner keeps control.
|
|
571
|
+
* - `compensating`→ a revoke compensation was attempted and failed
|
|
572
|
+
* (`compensation.outcome === 'failed'`); retried
|
|
573
|
+
* at next startup.
|
|
574
|
+
*/
|
|
575
|
+
interface PendingCompensation {
|
|
576
|
+
readonly action: 'revoke';
|
|
577
|
+
readonly outcome: 'failed';
|
|
578
|
+
readonly error: string;
|
|
579
|
+
readonly at: string;
|
|
580
|
+
}
|
|
581
|
+
type PendingStep = 'preparing' | 'awaiting_payment' | 'uploaded' | 'delivering' | 'compensating';
|
|
582
|
+
interface PendingOperation {
|
|
583
|
+
readonly tool: 'seal_share';
|
|
584
|
+
readonly mode: 'handoff' | 'forward';
|
|
585
|
+
readonly started_at: string;
|
|
586
|
+
readonly owner_token_b64: string;
|
|
587
|
+
readonly expires_at: string;
|
|
588
|
+
step: PendingStep;
|
|
589
|
+
seal_id?: string;
|
|
590
|
+
/** Channels `performHandoff` will try, in order (handoff mode only). */
|
|
591
|
+
handoff_channels_attempted?: ReadonlyArray<'clipboard' | 'file'>;
|
|
592
|
+
compensation?: PendingCompensation;
|
|
593
|
+
/**
|
|
594
|
+
* Backend base URL the operation runs against (DR-mcp: backend
|
|
595
|
+
* provenance). Recovery must not compensate against a different
|
|
596
|
+
* backend — see `HandleEntry.backend_url`.
|
|
597
|
+
*/
|
|
598
|
+
backend_url?: string;
|
|
599
|
+
}
|
|
600
|
+
interface AuditEntry {
|
|
601
|
+
readonly ts: string;
|
|
602
|
+
readonly tool: 'seal_share' | 'seal_request' | 'seal_open' | 'seal_list' | 'seal_revoke';
|
|
603
|
+
readonly handle: string | null;
|
|
604
|
+
/**
|
|
605
|
+
* Free-form contextual label per tool. Contract permits any string
|
|
606
|
+
* (`{ "type": ["string", "null"] }`) and the value carries
|
|
607
|
+
* tool-specific meaning:
|
|
608
|
+
* - seal_share → 'handoff' | 'forward'
|
|
609
|
+
* - seal_request → 'local' | 'link'
|
|
610
|
+
* - seal_open → 'metadata' | 'inline' | 'file'
|
|
611
|
+
* - seal_list → null
|
|
612
|
+
* - seal_revoke → original handle mode echoed back
|
|
613
|
+
*/
|
|
614
|
+
readonly mode: string | null;
|
|
615
|
+
}
|
|
616
|
+
interface StateView {
|
|
617
|
+
version: 1;
|
|
618
|
+
identity: IdentityFields;
|
|
619
|
+
handles: Record<string, HandleEntry>;
|
|
620
|
+
audit: AuditEntry[];
|
|
621
|
+
/**
|
|
622
|
+
* Durable operation journal keyed by handle (the handle is minted
|
|
623
|
+
* before the first remote effect, so it doubles as the op id).
|
|
624
|
+
* Absent in pre-ISSUE-0623 envelopes — `loadState` normalises to
|
|
625
|
+
* `{}` so old state files keep loading.
|
|
626
|
+
*/
|
|
627
|
+
pending: Record<string, PendingOperation>;
|
|
628
|
+
}
|
|
629
|
+
declare class StatePassphraseInvalid extends Error {
|
|
630
|
+
constructor();
|
|
631
|
+
}
|
|
632
|
+
declare class StateCorrupt extends Error {
|
|
633
|
+
constructor(reason: string);
|
|
634
|
+
}
|
|
635
|
+
declare function createEmptyState(identity: IdentityFields): StateView;
|
|
636
|
+
/**
|
|
637
|
+
* Decrypt and validate a state file. Throws:
|
|
638
|
+
* - `StatePassphraseInvalid` on AEAD tag failure (wrong passphrase
|
|
639
|
+
* or tampered file).
|
|
640
|
+
* - `StateCorrupt` on JSON / envelope / plaintext schema violations.
|
|
641
|
+
* - Node fs errors on missing file / permissions (let them propagate).
|
|
642
|
+
*/
|
|
643
|
+
declare function loadState(passphrase: string, statePath: string): Promise<StateView>;
|
|
644
|
+
/**
|
|
645
|
+
* Encrypt + atomically write a state file. Creates parent dir with
|
|
646
|
+
* 0o700 if missing. File is written 0o600. Pattern: write tmp, fsync,
|
|
647
|
+
* then rename — POSIX guarantees rename atomicity.
|
|
648
|
+
*
|
|
649
|
+
* A fresh 16-byte salt is generated on every save (overkill — Argon2id
|
|
650
|
+
* is non-adaptive — but cheap and follows defence-in-depth).
|
|
651
|
+
*/
|
|
652
|
+
declare function saveState(state: StateView, passphrase: string, statePath: string): Promise<void>;
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* X25519 identity for the MCP server (incoming targeted-share recipient).
|
|
656
|
+
*
|
|
657
|
+
* The keys come from `@seal/protocol` (`generateRecipientKeyPair`,
|
|
658
|
+
* `recipientPublicKey`), which derives them in the same core the Rust CLI's
|
|
659
|
+
* `seal keygen`, the SDK and `sealnet` use — byte-for-byte compatible by
|
|
660
|
+
* construction, not by spec.
|
|
661
|
+
*
|
|
662
|
+
* The `Identity` view is held only in process memory; it is reconstructed
|
|
663
|
+
* from `state.identity` on every server start. The serialised form lives
|
|
664
|
+
* inside the AES-256-GCM ciphertext of `state.ts`.
|
|
665
|
+
*/
|
|
666
|
+
|
|
667
|
+
interface Identity {
|
|
668
|
+
readonly secret: Uint8Array;
|
|
669
|
+
readonly public: Uint8Array;
|
|
670
|
+
}
|
|
671
|
+
/** A fresh X25519 keypair for this server. */
|
|
672
|
+
declare function generateIdentity(): Promise<Identity>;
|
|
673
|
+
/**
|
|
674
|
+
* Project an `Identity` into the serialised form that lives in
|
|
675
|
+
* `state.identity` (base64-encoded raw bytes; matches contract
|
|
676
|
+
* `shared/contracts/seal_mcp.v1.json::state_envelope.plaintext_schema.properties.identity`).
|
|
677
|
+
*/
|
|
678
|
+
declare function identityToFields(id: Identity): IdentityFields;
|
|
679
|
+
/**
|
|
680
|
+
* Reconstruct an `Identity` from `state.identity`. Validates both key
|
|
681
|
+
* lengths AND that `public` actually corresponds to `secret` — catches a
|
|
682
|
+
* corrupted / tampered state where the two fields drifted apart.
|
|
683
|
+
*/
|
|
684
|
+
declare function loadIdentity(fields: IdentityFields): Promise<Identity>;
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Exclusive lifetime lock for the seal-mcp state directory (ISSUE-0623).
|
|
688
|
+
*
|
|
689
|
+
* # Why
|
|
690
|
+
*
|
|
691
|
+
* `state.json` is a whole-envelope read-modify-write store: the server
|
|
692
|
+
* loads the decrypted view once at boot and every `persist()` re-encrypts
|
|
693
|
+
* and atomically replaces the file. Two MCP processes pointed at the same
|
|
694
|
+
* state dir (the DEFAULT for every MCP host on the machine — Cursor +
|
|
695
|
+
* Claude Desktop both spawn `seal-mcp serve`) would silently overwrite
|
|
696
|
+
* each other's handles and audit entries: last writer wins, the other
|
|
697
|
+
* process's owner tokens are gone forever.
|
|
698
|
+
*
|
|
699
|
+
* The fix is a single-writer discipline: exactly one process may hold
|
|
700
|
+
* the state open for writing for its lifetime. Second `serve` (or a
|
|
701
|
+
* concurrent `init`/`reset`) fails fast with a legible error instead of
|
|
702
|
+
* corrupting capability state.
|
|
703
|
+
*
|
|
704
|
+
* # Mechanism (DR-mcp: TOCTOU-hardened)
|
|
705
|
+
*
|
|
706
|
+
* Creation is atomic WITH content: the holder info JSON is written to a
|
|
707
|
+
* private temp file first, then `link(tmp, state.lock)` publishes it —
|
|
708
|
+
* `link` fails with `EEXIST` if the lock exists (atomic on POSIX and
|
|
709
|
+
* NTFS), and a contender can never observe a half-written lock from a
|
|
710
|
+
* healthy holder. (The previous `open('wx')`-then-write protocol had an
|
|
711
|
+
* empty-file window that a contender misread as a stale lock and
|
|
712
|
+
* unlinked, yielding two simultaneous owners.)
|
|
713
|
+
*
|
|
714
|
+
* The lock file holds `{ pid, hostname, acquired_at }` so a contender
|
|
715
|
+
* can detect a stale lock: if the recorded pid is no longer alive ON
|
|
716
|
+
* THE SAME HOST, the previous holder crashed without cleanup. Breaking
|
|
717
|
+
* a stale lock is also atomic: the contender `rename`s the lock aside
|
|
718
|
+
* to a private name — only ONE contender wins the rename — verifies the
|
|
719
|
+
* claimed file still describes the stale holder (renaming it back if a
|
|
720
|
+
* fresh holder slipped in between probe and rename), and only then
|
|
721
|
+
* creates its own lock. A lock from a different hostname (network
|
|
722
|
+
* volume) is never broken automatically — we cannot probe liveness
|
|
723
|
+
* across hosts.
|
|
724
|
+
*
|
|
725
|
+
* PID-recycling is the accepted residual risk: the probe window is
|
|
726
|
+
* milliseconds and the failure mode (refusing to start) is safe.
|
|
727
|
+
*
|
|
728
|
+
* Release verifies ownership: the lock is unlinked only if it still
|
|
729
|
+
* carries OUR pid+hostname, so a process can never release a lock a
|
|
730
|
+
* contender legitimately re-acquired. Same check runs in the
|
|
731
|
+
* best-effort sync unlink on `process.on('exit')`.
|
|
732
|
+
*/
|
|
733
|
+
declare const LOCK_FILE_NAME = "state.lock";
|
|
734
|
+
interface LockHolderInfo {
|
|
735
|
+
readonly pid: number;
|
|
736
|
+
readonly hostname: string;
|
|
737
|
+
readonly acquired_at: string;
|
|
738
|
+
}
|
|
739
|
+
declare class StateLockedError extends Error {
|
|
740
|
+
readonly code = "state_locked";
|
|
741
|
+
readonly holder: LockHolderInfo | null;
|
|
742
|
+
readonly lockPath: string;
|
|
743
|
+
constructor(lockPath: string, holder: LockHolderInfo | null);
|
|
744
|
+
}
|
|
745
|
+
interface StateLock {
|
|
746
|
+
readonly lockPath: string;
|
|
747
|
+
/** Idempotent. Removes the lock file (if still ours) and the process-exit hook. */
|
|
748
|
+
release: () => Promise<void>;
|
|
749
|
+
}
|
|
750
|
+
interface AcquireLockDeps {
|
|
751
|
+
/** Override liveness probe for tests. Default: `process.kill(pid, 0)`. */
|
|
752
|
+
readonly isPidAlive?: (pid: number) => boolean;
|
|
753
|
+
/** Override own pid for tests. */
|
|
754
|
+
readonly pid?: number;
|
|
755
|
+
/** Override hostname for tests. */
|
|
756
|
+
readonly host?: string;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Acquire the exclusive state-dir lock or throw `StateLockedError`.
|
|
760
|
+
*
|
|
761
|
+
* Creates `stateDir` (0o700) if missing so `init` can lock before the
|
|
762
|
+
* first `saveState`. Refuses to operate on a symlinked state dir and
|
|
763
|
+
* re-tightens permissions on a pre-existing one (DR-mcp: `mkdir` with
|
|
764
|
+
* `mode` does not repair an existing directory).
|
|
765
|
+
*/
|
|
766
|
+
declare function acquireStateLock(stateDir: string, deps?: AcquireLockDeps): Promise<StateLock>;
|
|
767
|
+
/**
|
|
768
|
+
* Read-only probe for `doctor`: report who (if anyone) holds the lock
|
|
769
|
+
* and whether it looks stale from this host's perspective.
|
|
770
|
+
*/
|
|
771
|
+
declare function probeStateLock(stateDir: string, deps?: AcquireLockDeps): Promise<{
|
|
772
|
+
held: boolean;
|
|
773
|
+
holder: LockHolderInfo | null;
|
|
774
|
+
stale: boolean;
|
|
775
|
+
}>;
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Startup recovery for the durable operation journal (ISSUE-0623).
|
|
779
|
+
*
|
|
780
|
+
* Runs once in `createServer()` after the state lock is held and the
|
|
781
|
+
* envelope is decrypted, BEFORE any tool can execute. Each surviving
|
|
782
|
+
* `state.pending` entry is a `seal_share` that crashed (or lost its
|
|
783
|
+
* process) mid-flight; the step recorded at the last checkpoint tells
|
|
784
|
+
* us exactly what is safe to do:
|
|
785
|
+
*
|
|
786
|
+
* - `preparing` — WITHOUT a seal_id: no remote effect ever
|
|
787
|
+
* confirmed. Nothing to compensate; drop the entry
|
|
788
|
+
* (backend GC owns incomplete multipart uploads).
|
|
789
|
+
* WITH a seal_id (journaled by the upload engine's
|
|
790
|
+
* onSealCreated hook): the seal is live — treat it
|
|
791
|
+
* like `uploaded` and revoke the orphan.
|
|
792
|
+
* - `uploaded` — ciphertext live, URL never delivered anywhere.
|
|
793
|
+
* A live seal whose link nobody holds is pure
|
|
794
|
+
* orphan storage: revoke it (awaited).
|
|
795
|
+
* - `awaiting_payment` — an unpaid seal over 1 GB; what would finish
|
|
796
|
+
* it died with the process. Revoked like `uploaded`
|
|
797
|
+
* (the revoke closes its payment page).
|
|
798
|
+
* - `delivering` — the handoff attempt started, so the URL may
|
|
799
|
+
* already sit in the user's clipboard / 0o600
|
|
800
|
+
* file. Revoking would kill a link the user may
|
|
801
|
+
* have sent already — instead ADOPT the op as a
|
|
802
|
+
* durable handle. The owner keeps control: it
|
|
803
|
+
* shows up in `seal_list` and can be revoked
|
|
804
|
+
* explicitly via `seal_revoke`.
|
|
805
|
+
* - `compensating`— an earlier awaited revoke failed and was
|
|
806
|
+
* journaled; retry it now.
|
|
807
|
+
*
|
|
808
|
+
* Revoke failures stay in the journal (`step='compensating'`, outcome
|
|
809
|
+
* recorded) and are retried at the next startup — compensation is
|
|
810
|
+
* durable, never fire-and-forget.
|
|
811
|
+
*/
|
|
812
|
+
|
|
813
|
+
/** Minimal client slice recovery needs — keeps tests trivial. */
|
|
814
|
+
interface RevokeClient {
|
|
815
|
+
/** Backend base URL this client talks to (provenance comparisons). */
|
|
816
|
+
readonly baseUrl: string;
|
|
817
|
+
revoke(sealId: string, ownerTokenB64: string): Promise<unknown>;
|
|
818
|
+
}
|
|
819
|
+
interface RecoveryReport {
|
|
820
|
+
/** `delivering` ops adopted as durable handles. */
|
|
821
|
+
readonly adopted: string[];
|
|
822
|
+
/** Orphan seals successfully revoked (`uploaded` / retry). */
|
|
823
|
+
readonly revoked: string[];
|
|
824
|
+
/** Revoke retries that failed again — kept in the journal. */
|
|
825
|
+
readonly stillFailing: string[];
|
|
826
|
+
/** `preparing` entries dropped (no remote effect to undo). */
|
|
827
|
+
readonly dropped: string[];
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Reconcile `view.pending` in place. Returns the report + whether the
|
|
831
|
+
* view changed (caller persists once at the end — a crash during
|
|
832
|
+
* recovery just means the same idempotent pass reruns next start).
|
|
833
|
+
*/
|
|
834
|
+
declare function recoverPendingOperations(view: StateView, client: RevokeClient, opts?: {
|
|
835
|
+
now?: () => Date;
|
|
836
|
+
log?: (line: string) => void;
|
|
837
|
+
}): Promise<{
|
|
838
|
+
report: RecoveryReport;
|
|
839
|
+
changed: boolean;
|
|
840
|
+
}>;
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* State-path resolver for `sealnet-mcp` (the directory keeps the name `seal-mcp`).
|
|
844
|
+
*
|
|
845
|
+
* Resolution priority (highest wins):
|
|
846
|
+
* 1. `--state-dir <path>` CLI flag (caller passes via `cliFlag`).
|
|
847
|
+
* 2. `SEAL_MCP_CONFIG_DIR` environment variable. Mirrors the Rust
|
|
848
|
+
* `seal-cli` (`seal/clients/core/src/config.rs::Config::dir`)
|
|
849
|
+
* so power users with multiple profiles get the same override
|
|
850
|
+
* knob across both clients.
|
|
851
|
+
* 3. OS-native default:
|
|
852
|
+
* - macOS: `~/Library/Application Support/seal-mcp`
|
|
853
|
+
* - Linux/*BSD: `$XDG_CONFIG_HOME ?? ~/.config/seal-mcp`
|
|
854
|
+
* - Windows: `$APPDATA/seal-mcp` (or `~/AppData/Roaming/seal-mcp`)
|
|
855
|
+
* Matches `env-paths` (Sindre Sorhus) without taking the dep.
|
|
856
|
+
*
|
|
857
|
+
* Pure function — does not touch the filesystem. Callers `mkdir` lazily
|
|
858
|
+
* via `state.saveState` which already enforces `0o700` on the dir and
|
|
859
|
+
* `0o600` on the envelope.
|
|
860
|
+
*/
|
|
861
|
+
declare const APP_NAME = "seal-mcp";
|
|
862
|
+
declare const STATE_FILE_NAME = "state.json";
|
|
863
|
+
type StateDirSource = 'flag' | 'env' | 'os-default';
|
|
864
|
+
interface PathResolution {
|
|
865
|
+
readonly dir: string;
|
|
866
|
+
readonly statePath: string;
|
|
867
|
+
readonly source: StateDirSource;
|
|
868
|
+
}
|
|
869
|
+
interface ResolveOptions {
|
|
870
|
+
/** Explicit `--state-dir <path>` from commander. Empty/undefined = skip. */
|
|
871
|
+
readonly cliFlag?: string;
|
|
872
|
+
/** Override process.env for tests. */
|
|
873
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
874
|
+
/** Override process.platform for tests (default current). */
|
|
875
|
+
readonly platform?: NodeJS.Platform;
|
|
876
|
+
/** Override os.homedir() for tests. */
|
|
877
|
+
readonly home?: string;
|
|
878
|
+
}
|
|
879
|
+
declare function resolveStateDir(opts?: ResolveOptions): PathResolution;
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Passphrase resolution + hidden TTY prompt for `seal-mcp`.
|
|
883
|
+
*
|
|
884
|
+
* Priority chain (per `shared/contracts/seal_mcp.v1.json::passphrase_delivery`):
|
|
885
|
+
*
|
|
886
|
+
* 1. OS keychain via `keytar` (service `'seal-mcp'`, account `'state'`).
|
|
887
|
+
* 2. `SEAL_MCP_PASSPHRASE` environment variable. Logs a warning to
|
|
888
|
+
* stderr because env vars are visible via `/proc/<pid>/environ`
|
|
889
|
+
* on Linux, `ps -E`/`procstat -e` on BSD, and via the process
|
|
890
|
+
* object on Windows.
|
|
891
|
+
* 3. Cleartext on disk — REFUSED. Listed only to document the
|
|
892
|
+
* refusal; the resolver has no codepath that reads from a file.
|
|
893
|
+
*
|
|
894
|
+
* `keytar` is loaded lazily (same pattern as `clipboardy` in
|
|
895
|
+
* `handoff.ts`) so headless environments lacking `libsecret-1.so.0`
|
|
896
|
+
* (Linux) can still fall back to the env var without crashing the CLI
|
|
897
|
+
* at import time.
|
|
898
|
+
*/
|
|
899
|
+
declare const KEYCHAIN_SERVICE: "seal-mcp";
|
|
900
|
+
declare const KEYCHAIN_ACCOUNT: "state";
|
|
901
|
+
declare const PASSPHRASE_ENV_VAR: "SEAL_MCP_PASSPHRASE";
|
|
902
|
+
type PassphraseSource = 'keychain' | 'env';
|
|
903
|
+
interface ResolvedPassphrase {
|
|
904
|
+
readonly passphrase: string;
|
|
905
|
+
readonly source: PassphraseSource;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Surface used by `passphrase.ts` — kept narrow on purpose so tests can
|
|
909
|
+
* stub keytar without pulling in the native module.
|
|
910
|
+
*/
|
|
911
|
+
interface KeytarLike {
|
|
912
|
+
getPassword(service: string, account: string): Promise<string | null>;
|
|
913
|
+
setPassword(service: string, account: string, password: string): Promise<void>;
|
|
914
|
+
deletePassword(service: string, account: string): Promise<boolean>;
|
|
915
|
+
}
|
|
916
|
+
declare class KeytarUnavailableError extends Error {
|
|
917
|
+
readonly code = "keytar_unavailable";
|
|
918
|
+
constructor(detail: string);
|
|
919
|
+
}
|
|
920
|
+
declare class PassphraseNotFoundError extends Error {
|
|
921
|
+
readonly code = "passphrase_not_found";
|
|
922
|
+
constructor();
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Try keychain first, then env var. NEVER reads from a file. Returns
|
|
926
|
+
* `null` if neither source has a passphrase — caller decides whether to
|
|
927
|
+
* prompt interactively (`init`) or hard-refuse (`serve`).
|
|
928
|
+
*/
|
|
929
|
+
declare function resolvePassphraseFromStores(env?: NodeJS.ProcessEnv): Promise<ResolvedPassphrase | null>;
|
|
930
|
+
/**
|
|
931
|
+
* Probe whether the OS keychain is usable AND whether the seal-mcp
|
|
932
|
+
* entry already exists. Used by `sealnet-mcp doctor`.
|
|
933
|
+
*/
|
|
934
|
+
declare function probeKeychain(): Promise<{
|
|
935
|
+
available: boolean;
|
|
936
|
+
hasEntry: boolean;
|
|
937
|
+
}>;
|
|
938
|
+
declare function setKeychainPassphrase(passphrase: string): Promise<void>;
|
|
939
|
+
declare function deleteKeychainPassphrase(): Promise<boolean>;
|
|
940
|
+
/**
|
|
941
|
+
* Prompt for a passphrase on the TTY without echoing keystrokes.
|
|
942
|
+
*
|
|
943
|
+
* Requires `process.stdin.isTTY === true` — caller must verify ahead
|
|
944
|
+
* of time and route non-TTY callers to the env-var path.
|
|
945
|
+
*
|
|
946
|
+
* Special keys:
|
|
947
|
+
* - `\r` / `\n` → resolve with the accumulated buffer.
|
|
948
|
+
* - `\u0003` (Ctrl-C) → reject with `cancelled`.
|
|
949
|
+
* - `\u0004` (Ctrl-D) on empty buffer → reject with `cancelled`.
|
|
950
|
+
* - `\u007f` / `\b` (Backspace) → delete last char.
|
|
951
|
+
*/
|
|
952
|
+
declare function promptHiddenPassphrase(prompt: string): Promise<string>;
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Shared `ToolContext` and helpers used by every tool implementation.
|
|
956
|
+
*
|
|
957
|
+
* Each tool is a pure async function `(ctx, input) → output` so the
|
|
958
|
+
* `server.ts` bootstrap can wire tools to the MCP SDK by registering
|
|
959
|
+
* arrow-functions that close over a single `ToolContext` instance.
|
|
960
|
+
* Keeps the tool implementations test-friendly (inject a fake
|
|
961
|
+
* context) and prevents accidental global state.
|
|
962
|
+
*/
|
|
963
|
+
|
|
964
|
+
/** Mutable, single-source-of-truth state passed across tool calls. */
|
|
965
|
+
interface ToolContext {
|
|
966
|
+
readonly state: StateView;
|
|
967
|
+
readonly identity: Identity;
|
|
968
|
+
readonly client: S;
|
|
969
|
+
/**
|
|
970
|
+
* Persistence hook. The server is responsible for serialising and
|
|
971
|
+
* encrypting `ctx.state` to disk after a successful mutation; tools
|
|
972
|
+
* mutate `ctx.state` in-place and call `await ctx.persist()` when
|
|
973
|
+
* ready. Errors propagate to the tool which surfaces them as a
|
|
974
|
+
* structured MCP error to the model.
|
|
975
|
+
*/
|
|
976
|
+
persist: () => Promise<void>;
|
|
977
|
+
/** No keychain, nothing on disk: handles die with the process (receipt says so). */
|
|
978
|
+
readonly ephemeral?: boolean;
|
|
979
|
+
/** `seal_request` handles waiting on a link, to their intake id; in memory only. */
|
|
980
|
+
readonly requests?: Map<string, string>;
|
|
981
|
+
/**
|
|
982
|
+
* `seal_share` handles waiting for a payment (SPEC-AGENTS §5.2), to what
|
|
983
|
+
* finishes them; in memory only — a restart revokes the unpaid seal
|
|
984
|
+
* (`recovery.ts`, step `awaiting_payment`).
|
|
985
|
+
*/
|
|
986
|
+
readonly payments?: Map<string, PendingPayment>;
|
|
987
|
+
/** UTC-now hook for deterministic testing. */
|
|
988
|
+
now?: () => Date;
|
|
989
|
+
}
|
|
990
|
+
/** A share stopped on 402: `finish` waits for the payment and completes it. */
|
|
991
|
+
interface PendingPayment {
|
|
992
|
+
readonly finish: (onProgress?: (done: number, total: number) => void) => Promise<unknown>;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Generate an opaque handle of the form `h_<12 base64url chars>`,
|
|
996
|
+
* matching `seal_mcp.v1.json::output_handoff.handle.pattern`.
|
|
997
|
+
*
|
|
998
|
+
* 9 random bytes = 72 bits of entropy. With ≤10^6 handles in flight
|
|
999
|
+
* the birthday-collision probability is ≈ 2·10^-11 — well below the
|
|
1000
|
+
* MCP server's effective lifetime.
|
|
1001
|
+
*/
|
|
1002
|
+
declare function generateHandle(): string;
|
|
1003
|
+
/** Validate a handle against the contract pattern. */
|
|
1004
|
+
declare function isValidHandle(handle: string): boolean;
|
|
1005
|
+
/**
|
|
1006
|
+
* Tool-side error type. The server converts these into structured
|
|
1007
|
+
* MCP error responses with `code` as the error symbol.
|
|
1008
|
+
*/
|
|
1009
|
+
declare class ToolError extends Error {
|
|
1010
|
+
readonly code: string;
|
|
1011
|
+
readonly cause?: unknown;
|
|
1012
|
+
constructor(code: string, message: string, cause?: unknown);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* `seal_list` — return the model-visible view of locally-tracked
|
|
1017
|
+
* handles. Pure local state read; no backend round-trip required.
|
|
1018
|
+
*
|
|
1019
|
+
* Output shape and field names track
|
|
1020
|
+
* `shared/contracts/seal_mcp.v1.json::tools.seal_list.output` exactly
|
|
1021
|
+
* (FINDINGS Bug #5 fix, 2026-05-26). Pre-fix history:
|
|
1022
|
+
*
|
|
1023
|
+
* - Pre-Bug-#5 wire: `{ entries: [{ handle, createdAt, expiresAt,
|
|
1024
|
+
* mode, revokedAt? }] }`. snake_vs_camel + missing `status` +
|
|
1025
|
+
* missing `file_count` + extra `expiresAt`/`revokedAt`. Any host
|
|
1026
|
+
* validating against the published contract would have rejected
|
|
1027
|
+
* it; golden phase 3.6b ("strictly matches the v1 contract shape")
|
|
1028
|
+
* surfaced this divergence.
|
|
1029
|
+
*
|
|
1030
|
+
* Why wrap in `{entries:[]}` instead of returning a bare array (which
|
|
1031
|
+
* the original contract said): MCP's `CallToolResult.structuredContent`
|
|
1032
|
+
* is typed `Record<string, unknown>` and a validating host will reject
|
|
1033
|
+
* a top-level array there. The contract was updated to match this
|
|
1034
|
+
* MCP-level constraint in the same patch as this file.
|
|
1035
|
+
*
|
|
1036
|
+
* The expired/revoked filter still excludes "dead" handles by default
|
|
1037
|
+
* so the model doesn't waste turns trying to `seal_open` /
|
|
1038
|
+
* `seal_revoke` them. Revoke on an already-revoked handle is
|
|
1039
|
+
* idempotent per `seal_revoke`, but listing it is just noise.
|
|
1040
|
+
*/
|
|
1041
|
+
|
|
1042
|
+
interface SealListInput {
|
|
1043
|
+
/** If true, include entries with status 'expired' or 'revoked'; defaults false. */
|
|
1044
|
+
readonly includeExpired?: boolean;
|
|
1045
|
+
}
|
|
1046
|
+
type SealListStatus = 'active' | 'expired' | 'revoked';
|
|
1047
|
+
/**
|
|
1048
|
+
* One row in `seal_list.output.entries`. Field set and casing must
|
|
1049
|
+
* stay aligned with `seal_mcp.v1.json::seal_list.output.entries.items`
|
|
1050
|
+
* — that schema has `additionalProperties: false`, so adding a field
|
|
1051
|
+
* here without bumping the contract will be rejected by validating
|
|
1052
|
+
* MCP hosts.
|
|
1053
|
+
*/
|
|
1054
|
+
interface SealListEntry {
|
|
1055
|
+
readonly handle: string;
|
|
1056
|
+
readonly created_at: string;
|
|
1057
|
+
readonly status: SealListStatus;
|
|
1058
|
+
/**
|
|
1059
|
+
* Always `1` today: `seal_share` accepts a single `path`. The
|
|
1060
|
+
* contract reserves the field so a future multi-file `seal_share`
|
|
1061
|
+
* doesn't need a v2 bump.
|
|
1062
|
+
*/
|
|
1063
|
+
readonly file_count: number;
|
|
1064
|
+
readonly mode: 'handoff' | 'forward';
|
|
1065
|
+
}
|
|
1066
|
+
interface SealListOutput {
|
|
1067
|
+
readonly entries: SealListEntry[];
|
|
1068
|
+
}
|
|
1069
|
+
declare function sealList(ctx: ToolContext, input?: SealListInput): Promise<SealListOutput>;
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* `seal_open` — read a SEAL link: describe it, return small text inline, or
|
|
1073
|
+
* stream every file it carries to disk.
|
|
1074
|
+
*
|
|
1075
|
+
* Reading the link is the protocol's (`openShareLink`: parse, unwrap a
|
|
1076
|
+
* targeted fragment with THIS server's X25519 identity, public metadata,
|
|
1077
|
+
* share blob, file names) — the same code the SDK opens links with (И4). What
|
|
1078
|
+
* stays here is this tool's policy:
|
|
1079
|
+
*
|
|
1080
|
+
* Metadata (default): the first file's name, MIME and size, the file count,
|
|
1081
|
+
* the seal's flags. No download, no counter tick.
|
|
1082
|
+
*
|
|
1083
|
+
* Inline: refused WITHOUT a download — so a refusal never burns
|
|
1084
|
+
* `single_use`/`max_opens` — when the MIME is not JSON/CSV/plain text
|
|
1085
|
+
* (HTML/PDF/XHTML carry prompt-injection risk, binary cannot be sanitised),
|
|
1086
|
+
* when the sender marked it a secret (`inline_blocked.secret`, И2), or when
|
|
1087
|
+
* the ciphertext exceeds `INLINE_CIPHERTEXT_MAX_BYTES`. Otherwise one
|
|
1088
|
+
* download, decrypted through `decryptVerified` (ciphertext digest checked
|
|
1089
|
+
* against the one registered at upload, ISSUE-0606), a plaintext cap, and a
|
|
1090
|
+
* fatal UTF-8 decode.
|
|
1091
|
+
*
|
|
1092
|
+
* File (SPEC-AGENTS §5.1): every file the link carries a key for, streamed to
|
|
1093
|
+
* disk by `@seal/protocol/node` — the name given only after the digest
|
|
1094
|
+
* matched, 0600, memory one segment whatever the size. The model gets paths,
|
|
1095
|
+
* sizes and plaintext digests, never content. Each download counts against
|
|
1096
|
+
* the seal like a browser's.
|
|
1097
|
+
*/
|
|
1098
|
+
|
|
1099
|
+
interface SealOpenInput {
|
|
1100
|
+
readonly url: string;
|
|
1101
|
+
readonly mode?: 'metadata' | 'inline' | 'file';
|
|
1102
|
+
/** `file` mode: directory to write into; default `~/Downloads`, created if missing. */
|
|
1103
|
+
readonly dir?: string;
|
|
1104
|
+
/** `file` mode: ciphertext bytes received so far, out of the seal's total. */
|
|
1105
|
+
readonly onProgress?: (done: number, total: number) => void;
|
|
1106
|
+
}
|
|
1107
|
+
/** One file of the seal, written to disk (`mode: 'file'`). */
|
|
1108
|
+
interface SealOpenFileEntry {
|
|
1109
|
+
readonly path: string;
|
|
1110
|
+
readonly filename: string;
|
|
1111
|
+
readonly sizeBytes: number;
|
|
1112
|
+
readonly mime: string;
|
|
1113
|
+
readonly sha256: string;
|
|
1114
|
+
}
|
|
1115
|
+
interface SealOpenFileOutput {
|
|
1116
|
+
readonly handle: string;
|
|
1117
|
+
readonly files: readonly SealOpenFileEntry[];
|
|
1118
|
+
}
|
|
1119
|
+
interface SealOpenMetadataOutput {
|
|
1120
|
+
readonly handle: string;
|
|
1121
|
+
readonly filename: string;
|
|
1122
|
+
readonly sizeBytes: number;
|
|
1123
|
+
readonly mime: string;
|
|
1124
|
+
readonly fileCount: number;
|
|
1125
|
+
readonly ownerEmailHash: string | null;
|
|
1126
|
+
readonly geoRestriction: string | null;
|
|
1127
|
+
readonly singleUse: boolean;
|
|
1128
|
+
readonly maxReads: number;
|
|
1129
|
+
readonly expiresAt: string;
|
|
1130
|
+
}
|
|
1131
|
+
interface SealOpenInlineRefusal {
|
|
1132
|
+
readonly handle: string;
|
|
1133
|
+
readonly refusal: 'inline_blocked.binary' | 'inline_blocked.injection_risk' | 'inline_blocked.too_large' | 'inline_blocked.secret';
|
|
1134
|
+
readonly mime: string | null;
|
|
1135
|
+
readonly note: string;
|
|
1136
|
+
}
|
|
1137
|
+
/** Successful inline body — the model gets the decrypted UTF-8 text. */
|
|
1138
|
+
interface SealOpenInlineSuccess {
|
|
1139
|
+
readonly handle: string;
|
|
1140
|
+
readonly mime: 'application/json' | 'text/csv' | 'text/plain';
|
|
1141
|
+
readonly sizeBytes: number;
|
|
1142
|
+
readonly encoding: 'utf-8';
|
|
1143
|
+
readonly content: string;
|
|
1144
|
+
}
|
|
1145
|
+
type SealOpenOutput = SealOpenMetadataOutput | SealOpenInlineRefusal | SealOpenInlineSuccess | SealOpenFileOutput;
|
|
1146
|
+
declare function sealOpen(ctx: ToolContext, input: SealOpenInput): Promise<SealOpenOutput>;
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* `seal_revoke` — invalidate a SEAL by opaque handle.
|
|
1150
|
+
*
|
|
1151
|
+
* The model never learns the raw `seal_id` or `owner_token`; we look
|
|
1152
|
+
* them up from the handle row in `state.handles`. Idempotent:
|
|
1153
|
+
* revoking an already-revoked / already-expired handle returns
|
|
1154
|
+
* `{revoked: true}` without error, per
|
|
1155
|
+
* `seal_mcp.v1.json::tools.seal_revoke.output`.
|
|
1156
|
+
*/
|
|
1157
|
+
|
|
1158
|
+
interface SealRevokeInput {
|
|
1159
|
+
readonly handle: string;
|
|
1160
|
+
}
|
|
1161
|
+
interface SealRevokeOutput {
|
|
1162
|
+
readonly revoked: boolean;
|
|
1163
|
+
readonly revokedAt: string;
|
|
1164
|
+
}
|
|
1165
|
+
declare function sealRevoke(ctx: ToolContext, input: SealRevokeInput): Promise<SealRevokeOutput>;
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* `seal_share` — create a new SEAL from a local file or folder and deliver the
|
|
1169
|
+
* share link (key in its fragment, no separate password) out-of-band
|
|
1170
|
+
* (handoff) or back to the model (forward, targeted recipient only,
|
|
1171
|
+
* hard policy gate).
|
|
1172
|
+
*
|
|
1173
|
+
* # Modes
|
|
1174
|
+
*
|
|
1175
|
+
* **`handoff` (default)** — URL delivered via clipboard or 0o600 file.
|
|
1176
|
+
* Model receives only an opaque `handle` and a receipt. Anonymous_v1
|
|
1177
|
+
* fragment (raw link_key in URL).
|
|
1178
|
+
*
|
|
1179
|
+
* **`forward` (opt-in, Phase 3.5d)** — URL returned in the tool
|
|
1180
|
+
* response so the model can paste it into chat. Hard policy:
|
|
1181
|
+
* - `to` REQUIRED (X25519 recipient pubkey, 32 B base64url-no-pad)
|
|
1182
|
+
* - `expire ≤ 30m` (enforced client-side; backend also caps at
|
|
1183
|
+
* `MAX_FORWARD_EXPIRE_MINUTES`)
|
|
1184
|
+
* - `max_reads = 1` (locked — any other value rejected)
|
|
1185
|
+
* Wire format: `targeted_x25519_v1` (see
|
|
1186
|
+
* `shared/contracts/seal_share_url.v1.json`). Fragment built by
|
|
1187
|
+
* `@seal/protocol::buildTargetedFragment` using the same WASM
|
|
1188
|
+
* `wasm_sealbox_wrap` as the Rust CLI's `seal send --to`.
|
|
1189
|
+
*
|
|
1190
|
+
* # Pipeline
|
|
1191
|
+
*
|
|
1192
|
+
* 1. **forward only**: validate `to` + `expire ≤ 30m` + `max_reads = 1`.
|
|
1193
|
+
* Validation happens BEFORE upload so a bad request doesn't
|
|
1194
|
+
* leak an orphaned ciphertext on the backend.
|
|
1195
|
+
* 2. Open the path lazily as a `globalThis.File` (`openSource`): a
|
|
1196
|
+
* file as it is, a folder as one ZIP streamed into a private 0600
|
|
1197
|
+
* temp file (`zip.ts`, SPEC-AGENTS §5.1) and removed when the call
|
|
1198
|
+
* ends. Above the free limit the seal is created with a tier and
|
|
1199
|
+
* paid for (`awaitPayment`, §5.2); more than the tier holds is
|
|
1200
|
+
* refused before upload. Without `expire`, a handoff over 100 MiB
|
|
1201
|
+
* lives 1d instead of 5m (contract `expire_default`).
|
|
1202
|
+
* 3. Generate ownerToken (32 B) + ownerTokenHash via the bundled e2ee primitives.
|
|
1203
|
+
* 4. **Journal (ISSUE-0623)**: persist `state.pending[handle] =
|
|
1204
|
+
* { owner_token, mode, step: 'preparing', … }` BEFORE the first
|
|
1205
|
+
* remote effect. Every step below updates the journal; every
|
|
1206
|
+
* post-upload failure runs an AWAITED revoke compensation whose
|
|
1207
|
+
* outcome is recorded (and retried at startup by `recovery.ts`
|
|
1208
|
+
* if it fails). Completion removes the journal entry in the same
|
|
1209
|
+
* envelope write that inserts the durable handle.
|
|
1210
|
+
* 5. `uploadSingleFile(...)` — full AEON streaming upload via the
|
|
1211
|
+
* multipart protocol; identical wire format to the browser
|
|
1212
|
+
* (Phase 2.4 unified WASM init across runtimes). Journal step →
|
|
1213
|
+
* `uploaded` (seal_id recorded — full revoke capability durable).
|
|
1214
|
+
* 6. Generate a separate `shareLinkKey` and encrypt a share-blob
|
|
1215
|
+
* containing `{ file_keys: { sha256_hex: file_key_b64 } }`.
|
|
1216
|
+
* 7. `POST /seal/{id}/share` with the encrypted blob → server
|
|
1217
|
+
* allocates `share_code`.
|
|
1218
|
+
* 8. Build URL fragment:
|
|
1219
|
+
* - **handoff**: `base64url(shareLinkKey)` (anonymous_v1)
|
|
1220
|
+
* - **forward**: `x25519:` || `base64url(packed)` (targeted_x25519_v1)
|
|
1221
|
+
* 9. Deliver:
|
|
1222
|
+
* - **handoff**: journal step → `delivering` (+ attempted
|
|
1223
|
+
* channels), then `performHandoff` (clipboard / 0o600 file)
|
|
1224
|
+
* - **forward**: return `share_url` in the tool response (model gets it)
|
|
1225
|
+
* 10. Persist `state.handles[handle] = { seal_id, owner_token, mode,
|
|
1226
|
+
* expires_at, created_at, handoff_channel? }` + drop the journal
|
|
1227
|
+
* entry (single atomic write).
|
|
1228
|
+
*
|
|
1229
|
+
* # Zero-knowledge invariants enforced
|
|
1230
|
+
* - `handle` returned to the model contains no capability material.
|
|
1231
|
+
* - The share blob is opaque to the backend (zero-knowledge).
|
|
1232
|
+
* - **handoff mode**: URL never appears in the tool result.
|
|
1233
|
+
* - **forward mode**: URL IS in the tool result (this is the point);
|
|
1234
|
+
* for `targeted_x25519_v1` URLs the URL alone is NOT a usable
|
|
1235
|
+
* capability without the recipient's identity secret, so leaking
|
|
1236
|
+
* the URL (e.g. via chat history) does not compromise content.
|
|
1237
|
+
*/
|
|
1238
|
+
|
|
1239
|
+
type SealShareMode = 'handoff' | 'forward';
|
|
1240
|
+
interface SealShareInput {
|
|
1241
|
+
/** Required unless `payment` is given. */
|
|
1242
|
+
readonly path?: string;
|
|
1243
|
+
/** Over 1 GB: the tier to pay for; default the smallest that fits. */
|
|
1244
|
+
readonly tier?: string;
|
|
1245
|
+
/** Handle of a share waiting for its payment: wait for it and finish. */
|
|
1246
|
+
readonly payment?: string;
|
|
1247
|
+
readonly mode?: SealShareMode;
|
|
1248
|
+
readonly expire?: string;
|
|
1249
|
+
readonly maxReads?: number;
|
|
1250
|
+
readonly burn?: boolean;
|
|
1251
|
+
readonly to?: string;
|
|
1252
|
+
/**
|
|
1253
|
+
* Explicit MIME override. Defaults to extension-based detection via
|
|
1254
|
+
* `guessMimeFromName`; unknown extensions get
|
|
1255
|
+
* `application/octet-stream`. Encrypted into the per-file metadata
|
|
1256
|
+
* bundle (zero-knowledge to backend).
|
|
1257
|
+
*/
|
|
1258
|
+
readonly mime?: string;
|
|
1259
|
+
/** Ciphertext bytes storage has confirmed, out of the upload's total. */
|
|
1260
|
+
readonly onProgress?: (done: number, total: number) => void;
|
|
1261
|
+
}
|
|
1262
|
+
interface SealShareHandoffOutput {
|
|
1263
|
+
readonly handle: string;
|
|
1264
|
+
readonly receipt: string;
|
|
1265
|
+
readonly handoffChannel: 'clipboard' | 'file';
|
|
1266
|
+
readonly handoffFile?: string;
|
|
1267
|
+
readonly expiresAt: string;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Forward-mode output (contract: `seal_mcp.v1.json::seal_share.output_forward`).
|
|
1271
|
+
* Includes `shareUrl` — model receives the targeted-X25519 URL. The
|
|
1272
|
+
* URL alone is not a usable capability without the recipient's
|
|
1273
|
+
* identity secret. `password` is omitted because the targeted_x25519_v1
|
|
1274
|
+
* fragment carries the wrapped key inline (zero-knowledge to backend).
|
|
1275
|
+
*/
|
|
1276
|
+
interface SealShareForwardOutput {
|
|
1277
|
+
readonly handle: string;
|
|
1278
|
+
readonly shareUrl: string;
|
|
1279
|
+
readonly expiresAt: string;
|
|
1280
|
+
readonly maxReads: 1;
|
|
1281
|
+
readonly mode: 'forward';
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Over 1 GB without URL-mode elicitation (contract `seal_share.output_payment`):
|
|
1285
|
+
* the model shows `paymentUrl` and calls again with `payment: handle`.
|
|
1286
|
+
*/
|
|
1287
|
+
interface SealSharePaymentOutput {
|
|
1288
|
+
readonly handle: string;
|
|
1289
|
+
readonly paymentUrl: string;
|
|
1290
|
+
readonly tier: string;
|
|
1291
|
+
/** Payment deadline; the seal is revoked after it. */
|
|
1292
|
+
readonly expiresAt: string;
|
|
1293
|
+
readonly receipt: string;
|
|
1294
|
+
}
|
|
1295
|
+
type SealShareOutput = SealShareHandoffOutput | SealShareForwardOutput | SealSharePaymentOutput;
|
|
1296
|
+
interface SealShareDeps {
|
|
1297
|
+
readonly handoff?: HandoffDeps;
|
|
1298
|
+
/**
|
|
1299
|
+
* Override the public origin share URLs are built on. Default is the
|
|
1300
|
+
* `config.ts` resolver chain (`SEAL_MCP_PUBLIC_HOST` env →
|
|
1301
|
+
* `https://seal.net`) — NOT derived from the backend URL, which in
|
|
1302
|
+
* production is the separate `https://api.seal.net` origin serving
|
|
1303
|
+
* no frontend (ISSUE-0622).
|
|
1304
|
+
*/
|
|
1305
|
+
readonly publicHost?: string;
|
|
1306
|
+
/**
|
|
1307
|
+
* Inject the upload pipeline. Default: the bundled `uploadSingleFile`
|
|
1308
|
+
* streaming uploader. Tests substitute a fixture that returns a
|
|
1309
|
+
* canned `UploadResult` so they don't need a live backend.
|
|
1310
|
+
* Production code must NOT pass this — the default is the
|
|
1311
|
+
* cross-stack-verified streaming uploader.
|
|
1312
|
+
*/
|
|
1313
|
+
readonly uploadSingleFile?: typeof uploadSingleFile;
|
|
1314
|
+
/** URL-mode elicitation for the payment page, when the client supports it. */
|
|
1315
|
+
readonly elicitUrl?: (params: {
|
|
1316
|
+
url: string;
|
|
1317
|
+
message: string;
|
|
1318
|
+
elicitationId: string;
|
|
1319
|
+
}) => Promise<string>;
|
|
1320
|
+
/** Tell the client the out-of-band part is done (`notifications/elicitation/complete`). */
|
|
1321
|
+
readonly elicitationDone?: (elicitationId: string) => Promise<void>;
|
|
1322
|
+
}
|
|
1323
|
+
declare function sealShare(ctx: ToolContext, input: SealShareInput, deps?: SealShareDeps): Promise<SealShareOutput>;
|
|
1324
|
+
|
|
1325
|
+
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 };
|