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.js CHANGED
@@ -6,7 +6,6 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/server.ts
9
- import { dirname as dirname3 } from "path";
10
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
11
 
@@ -4066,13 +4065,13 @@ var seal_mcp_v1_default = {
4066
4065
  progress: "When a `tools/call` carries `_meta.progressToken`, `seal_share` and `seal_open mode=file` send `notifications/progress` with `progress` = ciphertext bytes done (upload: confirmed by storage) and `total` = ciphertext bytes in all; at most one per second, `progress` strictly increasing, the last one at `progress = total`."
4067
4066
  },
4068
4067
  server_instructions: {
4069
- text: "SEAL moves files and secrets between people and agents without their content entering this conversation. When someone wants a file, folder or secret sent to them, to another machine or to another agent, call seal_share with its path and do not open, print or Read it first. When you need an API key, password, file or folder from the user, or they offer or ask how to give you one, call seal_request right away instead of explaining other ways or asking them to paste or upload it. Open a SEAL link someone gave you with seal_open (mode=file for secrets and large files).",
4068
+ text: "SEAL moves files and secrets between people and agents without their content entering this conversation. When someone wants a file, folder or secret sent to them, to another machine or to another agent, call seal_share with its path and do not open, print or Read it first. When you need an API key, password, file or folder from the user, or they offer or ask how to give you one, call seal_request right away instead of explaining other ways or asking them to paste or upload it. Open a SEAL link someone gave you with seal_open (mode=file for secrets and large files). Never read or print a SEAL handoff file.",
4070
4069
  note: "The `instructions` of the `initialize` result (SPEC-AGENTS \xA78.1). Hosts that defer MCP tools behind a tool search (Claude Code) show the model only tool names until it searches, but put these instructions into the system prompt: this is the one text a model reads before it decides to look for a tool, or reads the file it was asked to send."
4071
4070
  },
4072
4071
  tools: {
4073
4072
  seal_share: {
4074
4073
  title: "Share a file via SEAL",
4075
- description: "Give the user or another agent a file or a secret WITHOUT it passing through this chat. Works for any size (folders, dumps, exports, builds, videos \u2014 up to 1 GB free, more after one card payment by the user). Default mode `handoff`: the link goes to the user's clipboard or a 0600 file and you get only a handle. Mode `forward`: a link only the holder of the given X25519 key can open \u2014 use it for another agent. Never paste secrets or huge content into the chat instead.",
4074
+ description: "Give the user or another agent a file or a secret WITHOUT it passing through this chat. Works for large files (folders, dumps, exports, builds, videos \u2014 up to 1 GB free, more after one card payment by the user). Default mode `handoff`: the link goes to the user's clipboard and you get a handle. Where there is no clipboard it goes to a 0600 file and you get its path: do not read that file and do not print it. Mode `forward`: a link only the holder of the given X25519 key can open \u2014 use it for another agent. Never paste secrets or huge content into the chat instead.",
4076
4075
  annotations: { destructiveHint: false, openWorldHint: true },
4077
4076
  input: {
4078
4077
  properties: {
@@ -4116,13 +4115,15 @@ var seal_mcp_v1_default = {
4116
4115
  required: ["handle", "receipt", "handoff_channel"],
4117
4116
  properties: {
4118
4117
  handle: { type: "string", pattern: "^h_[A-Za-z0-9_-]{12,}$", description: "Opaque server-internal ID. Used by `seal_revoke` and other tools. 12+ base64url chars after the `h_` prefix \u2014 the alphabet includes `-` and `_` (generateHandle emits base64url), so the pattern must too." },
4119
- receipt: { type: "string", description: "Plain-text status the model should relay verbatim to the user. e.g. 'The seal link is on your clipboard. Paste it to the recipient.' In ephemeral mode (see `passphrase_delivery`) it adds that the handle lives until the server restarts and the seal's expiry does not depend on it." },
4118
+ receipt: { type: "string", description: "Plain-text status the model should relay verbatim to the user. e.g. 'The seal link is on your clipboard. Paste it to the recipient.' In ephemeral mode (see `root_secret.ephemeral_mode`) it adds that the handle lives until the server restarts and the seal's expiry does not depend on it." },
4120
4119
  handoff_channel: { type: "string", enum: ["clipboard", "file"] },
4121
4120
  handoff_file: { type: "string", description: "ONLY when handoff_channel='file'. Absolute path to a 0o600 file containing the share link and a newline." },
4122
4121
  expires_at: { type: "string", format: "date-time", description: "ISO 8601 UTC expiry timestamp." }
4123
4122
  },
4124
4123
  MUST_NOT_contain: ["share_url", "url", "password", "passphrase", "link_key", "owner_token"],
4125
- rationale: "Anything in this object reaches the model. Any field that lets the model reconstruct or re-issue the share capability defeats handoff mode."
4124
+ rationale: "Anything in this object reaches the model. Any field that lets the model reconstruct or re-issue the share capability defeats handoff mode.",
4125
+ receipt_clipboard: "The seal link is on your clipboard. Paste it to the recipient.",
4126
+ receipt_file: "The seal link is in {file} (mode 0600, removed automatically later). Give it to the recipient. Anyone who can read files on this machine as this user, this agent included, can read it. In a cloud or container session prefer mode=forward to the recipient\u2019s key."
4126
4127
  },
4127
4128
  output_forward: {
4128
4129
  type: "object",
@@ -4450,7 +4451,7 @@ var seal_mcp_v1_default = {
4450
4451
  type: "object",
4451
4452
  required: ["x25519_secret_b64", "x25519_public_b64"],
4452
4453
  properties: {
4453
- x25519_secret_b64: { type: "string", description: "32 raw bytes, base64-padded. Generated once, at `sealnet-mcp init` or at the first `sealnet-mcp serve` without state. Lost passphrase = lost identity = unable to open incoming targeted seals; handoff continues to work without identity." },
4454
+ x25519_secret_b64: { type: "string", description: "32 raw bytes, base64url. The seed of `root_secret`: must equal it, else the server refuses with `identity_conflict`. Lost seed = lost address = incoming targeted seals unopenable; handoff continues to work without identity." },
4454
4455
  x25519_public_b64: { type: "string", description: "32 raw bytes, derived from secret. Safe to publish; advertised so other parties can target this MCP." }
4455
4456
  }
4456
4457
  },
@@ -4471,7 +4472,7 @@ var seal_mcp_v1_default = {
4471
4472
  },
4472
4473
  pending: {
4473
4474
  type: "object",
4474
- description: "Durable operation journal (ISSUE-0623), keyed by handle. A seal_share journals BEFORE its first remote effect and checkpoints every step; completion removes the entry in the same envelope write that inserts the durable handle. OPTIONAL: envelopes written before the journal existed omit it and readers normalise to {}. Recovery semantics at server start: 'preparing' (no confirmed remote effect) is dropped; 'uploaded'/'awaiting_payment'/'compensating' (URL never delivered) is revoked, AWAITED \u2014 an unpaid seal cannot be finished after a restart, and a payment that lands after the revoke is refunded by the server; 'delivering' (URL possibly already delivered) is ADOPTED as a durable handle \u2014 never revoked, so the owner keeps control of a link the user may already hold.",
4475
+ description: "Durable operation journal (ISSUE-0623), keyed by handle. A seal_share journals BEFORE its first remote effect and checkpoints every step; completion removes the entry in the same envelope write that inserts the durable handle. OPTIONAL: envelopes written before the journal existed omit it and readers normalise to {}. Recovery semantics at server start, for entries whose `owner` cannot still be running (several MCP hosts share one state, SPEC-ADDRESS F1 \xA74): 'preparing' (no confirmed remote effect) is dropped; 'uploaded'/'awaiting_payment'/'compensating' (URL never delivered) is revoked, AWAITED \u2014 an unpaid seal cannot be finished after a restart, and a payment that lands after the revoke is refunded by the server; 'delivering' (URL possibly already delivered) is ADOPTED as a durable handle \u2014 never revoked, so the owner keeps control of a link the user may already hold.",
4475
4476
  additionalProperties: {
4476
4477
  type: "object",
4477
4478
  required: ["tool", "mode", "started_at", "owner_token_b64", "expires_at", "step"],
@@ -4484,6 +4485,12 @@ var seal_mcp_v1_default = {
4484
4485
  step: { type: "string", enum: ["preparing", "awaiting_payment", "uploaded", "delivering", "compensating"] },
4485
4486
  seal_id: { type: "string", pattern: "^[A-Za-z0-9_-]{6,20}$", description: "Set once the upload confirms; (seal_id, owner_token_b64) is the full revoke capability." },
4486
4487
  handoff_channels_attempted: { type: "array", items: { type: "string", enum: ["clipboard", "file"] }, description: "Channels performHandoff will try, in order (handoff mode only; journaled before delivery starts)." },
4488
+ owner: {
4489
+ type: "object",
4490
+ required: ["pid", "hostname"],
4491
+ properties: { pid: { type: "integer" }, hostname: { type: "string" } },
4492
+ description: "The process running the operation. Recovery leaves an entry alone while that process may still run: alive on this host, or on another host, which cannot be probed (the state lock's rule). Entries without it, from 0.2.x, are recovered as crashed."
4493
+ },
4487
4494
  compensation: {
4488
4495
  type: "object",
4489
4496
  required: ["action", "outcome", "error", "at"],
@@ -4515,27 +4522,20 @@ var seal_mcp_v1_default = {
4515
4522
  }
4516
4523
  }
4517
4524
  },
4518
- passphrase_delivery: {
4519
- ordered_priority: [
4520
- {
4521
- rank: 1,
4522
- method: "os_keychain",
4523
- implementation: "@napi-rs/keyring (the Secret Service on Linux, never the kernel keyring, which forgets on reboot; macOS Keychain; Windows Credential Manager), an OPTIONAL dependency loaded lazily: its absence never fails install or start. It replaced keytar in 0.2.4 and finds the entry keytar wrote (Windows credential `seal-mcp/state`, the passphrase as UTF-8 bytes; on Linux the old entry is read once through `secret-tool` and stored anew). Service id: 'seal-mcp', account: 'state'. Set during `sealnet-mcp init` (chosen by the user), or by the first `sealnet-mcp serve` without state, which generates a random 32-byte passphrase itself \u2014 no prompt, no `init` step. Read on every subsequent process start.",
4524
- default: true
4525
- },
4526
- {
4527
- rank: 2,
4528
- method: "env_var",
4529
- implementation: "$SEAL_MCP_PASSPHRASE. Visible to same-user attackers via /proc/<pid>/environ on Linux. Warning logged on stderr at startup. Use only in CI / headless containers."
4530
- },
4531
- {
4532
- rank: 3,
4533
- method: "REFUSED",
4534
- implementation: "Cleartext passphrase file on disk is explicitly refused even if user passes `--passphrase-file \u2026`. Error message points at options 1 and 2."
4535
- }
4525
+ root_secret: {
4526
+ seed: "The agent's one root secret (SPEC-ADDRESS F1): a 32-byte X25519 secret, as text 43 base64url characters \u2014 the same bytes as `state.identity.x25519_secret_b64`, the CLI's `identity.v1.json::x25519_secret_b64url`, the SDK's `KeyPair.secretKey` and sealnet's `keygen().secret_key`. Its public half is the agent's address; it must survive a restart, a second MCP host on the machine and a move to the cloud.",
4527
+ ordered_sources: [
4528
+ { rank: 1, source: "env", implementation: "$SEAL_SEED. How a container or a cloud session gets the same address as at home." },
4529
+ { rank: 2, source: "keychain", implementation: "@napi-rs/keyring (the Secret Service on Linux, never the kernel keyring, which forgets on reboot; macOS Keychain; Windows Credential Manager), an OPTIONAL dependency loaded lazily: its absence never fails install or start. Service 'seal-mcp', account 'seed', the seed as UTF-8 bytes." },
4530
+ { rank: 3, source: "file", implementation: "`<state_dir>/seed`, mode 0600 in the 0700 state directory, created on the first start where there is no keychain. The CLI's threat model (`secrets.rs`): a process running as this user reads it either way, keychain or file." }
4536
4531
  ],
4537
- ephemeral_mode: "First `serve` without state where neither the keychain nor SEAL_MCP_PASSPHRASE is available (container, CI), or `serve --ephemeral`: identity, handles, journal and audit live only in process memory and nothing is written to disk (rank 3 stays refused). The `seal_share` receipt says the handle lives until the server restarts and the seal's expiry does not depend on it.",
4538
- lost_passphrase_recovery: "Passphrase is NOT recoverable. `sealnet-mcp init --force` mints a new identity, but old targeted seals (addressed to the old pubkey) become permanently unopenable. Handoff seals are not affected (they don't use identity)."
4532
+ created: "The first `serve` (or `init`) with no seed in any source makes one and keeps it in the keychain, or in the file where there is none. `init` asks nothing.",
4533
+ state_passphrase: "base64url(HKDF-SHA256(ikm = seed, salt = 'seal-mcp:state:v1', info = 'passphrase', 32 bytes)). The state envelope (Argon2id \u2192 AES-256-GCM) is unchanged; only where its passphrase comes from changed. One root secret instead of a passphrase and a key.",
4534
+ identity_conflict: "`state.identity` must be the seed. A state that opens neither with the seed's passphrase nor with a migration passphrase belongs to another key (a foreign SEAL_SEED): `serve` refuses with `identity_conflict`, exit 3. Unset SEAL_SEED, or `sealnet-mcp reset --force`. The address never changes silently.",
4535
+ migration_0_2: "Once, when no seed exists in any source and a state does: open it with the old passphrase (keychain entry 'seal-mcp'/'state', including the Linux entry keytar wrote, or $SEAL_MCP_PASSPHRASE), take the seed from `state.identity`, store it (keychain 'seed', else the file), save the same state under the seed's passphrase (atomic, as every save), delete the old keychain entry. $SEAL_MCP_PASSPHRASE is read only here and warns that it is no longer used. Removed in the first minor release after no 0.2.x state is left, the condition `readKeytarLinuxEntry` carries.",
4536
+ ephemeral_mode: "Only `serve --ephemeral` or $SEAL_MCP_EPHEMERAL=1: a new key each start; identity, handles, journal and audit live only in process memory, nothing is written to disk. The `seal_share` receipt says the handle lives until the server restarts and the seal's expiry does not depend on it.",
4537
+ several_hosts: "Every MCP host of one user on one machine (Claude Desktop, Claude Code, Cursor) runs its own `serve` on the same state: one seed, one address. The state lock is taken per write, not for a process lifetime: each change re-reads `state.json`, applies this process's insertions and explicit deletions and saves. Waiting for the lock ends after 5 s with `state_busy`.",
4538
+ lost_seed: "The seed is the address: lost (`reset --force`, a wiped keychain with no file) means links addressed to the old key stay unopenable and paired owners must pair again; handoff seals are unaffected. `sealnet-mcp seed --force` prints it, to move the agent by setting SEAL_SEED elsewhere."
4539
4539
  },
4540
4540
  handoff_delivery: {
4541
4541
  primary_channel: "system_clipboard",
@@ -4611,11 +4611,12 @@ var seal_mcp_v1_default = {
4611
4611
  pro_file_not_granted: "seal_pro_file_get: the room's seal is visible but a file's key envelope is refused to this agent.",
4612
4612
  pro_failed: "seal_pro_*: SEAL Pro answered with another error; the message has the status.",
4613
4613
  state_persist_failed: "seal_share: the journal checkpoint (`uploaded` or `delivering`) could not be made durable. The fresh seal is auto-revoked (compensation outcome embedded in the message) because delivering a capability the journal cannot account for is worse than failing the call.",
4614
+ state_busy: "Another sealnet-mcp process held the state lock for more than 5 s. Nothing was changed by this call; safe to retry.",
4614
4615
  runtime_unsupported: "Node runtime is missing a global the bundle assumes (e.g. `globalThis.File` or `globalThis.Blob`). Upgrade to Node \u226520.",
4615
4616
  internal_inconsistency: "Server detected a state branch the type-system thought was unreachable (e.g. forward gate passed but recipientPub is null). Always a bug \u2014 caller should report.",
4616
- "state.passphrase_invalid": "AES-256-GCM tag check failed. Likely wrong passphrase in keychain / env; could be tampered file. Server refuses to start.",
4617
+ identity_conflict: "Startup: the state does not open with this seed's passphrase nor with a migration passphrase \u2014 it belongs to another key (a foreign SEAL_SEED), or it was tampered with. Server refuses to start (exit 3); see `root_secret.identity_conflict`.",
4617
4618
  "state.corrupt": "Envelope JSON malformed or version unknown. Server refuses to start; user must rerun `sealnet-mcp init --force` (lose old data) or restore from backup.",
4618
- state_dir_unsafe: "Startup: the state directory is a symlink or not a real directory \u2014 the lifetime lock and the envelope it protects would resolve to different places. Server refuses to start.",
4619
+ state_dir_unsafe: "The state directory is a symlink or not a real directory \u2014 the state lock and the envelope it protects would resolve to different places. Refused.",
4619
4620
  base_url_invalid: "Startup/config: backend or public-host base URL is not an absolute http(s) URL, or carries a query string / fragment / embedded credentials. Every derived URL would be corrupt, so the value is rejected at configuration time."
4620
4621
  },
4621
4622
  current_format_version: 1,
@@ -11439,7 +11440,7 @@ function safeFilename(name) {
11439
11440
  return cleaned.length > 0 ? cleaned : "seal-file";
11440
11441
  }
11441
11442
  async function receiveToDisk(opened, dir, name) {
11442
- const fs2 = await import(
11443
+ const fs3 = await import(
11443
11444
  /* webpackIgnore: true */
11444
11445
  /* @vite-ignore */
11445
11446
  "fs/promises"
@@ -11449,10 +11450,10 @@ async function receiveToDisk(opened, dir, name) {
11449
11450
  /* @vite-ignore */
11450
11451
  "path"
11451
11452
  );
11452
- await fs2.mkdir(dir, { recursive: true });
11453
+ await fs3.mkdir(dir, { recursive: true });
11453
11454
  const suffix = Array.from(crypto.getRandomValues(new Uint8Array(6)), (b) => b.toString(16).padStart(2, "0")).join("");
11454
11455
  const tmp = path.join(dir, `.seal-partial-${suffix}`);
11455
- const fh = await fs2.open(tmp, "wx", 384);
11456
+ const fh = await fs3.open(tmp, "wx", 384);
11456
11457
  let placed = false;
11457
11458
  try {
11458
11459
  await opened.stream.pipeTo(
@@ -11470,12 +11471,12 @@ async function receiveToDisk(opened, dir, name) {
11470
11471
  for (let i = 0; i < 1e3; i++) {
11471
11472
  const candidate = path.join(dir, i === 0 ? name : `${stem} (${i})${ext}`);
11472
11473
  try {
11473
- await fs2.link(tmp, candidate);
11474
+ await fs3.link(tmp, candidate);
11474
11475
  } catch (err2) {
11475
11476
  if (err2.code === "EEXIST") continue;
11476
11477
  throw err2;
11477
11478
  }
11478
- await fs2.unlink(tmp);
11479
+ await fs3.unlink(tmp);
11479
11480
  placed = true;
11480
11481
  return { path: candidate, sizeBytes, sha256: sha2562 };
11481
11482
  }
@@ -11483,7 +11484,7 @@ async function receiveToDisk(opened, dir, name) {
11483
11484
  } finally {
11484
11485
  if (!placed) {
11485
11486
  await fh.close().catch(() => void 0);
11486
- await fs2.unlink(tmp).catch(() => void 0);
11487
+ await fs3.unlink(tmp).catch(() => void 0);
11487
11488
  }
11488
11489
  }
11489
11490
  }
@@ -11921,6 +11922,9 @@ async function generateIdentity() {
11921
11922
  const { publicKey, secretKey } = await generateRecipientKeyPair();
11922
11923
  return { secret: secretKey, public: publicKey };
11923
11924
  }
11925
+ async function identityFromSeed(seed) {
11926
+ return { secret: seed, public: await recipientPublicKey(seed) };
11927
+ }
11924
11928
  function identityToFields(id) {
11925
11929
  return {
11926
11930
  x25519_secret_b64: toB64(id.secret),
@@ -11954,20 +11958,27 @@ import { randomBytes } from "crypto";
11954
11958
  import { hostname } from "os";
11955
11959
  import { join as join2 } from "path";
11956
11960
  var LOCK_FILE_NAME = "state.lock";
11961
+ var LOCK_WAIT_MS = 5e3;
11962
+ var LOCK_RETRY_MS = 25;
11957
11963
  var StateLockedError = class extends Error {
11958
11964
  code = "state_locked";
11959
11965
  holder;
11960
11966
  lockPath;
11961
11967
  constructor(lockPath, holder) {
11962
11968
  const who = holder === null ? "holder info unreadable" : `pid ${holder.pid} on ${holder.hostname} since ${holder.acquired_at}`;
11963
- super(
11964
- `state directory is locked by another sealnet-mcp process (${who}). Only one writer may use a state dir at a time; stop the other instance or point this one at a different --state-dir. Lock file: ${lockPath}`
11965
- );
11969
+ super(`state directory is locked by another sealnet-mcp process (${who}). Lock file: ${lockPath}`);
11966
11970
  this.name = "StateLockedError";
11967
11971
  this.holder = holder;
11968
11972
  this.lockPath = lockPath;
11969
11973
  }
11970
11974
  };
11975
+ var StateBusyError = class extends Error {
11976
+ code = "state_busy";
11977
+ constructor(locked) {
11978
+ super(`${locked.message}; waited ${LOCK_WAIT_MS / 1e3} s, nothing was changed, safe to retry`);
11979
+ this.name = "StateBusyError";
11980
+ }
11981
+ };
11971
11982
  var StateDirUnsafeError = class extends Error {
11972
11983
  code = "state_dir_unsafe";
11973
11984
  constructor(stateDir, reason) {
@@ -11986,6 +11997,9 @@ function defaultIsPidAlive(pid) {
11986
11997
  return code === "EPERM";
11987
11998
  }
11988
11999
  }
12000
+ function holderMayRun(holder, deps = {}) {
12001
+ return holder.hostname !== (deps.host ?? hostname()) || (deps.isPidAlive ?? defaultIsPidAlive)(holder.pid);
12002
+ }
11989
12003
  function parseHolder(raw) {
11990
12004
  try {
11991
12005
  const parsed = JSON.parse(raw);
@@ -12028,7 +12042,7 @@ async function tryCreate(lockPath, info) {
12028
12042
  await unlink(tmp).catch(() => void 0);
12029
12043
  }
12030
12044
  }
12031
- async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
12045
+ async function tryBreakStale(lockPath, probed, deps) {
12032
12046
  const claimPath = `${lockPath}.break-${process.pid}-${randomBytes(4).toString("hex")}`;
12033
12047
  try {
12034
12048
  await rename2(lockPath, claimPath);
@@ -12036,8 +12050,7 @@ async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
12036
12050
  return true;
12037
12051
  }
12038
12052
  const claimed = parseHolder(await readFile2(claimPath, "utf8").catch(() => ""));
12039
- const claimedStale = claimed === null || claimed.hostname === selfHost && !isAlive(claimed.pid);
12040
- if (sameHolder(claimed, probed) || claimedStale) {
12053
+ if (sameHolder(claimed, probed) || claimed === null || !holderMayRun(claimed, deps)) {
12041
12054
  await unlink(claimPath).catch(() => void 0);
12042
12055
  return true;
12043
12056
  }
@@ -12050,11 +12063,9 @@ async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
12050
12063
  }
12051
12064
  async function acquireStateLock(stateDir, deps = {}) {
12052
12065
  const lockPath = join2(stateDir, LOCK_FILE_NAME);
12053
- const isAlive = deps.isPidAlive ?? defaultIsPidAlive;
12054
- const selfHost = deps.host ?? hostname();
12055
12066
  const self2 = {
12056
12067
  pid: deps.pid ?? process.pid,
12057
- hostname: selfHost,
12068
+ hostname: deps.host ?? hostname(),
12058
12069
  acquired_at: (/* @__PURE__ */ new Date()).toISOString()
12059
12070
  };
12060
12071
  await mkdir2(stateDir, { recursive: true, mode: 448 });
@@ -12065,11 +12076,10 @@ async function acquireStateLock(stateDir, deps = {}) {
12065
12076
  await chmod(stateDir, 448).catch(() => void 0);
12066
12077
  if (!await tryCreate(lockPath, self2)) {
12067
12078
  const holder = await readHolder(lockPath);
12068
- const stale = holder !== null && holder.hostname === selfHost && !isAlive(holder.pid);
12069
- if (holder !== null && !stale) {
12079
+ if (holder !== null && holderMayRun(holder, deps)) {
12070
12080
  throw new StateLockedError(lockPath, holder);
12071
12081
  }
12072
- if (!await tryBreakStale(lockPath, holder, isAlive, selfHost)) {
12082
+ if (!await tryBreakStale(lockPath, holder, deps)) {
12073
12083
  throw new StateLockedError(lockPath, await readHolder(lockPath));
12074
12084
  }
12075
12085
  if (!await tryCreate(lockPath, self2)) {
@@ -12101,6 +12111,30 @@ async function acquireStateLock(stateDir, deps = {}) {
12101
12111
  }
12102
12112
  };
12103
12113
  }
12114
+ var queues = /* @__PURE__ */ new Map();
12115
+ function withStateLock(stateDir, fn, deps = {}) {
12116
+ const run = (queues.get(stateDir) ?? Promise.resolve()).catch(() => void 0).then(async () => {
12117
+ const deadline = Date.now() + LOCK_WAIT_MS;
12118
+ let lock;
12119
+ for (; ; ) {
12120
+ try {
12121
+ lock = await acquireStateLock(stateDir, deps);
12122
+ break;
12123
+ } catch (err2) {
12124
+ if (!(err2 instanceof StateLockedError)) throw err2;
12125
+ if (Date.now() >= deadline) throw new StateBusyError(err2);
12126
+ await new Promise((resolve5) => setTimeout(resolve5, LOCK_RETRY_MS));
12127
+ }
12128
+ }
12129
+ try {
12130
+ return await fn();
12131
+ } finally {
12132
+ await lock.release();
12133
+ }
12134
+ });
12135
+ queues.set(stateDir, run);
12136
+ return run;
12137
+ }
12104
12138
  async function probeStateLock(stateDir, deps = {}) {
12105
12139
  const lockPath = join2(stateDir, LOCK_FILE_NAME);
12106
12140
  const holder = await readHolder(lockPath);
@@ -12112,10 +12146,7 @@ async function probeStateLock(stateDir, deps = {}) {
12112
12146
  return { held: false, holder: null, stale: false };
12113
12147
  }
12114
12148
  }
12115
- const isAlive = deps.isPidAlive ?? defaultIsPidAlive;
12116
- const sameHost = holder.hostname === (deps.host ?? hostname());
12117
- const stale = sameHost && !isAlive(holder.pid);
12118
- return { held: true, holder, stale };
12149
+ return { held: true, holder, stale: !holderMayRun(holder, deps) };
12119
12150
  }
12120
12151
 
12121
12152
  // src/recovery.ts
@@ -12132,6 +12163,10 @@ async function recoverPendingOperations(view, client, opts = {}) {
12132
12163
  log4(`[seal-mcp] recovery: skipping unknown pending op tool=${String(op.tool)} handle=${handle}`);
12133
12164
  continue;
12134
12165
  }
12166
+ if (op.owner !== void 0 && op.owner.pid !== process.pid && holderMayRun(op.owner)) {
12167
+ log4(`[seal-mcp] recovery: left ${handle} alone \u2014 pid ${op.owner.pid} on ${op.owner.hostname} may still run it`);
12168
+ continue;
12169
+ }
12135
12170
  if (op.seal_id === void 0) {
12136
12171
  delete view.pending[handle];
12137
12172
  report.dropped.push(handle);
@@ -12221,7 +12256,7 @@ var AES_KEY_BYTES = 32;
12221
12256
  var AES_NONCE_BYTES = 12;
12222
12257
  var StatePassphraseInvalid = class extends Error {
12223
12258
  constructor() {
12224
- super("state.passphrase_invalid: AES-256-GCM tag check failed");
12259
+ super("AES-256-GCM tag check failed: another passphrase, or a tampered file");
12225
12260
  this.name = "StatePassphraseInvalid";
12226
12261
  }
12227
12262
  };
@@ -12421,6 +12456,13 @@ async function saveState(state, passphrase, statePath) {
12421
12456
  await fs.chmod(dir, 448).catch(() => {
12422
12457
  });
12423
12458
  }
12459
+ function updateState(passphrase, statePath, mutate) {
12460
+ return withStateLock(dirname2(statePath), async () => {
12461
+ const view = await loadState(passphrase, statePath);
12462
+ if (await mutate(view) !== false) await saveState(view, passphrase, statePath);
12463
+ return view;
12464
+ });
12465
+ }
12424
12466
 
12425
12467
  // src/tools/context.ts
12426
12468
  import { randomBytes as randomBytes3 } from "crypto";
@@ -12438,19 +12480,18 @@ var HANDLE_REGEX = /^h_[A-Za-z0-9_-]{12,}$/;
12438
12480
  function isValidHandle(handle) {
12439
12481
  return typeof handle === "string" && HANDLE_REGEX.test(handle);
12440
12482
  }
12483
+ function auditEntry(ctx, tool, handle, mode) {
12484
+ return { ts: (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString(), tool, handle, mode };
12485
+ }
12441
12486
  function appendAudit(ctx, tool, handle, mode) {
12442
- ctx.state.audit.push({
12443
- ts: (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
12444
- tool,
12445
- handle,
12446
- mode
12447
- });
12487
+ const entry = auditEntry(ctx, tool, handle, mode);
12488
+ return ctx.update((state) => void state.audit.push(entry));
12448
12489
  }
12449
- function insertHandle(ctx, handle, entry) {
12450
- if (ctx.state.handles[handle]) {
12490
+ function insertHandle(state, handle, entry) {
12491
+ if (state.handles[handle]) {
12451
12492
  throw new Error(`insertHandle: collision on ${handle}`);
12452
12493
  }
12453
- ctx.state.handles[handle] = entry;
12494
+ state.handles[handle] = entry;
12454
12495
  }
12455
12496
  function lookupHandle(ctx, handle) {
12456
12497
  const entry = ctx.state.handles[handle];
@@ -12473,6 +12514,7 @@ var ToolError = class extends Error {
12473
12514
  // src/tools/list.ts
12474
12515
  var SINGLE_FILE_COUNT = 1;
12475
12516
  async function sealList(ctx, input = {}) {
12517
+ await appendAudit(ctx, "seal_list", null, null);
12476
12518
  const now = (ctx.now?.() ?? /* @__PURE__ */ new Date()).getTime();
12477
12519
  const entries = [];
12478
12520
  for (const [handle, entry] of Object.entries(ctx.state.handles)) {
@@ -12489,8 +12531,6 @@ async function sealList(ctx, input = {}) {
12489
12531
  });
12490
12532
  }
12491
12533
  entries.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
12492
- appendAudit(ctx, "seal_list", null, null);
12493
- await ctx.persist();
12494
12534
  return { entries };
12495
12535
  }
12496
12536
 
@@ -12575,13 +12615,11 @@ async function sealOpen(ctx, input) {
12575
12615
  if (mode === "inline") {
12576
12616
  const refusal = classifyInlineRefusal(first.mime);
12577
12617
  if (refusal !== null) {
12578
- appendAudit(ctx, "seal_open", handle, mode);
12579
- await ctx.persist();
12618
+ await appendAudit(ctx, "seal_open", handle, mode);
12580
12619
  return { handle, refusal, mime: first.mime, note: refusalNote(refusal, first.mime) };
12581
12620
  }
12582
12621
  if (first.size > INLINE_CIPHERTEXT_MAX_BYTES) {
12583
- appendAudit(ctx, "seal_open", handle, mode);
12584
- await ctx.persist();
12622
+ await appendAudit(ctx, "seal_open", handle, mode);
12585
12623
  return {
12586
12624
  handle,
12587
12625
  refusal: "inline_blocked.too_large",
@@ -12617,12 +12655,10 @@ async function sealOpen(ctx, input) {
12617
12655
  err2
12618
12656
  );
12619
12657
  }
12620
- appendAudit(ctx, "seal_open", handle, mode);
12621
- await ctx.persist();
12658
+ await appendAudit(ctx, "seal_open", handle, mode);
12622
12659
  return { handle, mime: bareMime(first.mime), sizeBytes: plaintext.byteLength, encoding: "utf-8", content };
12623
12660
  }
12624
- appendAudit(ctx, "seal_open", handle, mode);
12625
- await ctx.persist();
12661
+ await appendAudit(ctx, "seal_open", handle, mode);
12626
12662
  return {
12627
12663
  handle,
12628
12664
  filename: first.name,
@@ -12668,8 +12704,7 @@ async function openToDisk(ctx, seal, handle, input) {
12668
12704
  throw streamError(err2, file.id);
12669
12705
  }
12670
12706
  }
12671
- appendAudit(ctx, "seal_open", handle, "file");
12672
- await ctx.persist();
12707
+ await appendAudit(ctx, "seal_open", handle, "file");
12673
12708
  return { handle, files: out };
12674
12709
  }
12675
12710
 
@@ -12772,8 +12807,7 @@ async function sealRequest(ctx, input, deps) {
12772
12807
  if (input.whereUrl) cliArgs.push("--where", input.whereUrl);
12773
12808
  const local = await (deps.runCli ?? runSealCli)(cliArgs);
12774
12809
  if (local && "files" in local) {
12775
- appendAudit(ctx, "seal_request", handle, "local");
12776
- await ctx.persist();
12810
+ await appendAudit(ctx, "seal_request", handle, "local");
12777
12811
  return {
12778
12812
  handle,
12779
12813
  source: "local",
@@ -12813,8 +12847,7 @@ async function sealRequest(ctx, input, deps) {
12813
12847
  }
12814
12848
  return receiveThroughLink(ctx, handle, intake.id, deps, input.onProgress);
12815
12849
  }
12816
- appendAudit(ctx, "seal_request", handle, "link");
12817
- await ctx.persist();
12850
+ await appendAudit(ctx, "seal_request", handle, "link");
12818
12851
  return { handle, url, emoji, expiresAt: intake.expires_at };
12819
12852
  }
12820
12853
  async function receiveThroughLink(ctx, handle, intakeId, deps, onProgress) {
@@ -12835,8 +12868,7 @@ async function receiveThroughLink(ctx, handle, intakeId, deps, onProgress) {
12835
12868
  const opened = await sealOpen(ctx, { url: `${deps.publicHost}${sharePath}`, mode: "file", dir, onProgress });
12836
12869
  if (!("files" in opened)) throw new ToolError("internal_inconsistency", "seal_open mode=file returned no files");
12837
12870
  ctx.requests?.delete(handle);
12838
- appendAudit(ctx, "seal_request", handle, "link");
12839
- await ctx.persist();
12871
+ await appendAudit(ctx, "seal_request", handle, "link");
12840
12872
  return {
12841
12873
  handle,
12842
12874
  source: "link",
@@ -12849,6 +12881,7 @@ async function sealRevoke(ctx, input) {
12849
12881
  if (!isValidHandle(input.handle)) {
12850
12882
  throw new ToolError("handle_invalid", `handle does not match contract pattern: ${input.handle}`);
12851
12883
  }
12884
+ await ctx.update(() => false);
12852
12885
  const entry = lookupHandle(ctx, input.handle);
12853
12886
  const now = (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
12854
12887
  if (entry.revoked_at) {
@@ -12883,15 +12916,17 @@ async function sealRevoke(ctx, input) {
12883
12916
  err2
12884
12917
  );
12885
12918
  }
12886
- entry.revoked_at = now;
12887
- appendAudit(ctx, "seal_revoke", input.handle, entry.mode);
12888
- await ctx.persist();
12919
+ await ctx.update((state) => {
12920
+ state.handles[input.handle] = { ...entry, revoked_at: now };
12921
+ state.audit.push(auditEntry(ctx, "seal_revoke", input.handle, entry.mode));
12922
+ });
12889
12923
  return { revoked: true, revokedAt: now };
12890
12924
  }
12891
12925
 
12892
12926
  // src/tools/share.ts
12893
12927
  import { openAsBlob as openAsBlob2 } from "fs";
12894
12928
  import { stat as stat3 } from "fs/promises";
12929
+ import { hostname as hostname2 } from "os";
12895
12930
  import { basename as basename3 } from "path";
12896
12931
 
12897
12932
  // ../../contracts/limits.json
@@ -13118,6 +13153,7 @@ function guessMimeFromName(name) {
13118
13153
  }
13119
13154
  var VALID_TTL = ["5m", "30m", "1h", "1d", "7d"];
13120
13155
  var EXPIRE_DEFAULT = seal_mcp_v1_default.tools.seal_share.expire_default;
13156
+ var HANDOFF_OUTPUT = seal_mcp_v1_default.tools.seal_share.output_handoff;
13121
13157
  var TIER_BYTES2 = limits_default2.limits.TIER_BYTES;
13122
13158
  var PAYMENT_RECEIPT = "The file is larger than 1 GB. Show the user the payment link; the upload continues after payment. Then call seal_share with `payment` set to this handle.";
13123
13159
  function defaultExpire(mode, size) {
@@ -13145,31 +13181,46 @@ var FORWARD_MAX_READS_FORCED = 1;
13145
13181
  function nowIso2(ctx) {
13146
13182
  return (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
13147
13183
  }
13148
- async function persistBestEffort(ctx) {
13184
+ function journal(ctx, handle, op) {
13185
+ return ctx.update((state) => {
13186
+ state.pending[handle] = op;
13187
+ });
13188
+ }
13189
+ function dropJournal(ctx, handle) {
13190
+ return ctx.update((state) => {
13191
+ delete state.pending[handle];
13192
+ });
13193
+ }
13194
+ function complete(ctx, handle, entry) {
13195
+ return ctx.update((state) => {
13196
+ insertHandle(state, handle, entry);
13197
+ state.audit.push(auditEntry(ctx, "seal_share", handle, entry.mode));
13198
+ delete state.pending[handle];
13199
+ });
13200
+ }
13201
+ async function bestEffort(write) {
13149
13202
  try {
13150
- await ctx.persist();
13203
+ await write;
13151
13204
  } catch (err2) {
13152
13205
  const msg = err2 instanceof Error ? err2.message : String(err2);
13153
- process.stderr.write(`[seal-mcp] warn: journal persist failed during compensation: ${msg}
13206
+ process.stderr.write(`[seal-mcp] warn: journal write failed: ${msg}
13154
13207
  `);
13155
13208
  }
13156
13209
  }
13157
- async function compensateRevoke(ctx, handle) {
13158
- const op = ctx.state.pending[handle];
13159
- const sealId = op?.seal_id;
13160
- if (!op || sealId === void 0) {
13210
+ async function compensateRevoke(ctx, handle, op) {
13211
+ const sealId = op.seal_id;
13212
+ if (sealId === void 0) {
13161
13213
  return "no live seal to revoke";
13162
13214
  }
13163
13215
  try {
13164
13216
  await ctx.client.revoke(sealId, op.owner_token_b64);
13165
- delete ctx.state.pending[handle];
13166
- await persistBestEffort(ctx);
13217
+ await bestEffort(dropJournal(ctx, handle));
13167
13218
  return "seal auto-revoked";
13168
13219
  } catch (err2) {
13169
13220
  const msg = err2 instanceof Error ? err2.message : String(err2);
13170
13221
  op.step = "compensating";
13171
13222
  op.compensation = { action: "revoke", outcome: "failed", error: msg, at: nowIso2(ctx) };
13172
- await persistBestEffort(ctx);
13223
+ await bestEffort(journal(ctx, handle, op));
13173
13224
  return `auto-revoke FAILED (${msg}) \u2014 the orphaned seal stays live until its TTL (${op.expires_at}); revoke is journaled and will be retried at next server start`;
13174
13225
  }
13175
13226
  }
@@ -13341,13 +13392,14 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13341
13392
  // backend this seal is actually created on — recovery refuses to
13342
13393
  // "revoke" against a different backend whose 404 would be misread
13343
13394
  // as success.
13344
- backend_url: ctx.client.baseUrl
13395
+ backend_url: ctx.client.baseUrl,
13396
+ // SPEC-ADDRESS F1 §4: another MCP host starting now must not recover
13397
+ // (revoke) this upload while it runs.
13398
+ owner: { pid: process.pid, hostname: hostname2() }
13345
13399
  };
13346
- ctx.state.pending[handle] = op;
13347
13400
  try {
13348
- await ctx.persist();
13401
+ await journal(ctx, handle, op);
13349
13402
  } catch (err2) {
13350
- delete ctx.state.pending[handle];
13351
13403
  throw new ToolError(
13352
13404
  "state_persist_failed",
13353
13405
  `refusing to upload: could not journal the operation before its first remote effect: ${err2 instanceof Error ? err2.message : String(err2)}`,
@@ -13368,7 +13420,7 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13368
13420
  // `uploaded` checkpoint after this call is the durability gate.
13369
13421
  onSealCreated: async (sealId) => {
13370
13422
  op.seal_id = sealId;
13371
- await persistBestEffort(ctx);
13423
+ await bestEffort(journal(ctx, handle, op));
13372
13424
  }
13373
13425
  });
13374
13426
  } catch (err2) {
@@ -13377,15 +13429,14 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13377
13429
  return awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, expiresAt, session });
13378
13430
  }
13379
13431
  if (op.seal_id !== void 0) {
13380
- const outcome = await compensateRevoke(ctx, handle);
13432
+ const outcome = await compensateRevoke(ctx, handle, op);
13381
13433
  throw new ToolError(
13382
13434
  "upload_failed",
13383
13435
  `seal upload failed after the seal was created: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
13384
13436
  err2
13385
13437
  );
13386
13438
  }
13387
- delete ctx.state.pending[handle];
13388
- await persistBestEffort(ctx);
13439
+ await bestEffort(dropJournal(ctx, handle));
13389
13440
  throw new ToolError(
13390
13441
  "upload_failed",
13391
13442
  `seal upload failed: ${err2 instanceof Error ? err2.message : String(err2)}`,
@@ -13398,9 +13449,9 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13398
13449
  op.seal_id = uploaded.sealId;
13399
13450
  op.step = "uploaded";
13400
13451
  try {
13401
- await ctx.persist();
13452
+ await journal(ctx, handle, op);
13402
13453
  } catch (err2) {
13403
- const outcome = await compensateRevoke(ctx, handle);
13454
+ const outcome = await compensateRevoke(ctx, handle, op);
13404
13455
  throw new ToolError(
13405
13456
  "state_persist_failed",
13406
13457
  `could not journal the live seal after upload: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13413,7 +13464,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13413
13464
  try {
13414
13465
  shareBlobBytes = await encryptShareBlob(fileKeysB64, shareLinkKey, void 0);
13415
13466
  } catch (err2) {
13416
- const outcome = await compensateRevoke(ctx, handle);
13467
+ const outcome = await compensateRevoke(ctx, handle, op);
13417
13468
  throw new ToolError(
13418
13469
  "share_blob_failed",
13419
13470
  `share blob encryption failed: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13425,7 +13476,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13425
13476
  try {
13426
13477
  shareCode = await ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64);
13427
13478
  } catch (err2) {
13428
- const outcome = await compensateRevoke(ctx, handle);
13479
+ const outcome = await compensateRevoke(ctx, handle, op);
13429
13480
  throw new ToolError(
13430
13481
  "share_link_failed",
13431
13482
  `POST /seal/${uploaded.sealId}/share failed: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13443,7 +13494,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13443
13494
  try {
13444
13495
  fragment = await buildTargetedFragment(shareLinkKey, recipientPub, uploaded.sealId);
13445
13496
  } catch (err2) {
13446
- const outcome = await compensateRevoke(ctx, handle);
13497
+ const outcome = await compensateRevoke(ctx, handle, op);
13447
13498
  const message = err2 instanceof Error ? err2.message : String(err2);
13448
13499
  if (message.includes("non-contributory")) {
13449
13500
  throw new ToolError(
@@ -13464,7 +13515,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13464
13515
  const host = resolvePublicHost(deps.publicHost);
13465
13516
  const shareUrl = `${host}/s/${uploaded.sealId}/${shareCode}#${fragment}`;
13466
13517
  if (mode === "forward") {
13467
- insertHandle(ctx, handle, {
13518
+ await complete(ctx, handle, {
13468
13519
  seal_id: uploaded.sealId,
13469
13520
  owner_token_b64: ownerTokenB64,
13470
13521
  created_at: nowIso2(ctx),
@@ -13472,9 +13523,6 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13472
13523
  expires_at: expiresAt,
13473
13524
  backend_url: ctx.client.baseUrl
13474
13525
  });
13475
- appendAudit(ctx, "seal_share", handle, mode);
13476
- delete ctx.state.pending[handle];
13477
- await ctx.persist();
13478
13526
  return {
13479
13527
  handle,
13480
13528
  shareUrl,
@@ -13486,9 +13534,9 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13486
13534
  op.step = "delivering";
13487
13535
  op.handoff_channels_attempted = deps.handoff?.disableClipboard ? ["file"] : ["clipboard", "file"];
13488
13536
  try {
13489
- await ctx.persist();
13537
+ await journal(ctx, handle, op);
13490
13538
  } catch (err2) {
13491
- const outcome = await compensateRevoke(ctx, handle);
13539
+ const outcome = await compensateRevoke(ctx, handle, op);
13492
13540
  throw new ToolError(
13493
13541
  "state_persist_failed",
13494
13542
  `could not journal the delivery attempt before handing off the URL: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13499,14 +13547,14 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13499
13547
  try {
13500
13548
  handoff = await performHandoff({ handle, shareUrl, expireMs }, deps.handoff);
13501
13549
  } catch (err2) {
13502
- const outcome = await compensateRevoke(ctx, handle);
13550
+ const outcome = await compensateRevoke(ctx, handle, op);
13503
13551
  throw new ToolError(
13504
13552
  "handoff_both_channels_failed",
13505
13553
  `both clipboard and file handoff channels failed \u2014 ${outcome}`,
13506
13554
  err2
13507
13555
  );
13508
13556
  }
13509
- insertHandle(ctx, handle, {
13557
+ await complete(ctx, handle, {
13510
13558
  seal_id: uploaded.sealId,
13511
13559
  owner_token_b64: ownerTokenB64,
13512
13560
  created_at: nowIso2(ctx),
@@ -13515,10 +13563,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13515
13563
  handoff_channel: handoff.channel,
13516
13564
  backend_url: ctx.client.baseUrl
13517
13565
  });
13518
- appendAudit(ctx, "seal_share", handle, mode);
13519
- delete ctx.state.pending[handle];
13520
- await ctx.persist();
13521
- const delivered = handoff.channel === "clipboard" ? "The seal link is on your clipboard. Paste it to the recipient." : `The seal link is in ${handoff.file} (mode 0600, removed automatically later). Give it to the recipient.`;
13566
+ const delivered = handoff.channel === "clipboard" ? HANDOFF_OUTPUT.receipt_clipboard : HANDOFF_OUTPUT.receipt_file.replace("{file}", handoff.file ?? "");
13522
13567
  const verified = uploaded.verification === "verified" ? delivered : `${delivered} The server has not finished verifying the file: for the next few seconds the recipient sees "not ready", after which the link works.`;
13523
13568
  const receipt = ctx.ephemeral ? `${verified} This server keeps no state on disk: the handle lives until it restarts; the seal's expiry does not depend on it.` : verified;
13524
13569
  return {
@@ -13536,12 +13581,12 @@ async function awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, se
13536
13581
  const tier = prepared.tier;
13537
13582
  const sealId = op.seal_id;
13538
13583
  op.step = "awaiting_payment";
13539
- await persistBestEffort(ctx);
13584
+ await bestEffort(journal(ctx, handle, op));
13540
13585
  let paymentUrl;
13541
13586
  try {
13542
13587
  paymentUrl = await ctx.client.createCheckout(sealId, ownerTokenB64, tier);
13543
13588
  } catch (err2) {
13544
- const outcome = await compensateRevoke(ctx, handle);
13589
+ const outcome = await compensateRevoke(ctx, handle, op);
13545
13590
  throw new ToolError("payment_failed", `POST /billing/checkout failed: ${messageOf3(err2)} (${outcome})`, err2);
13546
13591
  }
13547
13592
  const deadline = paymentDeadline(op.expires_at);
@@ -13551,7 +13596,7 @@ async function awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, se
13551
13596
  clearTimeout(timer);
13552
13597
  ctx.payments?.delete(handle);
13553
13598
  await prepared.cleanup();
13554
- throw new ToolError(code, `${why} (${await compensateRevoke(ctx, handle)})`);
13599
+ throw new ToolError(code, `${why} (${await compensateRevoke(ctx, handle, op)})`);
13555
13600
  };
13556
13601
  const finish = async (onProgress) => {
13557
13602
  if (running) throw new ToolError("payment_invalid", `share ${handle} is already being finished`);
@@ -13720,7 +13765,7 @@ function proFileGetOutputToWire(out) {
13720
13765
  }
13721
13766
 
13722
13767
  // src/server.ts
13723
- var SERVER_VERSION = "0.2.4";
13768
+ var SERVER_VERSION = "0.3.0";
13724
13769
  var toolMeta = (name) => {
13725
13770
  const { title, description, annotations } = seal_mcp_v1_default.tools[name];
13726
13771
  return { title, description, annotations };
@@ -13747,41 +13792,29 @@ function progressReporter(extra) {
13747
13792
  }
13748
13793
  async function createServer(options) {
13749
13794
  logEngineToStderr();
13750
- let view;
13751
- let identity;
13752
- let lock = null;
13753
13795
  const client = new SealApi({ baseUrl: resolveBackendUrl(options.backendBaseUrl) });
13754
13796
  const disk = options.state;
13797
+ let view;
13798
+ const update = async (mutate) => {
13799
+ if (disk === null) await mutate(view);
13800
+ else view = await updateState(disk.passphrase, disk.path, mutate);
13801
+ };
13802
+ let identity;
13755
13803
  if (disk === null) {
13756
13804
  identity = await generateIdentity();
13757
13805
  view = createEmptyState(identityToFields(identity));
13758
13806
  } else {
13759
- const held = await acquireStateLock(dirname3(disk.path));
13760
- lock = held;
13761
- try {
13762
- view = await loadState(disk.passphrase, disk.path);
13763
- identity = await loadIdentity(view.identity);
13764
- const { changed } = await recoverPendingOperations(view, client);
13765
- if (changed) {
13766
- await saveState(view, disk.passphrase, disk.path);
13767
- }
13768
- } catch (err2) {
13769
- await held.release();
13770
- throw err2;
13771
- }
13807
+ view = await updateState(disk.passphrase, disk.path, async (state) => (await recoverPendingOperations(state, client)).changed);
13808
+ identity = await loadIdentity(view.identity);
13772
13809
  }
13773
13810
  const publicHost = resolvePublicHost(options.publicHost);
13774
- let persistChain = Promise.resolve();
13775
- const persist = () => {
13776
- if (disk === null) return Promise.resolve();
13777
- persistChain = persistChain.catch(() => void 0).then(() => saveState(view, disk.passphrase, disk.path));
13778
- return persistChain;
13779
- };
13780
13811
  const ctx = {
13781
- state: view,
13812
+ get state() {
13813
+ return view;
13814
+ },
13782
13815
  identity,
13783
13816
  client,
13784
- persist,
13817
+ update,
13785
13818
  ephemeral: disk === null,
13786
13819
  requests: /* @__PURE__ */ new Map(),
13787
13820
  payments: /* @__PURE__ */ new Map()
@@ -13984,18 +14017,12 @@ async function createServer(options) {
13984
14017
  );
13985
14018
  }
13986
14019
  const transport = new StdioServerTransport();
13987
- try {
13988
- await mcp.connect(transport);
13989
- } catch (err2) {
13990
- await lock?.release();
13991
- throw err2;
13992
- }
14020
+ await mcp.connect(transport);
13993
14021
  const close = async () => {
13994
14022
  try {
13995
14023
  await mcp.close();
13996
14024
  } finally {
13997
14025
  await transport.close().catch(() => void 0);
13998
- await lock?.release();
13999
14026
  }
14000
14027
  };
14001
14028
  return { mcp, transport, close };
@@ -14009,7 +14036,7 @@ async function wrapTool(fn, toWire) {
14009
14036
  structuredContent: wire
14010
14037
  };
14011
14038
  } catch (err2) {
14012
- if (err2 instanceof ToolError) {
14039
+ if (err2 instanceof ToolError || err2 instanceof StateBusyError) {
14013
14040
  return {
14014
14041
  isError: true,
14015
14042
  content: [
@@ -14068,29 +14095,34 @@ function osDefaultDir(env, platform, home) {
14068
14095
  }
14069
14096
  }
14070
14097
 
14071
- // src/passphrase.ts
14098
+ // src/seed.ts
14072
14099
  import { execFile as execFile2 } from "child_process";
14073
- import { randomBytes as randomBytes4 } from "crypto";
14074
- var SERVICE_NAME = "seal-mcp";
14075
- var ACCOUNT_NAME = "state";
14076
- var ENV_VAR = "SEAL_MCP_PASSPHRASE";
14077
- var KEYCHAIN_SERVICE = SERVICE_NAME;
14078
- var KEYCHAIN_ACCOUNT = ACCOUNT_NAME;
14079
- var PASSPHRASE_ENV_VAR = ENV_VAR;
14080
- var KeytarUnavailableError = class extends Error {
14081
- code = "keytar_unavailable";
14082
- constructor(detail) {
14083
- super(`keytar unavailable: ${detail}`);
14084
- this.name = "KeytarUnavailableError";
14085
- }
14086
- };
14087
- var PassphraseNotFoundError = class extends Error {
14088
- code = "passphrase_not_found";
14089
- constructor() {
14100
+ import { hkdfSync } from "crypto";
14101
+ import { promises as fs2 } from "fs";
14102
+ import { join as join9 } from "path";
14103
+ var SEED_ENV_VAR = "SEAL_SEED";
14104
+ var EPHEMERAL_ENV_VAR = "SEAL_MCP_EPHEMERAL";
14105
+ var PASSPHRASE_ENV_VAR = "SEAL_MCP_PASSPHRASE";
14106
+ var SEED_FILE_NAME = "seed";
14107
+ var KEYCHAIN_SERVICE = "seal-mcp";
14108
+ var KEYCHAIN_ACCOUNT = "seed";
14109
+ var LEGACY_KEYCHAIN_ACCOUNT = "state";
14110
+ var SeedInvalidError = class extends Error {
14111
+ code = "seed_invalid";
14112
+ constructor(where) {
14090
14113
  super(
14091
- `No passphrase found. Run \`sealnet-mcp init\` to store one in your OS keychain, or export ${ENV_VAR} for CI/headless use.`
14114
+ `${where} is not an agent key: expected 43 base64url characters, as \`sealnet-mcp seed --force\` or \`seal keygen --export --force\` print them`
14092
14115
  );
14093
- this.name = "PassphraseNotFoundError";
14116
+ this.name = "SeedInvalidError";
14117
+ }
14118
+ };
14119
+ var IdentityConflictError = class extends Error {
14120
+ code = "identity_conflict";
14121
+ constructor(statePath) {
14122
+ super(
14123
+ `${statePath} does not open with this agent's key: it belongs to another key (a foreign ${SEED_ENV_VAR}?) or was tampered with. Unset ${SEED_ENV_VAR}, or run \`sealnet-mcp reset --force\` to start over with a new address.`
14124
+ );
14125
+ this.name = "IdentityConflictError";
14094
14126
  }
14095
14127
  };
14096
14128
  function keyringEntry(keyring, platform, service, account) {
@@ -14125,113 +14157,146 @@ async function loadKeytar() {
14125
14157
  try {
14126
14158
  const mod = await import("@napi-rs/keyring");
14127
14159
  const keyring = mod.default ?? mod;
14128
- keyringEntry(keyring, process.platform, SERVICE_NAME, ACCOUNT_NAME);
14160
+ keyringEntry(keyring, process.platform, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
14129
14161
  keytarCache = keytarOverKeyring(keyring);
14130
14162
  } catch {
14131
14163
  keytarCache = null;
14132
14164
  }
14133
14165
  return keytarCache;
14134
14166
  }
14135
- async function loadKeytarOrThrow() {
14136
- const k = await loadKeytar();
14137
- if (k === null) {
14138
- throw new KeytarUnavailableError(
14139
- "no OS keychain. On Linux start a Secret Service (gnome-keyring or KWallet); on macOS/Windows this should never happen \u2014 file a bug."
14140
- );
14167
+ async function keychainGet(account) {
14168
+ const keytar = await loadKeytar();
14169
+ if (keytar === null) return null;
14170
+ try {
14171
+ const value = await keytar.getPassword(KEYCHAIN_SERVICE, account);
14172
+ return value !== null && value.length > 0 ? value : null;
14173
+ } catch {
14174
+ return null;
14175
+ }
14176
+ }
14177
+ async function keychainDelete(account) {
14178
+ const keytar = await loadKeytar();
14179
+ if (keytar === null) return false;
14180
+ try {
14181
+ return await keytar.deletePassword(KEYCHAIN_SERVICE, account);
14182
+ } catch {
14183
+ return false;
14184
+ }
14185
+ }
14186
+ function parseSeed(text, where) {
14187
+ const seed = Buffer.from(text, "base64url");
14188
+ if (!/^[A-Za-z0-9_-]{43}$/.test(text) || seed.toString("base64url") !== text) throw new SeedInvalidError(where);
14189
+ return new Uint8Array(seed);
14190
+ }
14191
+ async function readSeed(stateDir, env = process.env) {
14192
+ const fromEnv = env[SEED_ENV_VAR]?.trim();
14193
+ if (fromEnv) return { seed: parseSeed(fromEnv, SEED_ENV_VAR), source: "env" };
14194
+ const stored = await keychainGet(KEYCHAIN_ACCOUNT);
14195
+ if (stored !== null) return { seed: parseSeed(stored, `keychain entry ${KEYCHAIN_SERVICE}/${KEYCHAIN_ACCOUNT}`), source: "keychain" };
14196
+ const file = join9(stateDir, SEED_FILE_NAME);
14197
+ let text;
14198
+ try {
14199
+ text = await fs2.readFile(file, "utf8");
14200
+ } catch (err2) {
14201
+ if (err2.code === "ENOENT") return null;
14202
+ throw err2;
14141
14203
  }
14142
- return k;
14204
+ return { seed: parseSeed(text.trim(), file), source: "file" };
14143
14205
  }
14144
- async function resolvePassphraseFromStores(env = process.env) {
14206
+ async function storeSeed(stateDir, seed) {
14207
+ const text = Buffer.from(seed).toString("base64url");
14145
14208
  const keytar = await loadKeytar();
14146
14209
  if (keytar !== null) {
14147
14210
  try {
14148
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
14149
- if (stored !== null && stored.length > 0) {
14150
- return { passphrase: stored, source: "keychain" };
14151
- }
14211
+ await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, text);
14212
+ return "keychain";
14152
14213
  } catch {
14153
14214
  }
14154
14215
  }
14155
- const fromEnv = env[ENV_VAR];
14156
- if (fromEnv !== void 0 && fromEnv.length > 0) {
14157
- return { passphrase: fromEnv, source: "env" };
14216
+ const fh = await fs2.open(join9(stateDir, SEED_FILE_NAME), "wx", 384);
14217
+ try {
14218
+ await fh.writeFile(`${text}
14219
+ `, "utf8");
14220
+ await fh.sync();
14221
+ } finally {
14222
+ await fh.close();
14158
14223
  }
14159
- return null;
14224
+ return "file";
14160
14225
  }
14161
- async function probeKeychain() {
14162
- const keytar = await loadKeytar();
14163
- if (keytar === null) return { available: false, hasEntry: false };
14226
+ function statePassphrase(seed) {
14227
+ return Buffer.from(hkdfSync("sha256", seed, "seal-mcp:state:v1", "passphrase", 32)).toString("base64url");
14228
+ }
14229
+ async function legacyPassphrase(env) {
14230
+ const stored = await keychainGet(LEGACY_KEYCHAIN_ACCOUNT);
14231
+ if (stored !== null) return { passphrase: stored, source: "keychain" };
14232
+ const fromEnv = env[PASSPHRASE_ENV_VAR];
14233
+ return fromEnv ? { passphrase: fromEnv, source: "env" } : null;
14234
+ }
14235
+ async function tryOpen(passphrase, statePath) {
14164
14236
  try {
14165
- const v = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
14166
- return { available: true, hasEntry: v !== null && v.length > 0 };
14167
- } catch {
14168
- return { available: false, hasEntry: false };
14237
+ return await loadState(passphrase, statePath);
14238
+ } catch (err2) {
14239
+ if (err2 instanceof StatePassphraseInvalid) return null;
14240
+ throw err2;
14169
14241
  }
14170
14242
  }
14171
- async function setKeychainPassphrase(passphrase) {
14172
- if (passphrase.length === 0) {
14173
- throw new Error("refusing to store an empty passphrase in the keychain");
14174
- }
14175
- const keytar = await loadKeytarOrThrow();
14176
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, passphrase);
14243
+ async function requireIdentity(view, seed, statePath) {
14244
+ const { secret } = await loadIdentity(view.identity);
14245
+ if (!Buffer.from(secret).equals(Buffer.from(seed))) throw new IdentityConflictError(statePath);
14177
14246
  }
14178
- async function deleteKeychainPassphrase() {
14247
+ function openAgentState(path, env = process.env) {
14248
+ return withStateLock(path.dir, async () => {
14249
+ let root = await readSeed(path.dir, env);
14250
+ if (!await fs2.stat(path.statePath).then(() => true, () => false)) {
14251
+ if (root === null) {
14252
+ const { secret } = await generateIdentity();
14253
+ root = { seed: secret, source: await storeSeed(path.dir, secret) };
14254
+ }
14255
+ const passphrase2 = statePassphrase(root.seed);
14256
+ const identity = await identityFromSeed(root.seed);
14257
+ await saveState(createEmptyState(identityToFields(identity)), passphrase2, path.statePath);
14258
+ return { root, passphrase: passphrase2, created: true, moved: null };
14259
+ }
14260
+ if (root !== null) {
14261
+ const view2 = await tryOpen(statePassphrase(root.seed), path.statePath);
14262
+ if (view2 !== null) {
14263
+ await requireIdentity(view2, root.seed, path.statePath);
14264
+ return { root, passphrase: statePassphrase(root.seed), created: false, moved: null };
14265
+ }
14266
+ }
14267
+ const legacy = await legacyPassphrase(env);
14268
+ const view = legacy === null ? null : await tryOpen(legacy.passphrase, path.statePath);
14269
+ if (legacy === null || view === null) throw new IdentityConflictError(path.statePath);
14270
+ if (root !== null) await requireIdentity(view, root.seed, path.statePath);
14271
+ else {
14272
+ const { secret } = await loadIdentity(view.identity);
14273
+ root = { seed: secret, source: await storeSeed(path.dir, secret) };
14274
+ }
14275
+ const passphrase = statePassphrase(root.seed);
14276
+ await saveState(view, passphrase, path.statePath);
14277
+ await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT);
14278
+ return { root, passphrase, created: false, moved: legacy.source };
14279
+ });
14280
+ }
14281
+ async function deleteRootSecret(stateDir) {
14282
+ let seedFile = true;
14283
+ await fs2.unlink(join9(stateDir, SEED_FILE_NAME)).catch((err2) => {
14284
+ if (err2.code !== "ENOENT") throw err2;
14285
+ seedFile = false;
14286
+ });
14287
+ const seed = await keychainDelete(KEYCHAIN_ACCOUNT);
14288
+ const legacy = await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT);
14289
+ return { seedFile, keychain: seed || legacy };
14290
+ }
14291
+ async function probeKeychain() {
14179
14292
  const keytar = await loadKeytar();
14180
- if (keytar === null) return false;
14293
+ if (keytar === null) return { available: false, hasSeed: false };
14181
14294
  try {
14182
- return await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
14295
+ const value = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
14296
+ return { available: true, hasSeed: value !== null && value.length > 0 };
14183
14297
  } catch {
14184
- return false;
14185
- }
14186
- }
14187
- async function promptHiddenPassphrase(prompt) {
14188
- const stdin = process.stdin;
14189
- if (!stdin.isTTY) {
14190
- throw new Error(
14191
- `Cannot prompt for passphrase: stdin is not a TTY. Set ${ENV_VAR} for non-interactive use.`
14192
- );
14298
+ return { available: false, hasSeed: false };
14193
14299
  }
14194
- process.stdout.write(prompt);
14195
- const wasRaw = stdin.isRaw;
14196
- stdin.setRawMode(true);
14197
- stdin.resume();
14198
- stdin.setEncoding("utf8");
14199
- return new Promise((resolve5, reject) => {
14200
- let buf = "";
14201
- const cleanup = () => {
14202
- stdin.removeListener("data", onData);
14203
- stdin.setRawMode(wasRaw);
14204
- stdin.pause();
14205
- process.stdout.write("\n");
14206
- };
14207
- const onData = (chunk) => {
14208
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
14209
- for (let i = 0; i < text.length; i++) {
14210
- const ch = text[i];
14211
- if (ch === "") {
14212
- cleanup();
14213
- reject(new Error("cancelled"));
14214
- return;
14215
- }
14216
- if (ch === "" && buf.length === 0) {
14217
- cleanup();
14218
- reject(new Error("cancelled"));
14219
- return;
14220
- }
14221
- if (ch === "\r" || ch === "\n") {
14222
- cleanup();
14223
- resolve5(buf);
14224
- return;
14225
- }
14226
- if (ch === "\x7F" || ch === "\b") {
14227
- buf = buf.slice(0, -1);
14228
- continue;
14229
- }
14230
- buf += ch;
14231
- }
14232
- };
14233
- stdin.on("data", onData);
14234
- });
14235
14300
  }
14236
14301
  export {
14237
14302
  APP_NAME,
@@ -14239,16 +14304,20 @@ export {
14239
14304
  BackendError,
14240
14305
  DEFAULT_BACKEND_URL,
14241
14306
  DEFAULT_PUBLIC_HOST,
14307
+ EPHEMERAL_ENV_VAR,
14242
14308
  HandoffBothChannelsFailedError,
14309
+ IdentityConflictError,
14243
14310
  KEYCHAIN_ACCOUNT,
14244
14311
  KEYCHAIN_SERVICE,
14245
- KeytarUnavailableError,
14246
14312
  LOCK_FILE_NAME,
14247
14313
  PASSPHRASE_ENV_VAR,
14248
14314
  PUBLIC_HOST_ENV_VAR,
14249
- PassphraseNotFoundError,
14315
+ SEED_ENV_VAR,
14316
+ SEED_FILE_NAME,
14250
14317
  STATE_FILE_NAME,
14251
14318
  SealApi,
14319
+ SeedInvalidError,
14320
+ StateBusyError,
14252
14321
  StateCorrupt,
14253
14322
  StateLockedError,
14254
14323
  StatePassphraseInvalid,
@@ -14256,20 +14325,22 @@ export {
14256
14325
  acquireStateLock,
14257
14326
  createEmptyState,
14258
14327
  createServer,
14259
- deleteKeychainPassphrase,
14328
+ deleteRootSecret,
14260
14329
  generateHandle,
14261
14330
  generateIdentity,
14331
+ holderMayRun,
14332
+ identityFromSeed,
14262
14333
  identityToFields,
14263
14334
  isValidHandle,
14264
14335
  loadIdentity,
14265
14336
  loadState,
14337
+ openAgentState,
14266
14338
  performHandoff,
14267
14339
  probeKeychain,
14268
14340
  probeStateLock,
14269
- promptHiddenPassphrase,
14341
+ readSeed,
14270
14342
  recoverPendingOperations,
14271
14343
  resolveBackendUrl,
14272
- resolvePassphraseFromStores,
14273
14344
  resolvePublicHost,
14274
14345
  resolveStateDir,
14275
14346
  saveState,
@@ -14277,7 +14348,9 @@ export {
14277
14348
  sealOpen,
14278
14349
  sealRevoke,
14279
14350
  sealShare,
14280
- setKeychainPassphrase
14351
+ statePassphrase,
14352
+ updateState,
14353
+ withStateLock
14281
14354
  };
14282
14355
  /*! Bundled license information:
14283
14356