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.cjs CHANGED
@@ -35,16 +35,20 @@ __export(index_exports, {
35
35
  BackendError: () => BackendError,
36
36
  DEFAULT_BACKEND_URL: () => DEFAULT_BACKEND_URL,
37
37
  DEFAULT_PUBLIC_HOST: () => DEFAULT_PUBLIC_HOST,
38
+ EPHEMERAL_ENV_VAR: () => EPHEMERAL_ENV_VAR,
38
39
  HandoffBothChannelsFailedError: () => HandoffBothChannelsFailedError,
40
+ IdentityConflictError: () => IdentityConflictError,
39
41
  KEYCHAIN_ACCOUNT: () => KEYCHAIN_ACCOUNT,
40
42
  KEYCHAIN_SERVICE: () => KEYCHAIN_SERVICE,
41
- KeytarUnavailableError: () => KeytarUnavailableError,
42
43
  LOCK_FILE_NAME: () => LOCK_FILE_NAME,
43
44
  PASSPHRASE_ENV_VAR: () => PASSPHRASE_ENV_VAR,
44
45
  PUBLIC_HOST_ENV_VAR: () => PUBLIC_HOST_ENV_VAR,
45
- PassphraseNotFoundError: () => PassphraseNotFoundError,
46
+ SEED_ENV_VAR: () => SEED_ENV_VAR,
47
+ SEED_FILE_NAME: () => SEED_FILE_NAME,
46
48
  STATE_FILE_NAME: () => STATE_FILE_NAME,
47
49
  SealApi: () => SealApi,
50
+ SeedInvalidError: () => SeedInvalidError,
51
+ StateBusyError: () => StateBusyError,
48
52
  StateCorrupt: () => StateCorrupt,
49
53
  StateLockedError: () => StateLockedError,
50
54
  StatePassphraseInvalid: () => StatePassphraseInvalid,
@@ -52,20 +56,22 @@ __export(index_exports, {
52
56
  acquireStateLock: () => acquireStateLock,
53
57
  createEmptyState: () => createEmptyState,
54
58
  createServer: () => createServer,
55
- deleteKeychainPassphrase: () => deleteKeychainPassphrase,
59
+ deleteRootSecret: () => deleteRootSecret,
56
60
  generateHandle: () => generateHandle,
57
61
  generateIdentity: () => generateIdentity,
62
+ holderMayRun: () => holderMayRun,
63
+ identityFromSeed: () => identityFromSeed,
58
64
  identityToFields: () => identityToFields,
59
65
  isValidHandle: () => isValidHandle,
60
66
  loadIdentity: () => loadIdentity,
61
67
  loadState: () => loadState,
68
+ openAgentState: () => openAgentState,
62
69
  performHandoff: () => performHandoff,
63
70
  probeKeychain: () => probeKeychain,
64
71
  probeStateLock: () => probeStateLock,
65
- promptHiddenPassphrase: () => promptHiddenPassphrase,
72
+ readSeed: () => readSeed,
66
73
  recoverPendingOperations: () => recoverPendingOperations,
67
74
  resolveBackendUrl: () => resolveBackendUrl,
68
- resolvePassphraseFromStores: () => resolvePassphraseFromStores,
69
75
  resolvePublicHost: () => resolvePublicHost,
70
76
  resolveStateDir: () => resolveStateDir,
71
77
  saveState: () => saveState,
@@ -73,12 +79,13 @@ __export(index_exports, {
73
79
  sealOpen: () => sealOpen,
74
80
  sealRevoke: () => sealRevoke,
75
81
  sealShare: () => sealShare,
76
- setKeychainPassphrase: () => setKeychainPassphrase
82
+ statePassphrase: () => statePassphrase,
83
+ updateState: () => updateState,
84
+ withStateLock: () => withStateLock
77
85
  });
78
86
  module.exports = __toCommonJS(index_exports);
79
87
 
80
88
  // src/server.ts
81
- var import_node_path9 = require("path");
82
89
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
83
90
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
84
91
 
@@ -4138,13 +4145,13 @@ var seal_mcp_v1_default = {
4138
4145
  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`."
4139
4146
  },
4140
4147
  server_instructions: {
4141
- 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).",
4148
+ 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.",
4142
4149
  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."
4143
4150
  },
4144
4151
  tools: {
4145
4152
  seal_share: {
4146
4153
  title: "Share a file via SEAL",
4147
- 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.",
4154
+ 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.",
4148
4155
  annotations: { destructiveHint: false, openWorldHint: true },
4149
4156
  input: {
4150
4157
  properties: {
@@ -4188,13 +4195,15 @@ var seal_mcp_v1_default = {
4188
4195
  required: ["handle", "receipt", "handoff_channel"],
4189
4196
  properties: {
4190
4197
  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." },
4191
- 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." },
4198
+ 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." },
4192
4199
  handoff_channel: { type: "string", enum: ["clipboard", "file"] },
4193
4200
  handoff_file: { type: "string", description: "ONLY when handoff_channel='file'. Absolute path to a 0o600 file containing the share link and a newline." },
4194
4201
  expires_at: { type: "string", format: "date-time", description: "ISO 8601 UTC expiry timestamp." }
4195
4202
  },
4196
4203
  MUST_NOT_contain: ["share_url", "url", "password", "passphrase", "link_key", "owner_token"],
4197
- rationale: "Anything in this object reaches the model. Any field that lets the model reconstruct or re-issue the share capability defeats handoff mode."
4204
+ rationale: "Anything in this object reaches the model. Any field that lets the model reconstruct or re-issue the share capability defeats handoff mode.",
4205
+ receipt_clipboard: "The seal link is on your clipboard. Paste it to the recipient.",
4206
+ 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."
4198
4207
  },
4199
4208
  output_forward: {
4200
4209
  type: "object",
@@ -4522,7 +4531,7 @@ var seal_mcp_v1_default = {
4522
4531
  type: "object",
4523
4532
  required: ["x25519_secret_b64", "x25519_public_b64"],
4524
4533
  properties: {
4525
- 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." },
4534
+ 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." },
4526
4535
  x25519_public_b64: { type: "string", description: "32 raw bytes, derived from secret. Safe to publish; advertised so other parties can target this MCP." }
4527
4536
  }
4528
4537
  },
@@ -4543,7 +4552,7 @@ var seal_mcp_v1_default = {
4543
4552
  },
4544
4553
  pending: {
4545
4554
  type: "object",
4546
- 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.",
4555
+ 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.",
4547
4556
  additionalProperties: {
4548
4557
  type: "object",
4549
4558
  required: ["tool", "mode", "started_at", "owner_token_b64", "expires_at", "step"],
@@ -4556,6 +4565,12 @@ var seal_mcp_v1_default = {
4556
4565
  step: { type: "string", enum: ["preparing", "awaiting_payment", "uploaded", "delivering", "compensating"] },
4557
4566
  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." },
4558
4567
  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)." },
4568
+ owner: {
4569
+ type: "object",
4570
+ required: ["pid", "hostname"],
4571
+ properties: { pid: { type: "integer" }, hostname: { type: "string" } },
4572
+ 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."
4573
+ },
4559
4574
  compensation: {
4560
4575
  type: "object",
4561
4576
  required: ["action", "outcome", "error", "at"],
@@ -4587,27 +4602,20 @@ var seal_mcp_v1_default = {
4587
4602
  }
4588
4603
  }
4589
4604
  },
4590
- passphrase_delivery: {
4591
- ordered_priority: [
4592
- {
4593
- rank: 1,
4594
- method: "os_keychain",
4595
- 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.",
4596
- default: true
4597
- },
4598
- {
4599
- rank: 2,
4600
- method: "env_var",
4601
- 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."
4602
- },
4603
- {
4604
- rank: 3,
4605
- method: "REFUSED",
4606
- implementation: "Cleartext passphrase file on disk is explicitly refused even if user passes `--passphrase-file \u2026`. Error message points at options 1 and 2."
4607
- }
4605
+ root_secret: {
4606
+ 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.",
4607
+ ordered_sources: [
4608
+ { rank: 1, source: "env", implementation: "$SEAL_SEED. How a container or a cloud session gets the same address as at home." },
4609
+ { 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." },
4610
+ { 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." }
4608
4611
  ],
4609
- 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.",
4610
- 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)."
4612
+ 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.",
4613
+ 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.",
4614
+ 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.",
4615
+ 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.",
4616
+ 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.",
4617
+ 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`.",
4618
+ 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."
4611
4619
  },
4612
4620
  handoff_delivery: {
4613
4621
  primary_channel: "system_clipboard",
@@ -4683,11 +4691,12 @@ var seal_mcp_v1_default = {
4683
4691
  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.",
4684
4692
  pro_failed: "seal_pro_*: SEAL Pro answered with another error; the message has the status.",
4685
4693
  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.",
4694
+ state_busy: "Another sealnet-mcp process held the state lock for more than 5 s. Nothing was changed by this call; safe to retry.",
4686
4695
  runtime_unsupported: "Node runtime is missing a global the bundle assumes (e.g. `globalThis.File` or `globalThis.Blob`). Upgrade to Node \u226520.",
4687
4696
  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.",
4688
- "state.passphrase_invalid": "AES-256-GCM tag check failed. Likely wrong passphrase in keychain / env; could be tampered file. Server refuses to start.",
4697
+ 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`.",
4689
4698
  "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.",
4690
- 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.",
4699
+ 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.",
4691
4700
  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."
4692
4701
  },
4693
4702
  current_format_version: 1,
@@ -11511,7 +11520,7 @@ function safeFilename(name) {
11511
11520
  return cleaned.length > 0 ? cleaned : "seal-file";
11512
11521
  }
11513
11522
  async function receiveToDisk(opened, dir, name) {
11514
- const fs2 = await import(
11523
+ const fs3 = await import(
11515
11524
  /* webpackIgnore: true */
11516
11525
  /* @vite-ignore */
11517
11526
  "fs/promises"
@@ -11521,10 +11530,10 @@ async function receiveToDisk(opened, dir, name) {
11521
11530
  /* @vite-ignore */
11522
11531
  "path"
11523
11532
  );
11524
- await fs2.mkdir(dir, { recursive: true });
11533
+ await fs3.mkdir(dir, { recursive: true });
11525
11534
  const suffix = Array.from(crypto.getRandomValues(new Uint8Array(6)), (b) => b.toString(16).padStart(2, "0")).join("");
11526
11535
  const tmp = path.join(dir, `.seal-partial-${suffix}`);
11527
- const fh = await fs2.open(tmp, "wx", 384);
11536
+ const fh = await fs3.open(tmp, "wx", 384);
11528
11537
  let placed = false;
11529
11538
  try {
11530
11539
  await opened.stream.pipeTo(
@@ -11542,12 +11551,12 @@ async function receiveToDisk(opened, dir, name) {
11542
11551
  for (let i = 0; i < 1e3; i++) {
11543
11552
  const candidate = path.join(dir, i === 0 ? name : `${stem} (${i})${ext}`);
11544
11553
  try {
11545
- await fs2.link(tmp, candidate);
11554
+ await fs3.link(tmp, candidate);
11546
11555
  } catch (err2) {
11547
11556
  if (err2.code === "EEXIST") continue;
11548
11557
  throw err2;
11549
11558
  }
11550
- await fs2.unlink(tmp);
11559
+ await fs3.unlink(tmp);
11551
11560
  placed = true;
11552
11561
  return { path: candidate, sizeBytes, sha256: sha2562 };
11553
11562
  }
@@ -11555,7 +11564,7 @@ async function receiveToDisk(opened, dir, name) {
11555
11564
  } finally {
11556
11565
  if (!placed) {
11557
11566
  await fh.close().catch(() => void 0);
11558
- await fs2.unlink(tmp).catch(() => void 0);
11567
+ await fs3.unlink(tmp).catch(() => void 0);
11559
11568
  }
11560
11569
  }
11561
11570
  }
@@ -11993,6 +12002,9 @@ async function generateIdentity() {
11993
12002
  const { publicKey, secretKey } = await generateRecipientKeyPair();
11994
12003
  return { secret: secretKey, public: publicKey };
11995
12004
  }
12005
+ async function identityFromSeed(seed) {
12006
+ return { secret: seed, public: await recipientPublicKey(seed) };
12007
+ }
11996
12008
  function identityToFields(id) {
11997
12009
  return {
11998
12010
  x25519_secret_b64: toB64(id.secret),
@@ -12026,20 +12038,27 @@ var import_node_crypto = require("crypto");
12026
12038
  var import_node_os = require("os");
12027
12039
  var import_node_path = require("path");
12028
12040
  var LOCK_FILE_NAME = "state.lock";
12041
+ var LOCK_WAIT_MS = 5e3;
12042
+ var LOCK_RETRY_MS = 25;
12029
12043
  var StateLockedError = class extends Error {
12030
12044
  code = "state_locked";
12031
12045
  holder;
12032
12046
  lockPath;
12033
12047
  constructor(lockPath, holder) {
12034
12048
  const who = holder === null ? "holder info unreadable" : `pid ${holder.pid} on ${holder.hostname} since ${holder.acquired_at}`;
12035
- super(
12036
- `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}`
12037
- );
12049
+ super(`state directory is locked by another sealnet-mcp process (${who}). Lock file: ${lockPath}`);
12038
12050
  this.name = "StateLockedError";
12039
12051
  this.holder = holder;
12040
12052
  this.lockPath = lockPath;
12041
12053
  }
12042
12054
  };
12055
+ var StateBusyError = class extends Error {
12056
+ code = "state_busy";
12057
+ constructor(locked) {
12058
+ super(`${locked.message}; waited ${LOCK_WAIT_MS / 1e3} s, nothing was changed, safe to retry`);
12059
+ this.name = "StateBusyError";
12060
+ }
12061
+ };
12043
12062
  var StateDirUnsafeError = class extends Error {
12044
12063
  code = "state_dir_unsafe";
12045
12064
  constructor(stateDir, reason) {
@@ -12058,6 +12077,9 @@ function defaultIsPidAlive(pid) {
12058
12077
  return code === "EPERM";
12059
12078
  }
12060
12079
  }
12080
+ function holderMayRun(holder, deps = {}) {
12081
+ return holder.hostname !== (deps.host ?? (0, import_node_os.hostname)()) || (deps.isPidAlive ?? defaultIsPidAlive)(holder.pid);
12082
+ }
12061
12083
  function parseHolder(raw) {
12062
12084
  try {
12063
12085
  const parsed = JSON.parse(raw);
@@ -12100,7 +12122,7 @@ async function tryCreate(lockPath, info) {
12100
12122
  await (0, import_promises2.unlink)(tmp).catch(() => void 0);
12101
12123
  }
12102
12124
  }
12103
- async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
12125
+ async function tryBreakStale(lockPath, probed, deps) {
12104
12126
  const claimPath = `${lockPath}.break-${process.pid}-${(0, import_node_crypto.randomBytes)(4).toString("hex")}`;
12105
12127
  try {
12106
12128
  await (0, import_promises2.rename)(lockPath, claimPath);
@@ -12108,8 +12130,7 @@ async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
12108
12130
  return true;
12109
12131
  }
12110
12132
  const claimed = parseHolder(await (0, import_promises2.readFile)(claimPath, "utf8").catch(() => ""));
12111
- const claimedStale = claimed === null || claimed.hostname === selfHost && !isAlive(claimed.pid);
12112
- if (sameHolder(claimed, probed) || claimedStale) {
12133
+ if (sameHolder(claimed, probed) || claimed === null || !holderMayRun(claimed, deps)) {
12113
12134
  await (0, import_promises2.unlink)(claimPath).catch(() => void 0);
12114
12135
  return true;
12115
12136
  }
@@ -12122,11 +12143,9 @@ async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
12122
12143
  }
12123
12144
  async function acquireStateLock(stateDir, deps = {}) {
12124
12145
  const lockPath = (0, import_node_path.join)(stateDir, LOCK_FILE_NAME);
12125
- const isAlive = deps.isPidAlive ?? defaultIsPidAlive;
12126
- const selfHost = deps.host ?? (0, import_node_os.hostname)();
12127
12146
  const self2 = {
12128
12147
  pid: deps.pid ?? process.pid,
12129
- hostname: selfHost,
12148
+ hostname: deps.host ?? (0, import_node_os.hostname)(),
12130
12149
  acquired_at: (/* @__PURE__ */ new Date()).toISOString()
12131
12150
  };
12132
12151
  await (0, import_promises2.mkdir)(stateDir, { recursive: true, mode: 448 });
@@ -12137,11 +12156,10 @@ async function acquireStateLock(stateDir, deps = {}) {
12137
12156
  await (0, import_promises2.chmod)(stateDir, 448).catch(() => void 0);
12138
12157
  if (!await tryCreate(lockPath, self2)) {
12139
12158
  const holder = await readHolder(lockPath);
12140
- const stale = holder !== null && holder.hostname === selfHost && !isAlive(holder.pid);
12141
- if (holder !== null && !stale) {
12159
+ if (holder !== null && holderMayRun(holder, deps)) {
12142
12160
  throw new StateLockedError(lockPath, holder);
12143
12161
  }
12144
- if (!await tryBreakStale(lockPath, holder, isAlive, selfHost)) {
12162
+ if (!await tryBreakStale(lockPath, holder, deps)) {
12145
12163
  throw new StateLockedError(lockPath, await readHolder(lockPath));
12146
12164
  }
12147
12165
  if (!await tryCreate(lockPath, self2)) {
@@ -12173,6 +12191,30 @@ async function acquireStateLock(stateDir, deps = {}) {
12173
12191
  }
12174
12192
  };
12175
12193
  }
12194
+ var queues = /* @__PURE__ */ new Map();
12195
+ function withStateLock(stateDir, fn, deps = {}) {
12196
+ const run = (queues.get(stateDir) ?? Promise.resolve()).catch(() => void 0).then(async () => {
12197
+ const deadline = Date.now() + LOCK_WAIT_MS;
12198
+ let lock;
12199
+ for (; ; ) {
12200
+ try {
12201
+ lock = await acquireStateLock(stateDir, deps);
12202
+ break;
12203
+ } catch (err2) {
12204
+ if (!(err2 instanceof StateLockedError)) throw err2;
12205
+ if (Date.now() >= deadline) throw new StateBusyError(err2);
12206
+ await new Promise((resolve5) => setTimeout(resolve5, LOCK_RETRY_MS));
12207
+ }
12208
+ }
12209
+ try {
12210
+ return await fn();
12211
+ } finally {
12212
+ await lock.release();
12213
+ }
12214
+ });
12215
+ queues.set(stateDir, run);
12216
+ return run;
12217
+ }
12176
12218
  async function probeStateLock(stateDir, deps = {}) {
12177
12219
  const lockPath = (0, import_node_path.join)(stateDir, LOCK_FILE_NAME);
12178
12220
  const holder = await readHolder(lockPath);
@@ -12184,10 +12226,7 @@ async function probeStateLock(stateDir, deps = {}) {
12184
12226
  return { held: false, holder: null, stale: false };
12185
12227
  }
12186
12228
  }
12187
- const isAlive = deps.isPidAlive ?? defaultIsPidAlive;
12188
- const sameHost = holder.hostname === (deps.host ?? (0, import_node_os.hostname)());
12189
- const stale = sameHost && !isAlive(holder.pid);
12190
- return { held: true, holder, stale };
12229
+ return { held: true, holder, stale: !holderMayRun(holder, deps) };
12191
12230
  }
12192
12231
 
12193
12232
  // src/recovery.ts
@@ -12204,6 +12243,10 @@ async function recoverPendingOperations(view, client, opts = {}) {
12204
12243
  log4(`[seal-mcp] recovery: skipping unknown pending op tool=${String(op.tool)} handle=${handle}`);
12205
12244
  continue;
12206
12245
  }
12246
+ if (op.owner !== void 0 && op.owner.pid !== process.pid && holderMayRun(op.owner)) {
12247
+ log4(`[seal-mcp] recovery: left ${handle} alone \u2014 pid ${op.owner.pid} on ${op.owner.hostname} may still run it`);
12248
+ continue;
12249
+ }
12207
12250
  if (op.seal_id === void 0) {
12208
12251
  delete view.pending[handle];
12209
12252
  report.dropped.push(handle);
@@ -12293,7 +12336,7 @@ var AES_KEY_BYTES = 32;
12293
12336
  var AES_NONCE_BYTES = 12;
12294
12337
  var StatePassphraseInvalid = class extends Error {
12295
12338
  constructor() {
12296
- super("state.passphrase_invalid: AES-256-GCM tag check failed");
12339
+ super("AES-256-GCM tag check failed: another passphrase, or a tampered file");
12297
12340
  this.name = "StatePassphraseInvalid";
12298
12341
  }
12299
12342
  };
@@ -12493,6 +12536,13 @@ async function saveState(state, passphrase, statePath) {
12493
12536
  await import_node_fs2.promises.chmod(dir, 448).catch(() => {
12494
12537
  });
12495
12538
  }
12539
+ function updateState(passphrase, statePath, mutate) {
12540
+ return withStateLock((0, import_node_path2.dirname)(statePath), async () => {
12541
+ const view = await loadState(passphrase, statePath);
12542
+ if (await mutate(view) !== false) await saveState(view, passphrase, statePath);
12543
+ return view;
12544
+ });
12545
+ }
12496
12546
 
12497
12547
  // src/tools/context.ts
12498
12548
  var import_node_crypto3 = require("crypto");
@@ -12510,19 +12560,18 @@ var HANDLE_REGEX = /^h_[A-Za-z0-9_-]{12,}$/;
12510
12560
  function isValidHandle(handle) {
12511
12561
  return typeof handle === "string" && HANDLE_REGEX.test(handle);
12512
12562
  }
12563
+ function auditEntry(ctx, tool, handle, mode) {
12564
+ return { ts: (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString(), tool, handle, mode };
12565
+ }
12513
12566
  function appendAudit(ctx, tool, handle, mode) {
12514
- ctx.state.audit.push({
12515
- ts: (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
12516
- tool,
12517
- handle,
12518
- mode
12519
- });
12567
+ const entry = auditEntry(ctx, tool, handle, mode);
12568
+ return ctx.update((state) => void state.audit.push(entry));
12520
12569
  }
12521
- function insertHandle(ctx, handle, entry) {
12522
- if (ctx.state.handles[handle]) {
12570
+ function insertHandle(state, handle, entry) {
12571
+ if (state.handles[handle]) {
12523
12572
  throw new Error(`insertHandle: collision on ${handle}`);
12524
12573
  }
12525
- ctx.state.handles[handle] = entry;
12574
+ state.handles[handle] = entry;
12526
12575
  }
12527
12576
  function lookupHandle(ctx, handle) {
12528
12577
  const entry = ctx.state.handles[handle];
@@ -12545,6 +12594,7 @@ var ToolError = class extends Error {
12545
12594
  // src/tools/list.ts
12546
12595
  var SINGLE_FILE_COUNT = 1;
12547
12596
  async function sealList(ctx, input = {}) {
12597
+ await appendAudit(ctx, "seal_list", null, null);
12548
12598
  const now = (ctx.now?.() ?? /* @__PURE__ */ new Date()).getTime();
12549
12599
  const entries = [];
12550
12600
  for (const [handle, entry] of Object.entries(ctx.state.handles)) {
@@ -12561,8 +12611,6 @@ async function sealList(ctx, input = {}) {
12561
12611
  });
12562
12612
  }
12563
12613
  entries.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
12564
- appendAudit(ctx, "seal_list", null, null);
12565
- await ctx.persist();
12566
12614
  return { entries };
12567
12615
  }
12568
12616
 
@@ -12647,13 +12695,11 @@ async function sealOpen(ctx, input) {
12647
12695
  if (mode === "inline") {
12648
12696
  const refusal = classifyInlineRefusal(first.mime);
12649
12697
  if (refusal !== null) {
12650
- appendAudit(ctx, "seal_open", handle, mode);
12651
- await ctx.persist();
12698
+ await appendAudit(ctx, "seal_open", handle, mode);
12652
12699
  return { handle, refusal, mime: first.mime, note: refusalNote(refusal, first.mime) };
12653
12700
  }
12654
12701
  if (first.size > INLINE_CIPHERTEXT_MAX_BYTES) {
12655
- appendAudit(ctx, "seal_open", handle, mode);
12656
- await ctx.persist();
12702
+ await appendAudit(ctx, "seal_open", handle, mode);
12657
12703
  return {
12658
12704
  handle,
12659
12705
  refusal: "inline_blocked.too_large",
@@ -12689,12 +12735,10 @@ async function sealOpen(ctx, input) {
12689
12735
  err2
12690
12736
  );
12691
12737
  }
12692
- appendAudit(ctx, "seal_open", handle, mode);
12693
- await ctx.persist();
12738
+ await appendAudit(ctx, "seal_open", handle, mode);
12694
12739
  return { handle, mime: bareMime(first.mime), sizeBytes: plaintext.byteLength, encoding: "utf-8", content };
12695
12740
  }
12696
- appendAudit(ctx, "seal_open", handle, mode);
12697
- await ctx.persist();
12741
+ await appendAudit(ctx, "seal_open", handle, mode);
12698
12742
  return {
12699
12743
  handle,
12700
12744
  filename: first.name,
@@ -12740,8 +12784,7 @@ async function openToDisk(ctx, seal, handle, input) {
12740
12784
  throw streamError(err2, file.id);
12741
12785
  }
12742
12786
  }
12743
- appendAudit(ctx, "seal_open", handle, "file");
12744
- await ctx.persist();
12787
+ await appendAudit(ctx, "seal_open", handle, "file");
12745
12788
  return { handle, files: out };
12746
12789
  }
12747
12790
 
@@ -12844,8 +12887,7 @@ async function sealRequest(ctx, input, deps) {
12844
12887
  if (input.whereUrl) cliArgs.push("--where", input.whereUrl);
12845
12888
  const local = await (deps.runCli ?? runSealCli)(cliArgs);
12846
12889
  if (local && "files" in local) {
12847
- appendAudit(ctx, "seal_request", handle, "local");
12848
- await ctx.persist();
12890
+ await appendAudit(ctx, "seal_request", handle, "local");
12849
12891
  return {
12850
12892
  handle,
12851
12893
  source: "local",
@@ -12885,8 +12927,7 @@ async function sealRequest(ctx, input, deps) {
12885
12927
  }
12886
12928
  return receiveThroughLink(ctx, handle, intake.id, deps, input.onProgress);
12887
12929
  }
12888
- appendAudit(ctx, "seal_request", handle, "link");
12889
- await ctx.persist();
12930
+ await appendAudit(ctx, "seal_request", handle, "link");
12890
12931
  return { handle, url, emoji, expiresAt: intake.expires_at };
12891
12932
  }
12892
12933
  async function receiveThroughLink(ctx, handle, intakeId, deps, onProgress) {
@@ -12907,8 +12948,7 @@ async function receiveThroughLink(ctx, handle, intakeId, deps, onProgress) {
12907
12948
  const opened = await sealOpen(ctx, { url: `${deps.publicHost}${sharePath}`, mode: "file", dir, onProgress });
12908
12949
  if (!("files" in opened)) throw new ToolError("internal_inconsistency", "seal_open mode=file returned no files");
12909
12950
  ctx.requests?.delete(handle);
12910
- appendAudit(ctx, "seal_request", handle, "link");
12911
- await ctx.persist();
12951
+ await appendAudit(ctx, "seal_request", handle, "link");
12912
12952
  return {
12913
12953
  handle,
12914
12954
  source: "link",
@@ -12921,6 +12961,7 @@ async function sealRevoke(ctx, input) {
12921
12961
  if (!isValidHandle(input.handle)) {
12922
12962
  throw new ToolError("handle_invalid", `handle does not match contract pattern: ${input.handle}`);
12923
12963
  }
12964
+ await ctx.update(() => false);
12924
12965
  const entry = lookupHandle(ctx, input.handle);
12925
12966
  const now = (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
12926
12967
  if (entry.revoked_at) {
@@ -12955,15 +12996,17 @@ async function sealRevoke(ctx, input) {
12955
12996
  err2
12956
12997
  );
12957
12998
  }
12958
- entry.revoked_at = now;
12959
- appendAudit(ctx, "seal_revoke", input.handle, entry.mode);
12960
- await ctx.persist();
12999
+ await ctx.update((state) => {
13000
+ state.handles[input.handle] = { ...entry, revoked_at: now };
13001
+ state.audit.push(auditEntry(ctx, "seal_revoke", input.handle, entry.mode));
13002
+ });
12961
13003
  return { revoked: true, revokedAt: now };
12962
13004
  }
12963
13005
 
12964
13006
  // src/tools/share.ts
12965
13007
  var import_node_fs5 = require("fs");
12966
13008
  var import_promises9 = require("fs/promises");
13009
+ var import_node_os7 = require("os");
12967
13010
  var import_node_path8 = require("path");
12968
13011
 
12969
13012
  // ../../contracts/limits.json
@@ -13190,6 +13233,7 @@ function guessMimeFromName(name) {
13190
13233
  }
13191
13234
  var VALID_TTL = ["5m", "30m", "1h", "1d", "7d"];
13192
13235
  var EXPIRE_DEFAULT = seal_mcp_v1_default.tools.seal_share.expire_default;
13236
+ var HANDOFF_OUTPUT = seal_mcp_v1_default.tools.seal_share.output_handoff;
13193
13237
  var TIER_BYTES2 = limits_default2.limits.TIER_BYTES;
13194
13238
  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.";
13195
13239
  function defaultExpire(mode, size) {
@@ -13217,31 +13261,46 @@ var FORWARD_MAX_READS_FORCED = 1;
13217
13261
  function nowIso2(ctx) {
13218
13262
  return (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
13219
13263
  }
13220
- async function persistBestEffort(ctx) {
13264
+ function journal(ctx, handle, op) {
13265
+ return ctx.update((state) => {
13266
+ state.pending[handle] = op;
13267
+ });
13268
+ }
13269
+ function dropJournal(ctx, handle) {
13270
+ return ctx.update((state) => {
13271
+ delete state.pending[handle];
13272
+ });
13273
+ }
13274
+ function complete(ctx, handle, entry) {
13275
+ return ctx.update((state) => {
13276
+ insertHandle(state, handle, entry);
13277
+ state.audit.push(auditEntry(ctx, "seal_share", handle, entry.mode));
13278
+ delete state.pending[handle];
13279
+ });
13280
+ }
13281
+ async function bestEffort(write) {
13221
13282
  try {
13222
- await ctx.persist();
13283
+ await write;
13223
13284
  } catch (err2) {
13224
13285
  const msg = err2 instanceof Error ? err2.message : String(err2);
13225
- process.stderr.write(`[seal-mcp] warn: journal persist failed during compensation: ${msg}
13286
+ process.stderr.write(`[seal-mcp] warn: journal write failed: ${msg}
13226
13287
  `);
13227
13288
  }
13228
13289
  }
13229
- async function compensateRevoke(ctx, handle) {
13230
- const op = ctx.state.pending[handle];
13231
- const sealId = op?.seal_id;
13232
- if (!op || sealId === void 0) {
13290
+ async function compensateRevoke(ctx, handle, op) {
13291
+ const sealId = op.seal_id;
13292
+ if (sealId === void 0) {
13233
13293
  return "no live seal to revoke";
13234
13294
  }
13235
13295
  try {
13236
13296
  await ctx.client.revoke(sealId, op.owner_token_b64);
13237
- delete ctx.state.pending[handle];
13238
- await persistBestEffort(ctx);
13297
+ await bestEffort(dropJournal(ctx, handle));
13239
13298
  return "seal auto-revoked";
13240
13299
  } catch (err2) {
13241
13300
  const msg = err2 instanceof Error ? err2.message : String(err2);
13242
13301
  op.step = "compensating";
13243
13302
  op.compensation = { action: "revoke", outcome: "failed", error: msg, at: nowIso2(ctx) };
13244
- await persistBestEffort(ctx);
13303
+ await bestEffort(journal(ctx, handle, op));
13245
13304
  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`;
13246
13305
  }
13247
13306
  }
@@ -13413,13 +13472,14 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13413
13472
  // backend this seal is actually created on — recovery refuses to
13414
13473
  // "revoke" against a different backend whose 404 would be misread
13415
13474
  // as success.
13416
- backend_url: ctx.client.baseUrl
13475
+ backend_url: ctx.client.baseUrl,
13476
+ // SPEC-ADDRESS F1 §4: another MCP host starting now must not recover
13477
+ // (revoke) this upload while it runs.
13478
+ owner: { pid: process.pid, hostname: (0, import_node_os7.hostname)() }
13417
13479
  };
13418
- ctx.state.pending[handle] = op;
13419
13480
  try {
13420
- await ctx.persist();
13481
+ await journal(ctx, handle, op);
13421
13482
  } catch (err2) {
13422
- delete ctx.state.pending[handle];
13423
13483
  throw new ToolError(
13424
13484
  "state_persist_failed",
13425
13485
  `refusing to upload: could not journal the operation before its first remote effect: ${err2 instanceof Error ? err2.message : String(err2)}`,
@@ -13440,7 +13500,7 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13440
13500
  // `uploaded` checkpoint after this call is the durability gate.
13441
13501
  onSealCreated: async (sealId) => {
13442
13502
  op.seal_id = sealId;
13443
- await persistBestEffort(ctx);
13503
+ await bestEffort(journal(ctx, handle, op));
13444
13504
  }
13445
13505
  });
13446
13506
  } catch (err2) {
@@ -13449,15 +13509,14 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13449
13509
  return awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, expiresAt, session });
13450
13510
  }
13451
13511
  if (op.seal_id !== void 0) {
13452
- const outcome = await compensateRevoke(ctx, handle);
13512
+ const outcome = await compensateRevoke(ctx, handle, op);
13453
13513
  throw new ToolError(
13454
13514
  "upload_failed",
13455
13515
  `seal upload failed after the seal was created: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
13456
13516
  err2
13457
13517
  );
13458
13518
  }
13459
- delete ctx.state.pending[handle];
13460
- await persistBestEffort(ctx);
13519
+ await bestEffort(dropJournal(ctx, handle));
13461
13520
  throw new ToolError(
13462
13521
  "upload_failed",
13463
13522
  `seal upload failed: ${err2 instanceof Error ? err2.message : String(err2)}`,
@@ -13470,9 +13529,9 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13470
13529
  op.seal_id = uploaded.sealId;
13471
13530
  op.step = "uploaded";
13472
13531
  try {
13473
- await ctx.persist();
13532
+ await journal(ctx, handle, op);
13474
13533
  } catch (err2) {
13475
- const outcome = await compensateRevoke(ctx, handle);
13534
+ const outcome = await compensateRevoke(ctx, handle, op);
13476
13535
  throw new ToolError(
13477
13536
  "state_persist_failed",
13478
13537
  `could not journal the live seal after upload: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13485,7 +13544,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13485
13544
  try {
13486
13545
  shareBlobBytes = await encryptShareBlob(fileKeysB64, shareLinkKey, void 0);
13487
13546
  } catch (err2) {
13488
- const outcome = await compensateRevoke(ctx, handle);
13547
+ const outcome = await compensateRevoke(ctx, handle, op);
13489
13548
  throw new ToolError(
13490
13549
  "share_blob_failed",
13491
13550
  `share blob encryption failed: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13497,7 +13556,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13497
13556
  try {
13498
13557
  shareCode = await ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64);
13499
13558
  } catch (err2) {
13500
- const outcome = await compensateRevoke(ctx, handle);
13559
+ const outcome = await compensateRevoke(ctx, handle, op);
13501
13560
  throw new ToolError(
13502
13561
  "share_link_failed",
13503
13562
  `POST /seal/${uploaded.sealId}/share failed: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13515,7 +13574,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13515
13574
  try {
13516
13575
  fragment = await buildTargetedFragment(shareLinkKey, recipientPub, uploaded.sealId);
13517
13576
  } catch (err2) {
13518
- const outcome = await compensateRevoke(ctx, handle);
13577
+ const outcome = await compensateRevoke(ctx, handle, op);
13519
13578
  const message = err2 instanceof Error ? err2.message : String(err2);
13520
13579
  if (message.includes("non-contributory")) {
13521
13580
  throw new ToolError(
@@ -13536,7 +13595,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13536
13595
  const host = resolvePublicHost(deps.publicHost);
13537
13596
  const shareUrl = `${host}/s/${uploaded.sealId}/${shareCode}#${fragment}`;
13538
13597
  if (mode === "forward") {
13539
- insertHandle(ctx, handle, {
13598
+ await complete(ctx, handle, {
13540
13599
  seal_id: uploaded.sealId,
13541
13600
  owner_token_b64: ownerTokenB64,
13542
13601
  created_at: nowIso2(ctx),
@@ -13544,9 +13603,6 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13544
13603
  expires_at: expiresAt,
13545
13604
  backend_url: ctx.client.baseUrl
13546
13605
  });
13547
- appendAudit(ctx, "seal_share", handle, mode);
13548
- delete ctx.state.pending[handle];
13549
- await ctx.persist();
13550
13606
  return {
13551
13607
  handle,
13552
13608
  shareUrl,
@@ -13558,9 +13614,9 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13558
13614
  op.step = "delivering";
13559
13615
  op.handoff_channels_attempted = deps.handoff?.disableClipboard ? ["file"] : ["clipboard", "file"];
13560
13616
  try {
13561
- await ctx.persist();
13617
+ await journal(ctx, handle, op);
13562
13618
  } catch (err2) {
13563
- const outcome = await compensateRevoke(ctx, handle);
13619
+ const outcome = await compensateRevoke(ctx, handle, op);
13564
13620
  throw new ToolError(
13565
13621
  "state_persist_failed",
13566
13622
  `could not journal the delivery attempt before handing off the URL: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13571,14 +13627,14 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13571
13627
  try {
13572
13628
  handoff = await performHandoff({ handle, shareUrl, expireMs }, deps.handoff);
13573
13629
  } catch (err2) {
13574
- const outcome = await compensateRevoke(ctx, handle);
13630
+ const outcome = await compensateRevoke(ctx, handle, op);
13575
13631
  throw new ToolError(
13576
13632
  "handoff_both_channels_failed",
13577
13633
  `both clipboard and file handoff channels failed \u2014 ${outcome}`,
13578
13634
  err2
13579
13635
  );
13580
13636
  }
13581
- insertHandle(ctx, handle, {
13637
+ await complete(ctx, handle, {
13582
13638
  seal_id: uploaded.sealId,
13583
13639
  owner_token_b64: ownerTokenB64,
13584
13640
  created_at: nowIso2(ctx),
@@ -13587,10 +13643,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13587
13643
  handoff_channel: handoff.channel,
13588
13644
  backend_url: ctx.client.baseUrl
13589
13645
  });
13590
- appendAudit(ctx, "seal_share", handle, mode);
13591
- delete ctx.state.pending[handle];
13592
- await ctx.persist();
13593
- 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.`;
13646
+ const delivered = handoff.channel === "clipboard" ? HANDOFF_OUTPUT.receipt_clipboard : HANDOFF_OUTPUT.receipt_file.replace("{file}", handoff.file ?? "");
13594
13647
  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.`;
13595
13648
  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;
13596
13649
  return {
@@ -13608,12 +13661,12 @@ async function awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, se
13608
13661
  const tier = prepared.tier;
13609
13662
  const sealId = op.seal_id;
13610
13663
  op.step = "awaiting_payment";
13611
- await persistBestEffort(ctx);
13664
+ await bestEffort(journal(ctx, handle, op));
13612
13665
  let paymentUrl;
13613
13666
  try {
13614
13667
  paymentUrl = await ctx.client.createCheckout(sealId, ownerTokenB64, tier);
13615
13668
  } catch (err2) {
13616
- const outcome = await compensateRevoke(ctx, handle);
13669
+ const outcome = await compensateRevoke(ctx, handle, op);
13617
13670
  throw new ToolError("payment_failed", `POST /billing/checkout failed: ${messageOf3(err2)} (${outcome})`, err2);
13618
13671
  }
13619
13672
  const deadline = paymentDeadline(op.expires_at);
@@ -13623,7 +13676,7 @@ async function awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, se
13623
13676
  clearTimeout(timer);
13624
13677
  ctx.payments?.delete(handle);
13625
13678
  await prepared.cleanup();
13626
- throw new ToolError(code, `${why} (${await compensateRevoke(ctx, handle)})`);
13679
+ throw new ToolError(code, `${why} (${await compensateRevoke(ctx, handle, op)})`);
13627
13680
  };
13628
13681
  const finish = async (onProgress) => {
13629
13682
  if (running) throw new ToolError("payment_invalid", `share ${handle} is already being finished`);
@@ -13792,7 +13845,7 @@ function proFileGetOutputToWire(out) {
13792
13845
  }
13793
13846
 
13794
13847
  // src/server.ts
13795
- var SERVER_VERSION = "0.2.4";
13848
+ var SERVER_VERSION = "0.3.0";
13796
13849
  var toolMeta = (name) => {
13797
13850
  const { title, description, annotations } = seal_mcp_v1_default.tools[name];
13798
13851
  return { title, description, annotations };
@@ -13819,41 +13872,29 @@ function progressReporter(extra) {
13819
13872
  }
13820
13873
  async function createServer(options) {
13821
13874
  logEngineToStderr();
13822
- let view;
13823
- let identity;
13824
- let lock = null;
13825
13875
  const client = new SealApi({ baseUrl: resolveBackendUrl(options.backendBaseUrl) });
13826
13876
  const disk = options.state;
13877
+ let view;
13878
+ const update = async (mutate) => {
13879
+ if (disk === null) await mutate(view);
13880
+ else view = await updateState(disk.passphrase, disk.path, mutate);
13881
+ };
13882
+ let identity;
13827
13883
  if (disk === null) {
13828
13884
  identity = await generateIdentity();
13829
13885
  view = createEmptyState(identityToFields(identity));
13830
13886
  } else {
13831
- const held = await acquireStateLock((0, import_node_path9.dirname)(disk.path));
13832
- lock = held;
13833
- try {
13834
- view = await loadState(disk.passphrase, disk.path);
13835
- identity = await loadIdentity(view.identity);
13836
- const { changed } = await recoverPendingOperations(view, client);
13837
- if (changed) {
13838
- await saveState(view, disk.passphrase, disk.path);
13839
- }
13840
- } catch (err2) {
13841
- await held.release();
13842
- throw err2;
13843
- }
13887
+ view = await updateState(disk.passphrase, disk.path, async (state) => (await recoverPendingOperations(state, client)).changed);
13888
+ identity = await loadIdentity(view.identity);
13844
13889
  }
13845
13890
  const publicHost = resolvePublicHost(options.publicHost);
13846
- let persistChain = Promise.resolve();
13847
- const persist = () => {
13848
- if (disk === null) return Promise.resolve();
13849
- persistChain = persistChain.catch(() => void 0).then(() => saveState(view, disk.passphrase, disk.path));
13850
- return persistChain;
13851
- };
13852
13891
  const ctx = {
13853
- state: view,
13892
+ get state() {
13893
+ return view;
13894
+ },
13854
13895
  identity,
13855
13896
  client,
13856
- persist,
13897
+ update,
13857
13898
  ephemeral: disk === null,
13858
13899
  requests: /* @__PURE__ */ new Map(),
13859
13900
  payments: /* @__PURE__ */ new Map()
@@ -14056,18 +14097,12 @@ async function createServer(options) {
14056
14097
  );
14057
14098
  }
14058
14099
  const transport = new import_stdio.StdioServerTransport();
14059
- try {
14060
- await mcp.connect(transport);
14061
- } catch (err2) {
14062
- await lock?.release();
14063
- throw err2;
14064
- }
14100
+ await mcp.connect(transport);
14065
14101
  const close = async () => {
14066
14102
  try {
14067
14103
  await mcp.close();
14068
14104
  } finally {
14069
14105
  await transport.close().catch(() => void 0);
14070
- await lock?.release();
14071
14106
  }
14072
14107
  };
14073
14108
  return { mcp, transport, close };
@@ -14081,7 +14116,7 @@ async function wrapTool(fn, toWire) {
14081
14116
  structuredContent: wire
14082
14117
  };
14083
14118
  } catch (err2) {
14084
- if (err2 instanceof ToolError) {
14119
+ if (err2 instanceof ToolError || err2 instanceof StateBusyError) {
14085
14120
  return {
14086
14121
  isError: true,
14087
14122
  content: [
@@ -14101,8 +14136,8 @@ async function wrapTool(fn, toWire) {
14101
14136
  }
14102
14137
 
14103
14138
  // src/paths.ts
14104
- var import_node_os7 = require("os");
14105
- var import_node_path10 = require("path");
14139
+ var import_node_os8 = require("os");
14140
+ var import_node_path9 = require("path");
14106
14141
  var APP_NAME = "seal-mcp";
14107
14142
  var STATE_FILE_NAME = "state.json";
14108
14143
  function resolveStateDir(opts = {}) {
@@ -14110,7 +14145,7 @@ function resolveStateDir(opts = {}) {
14110
14145
  if (opts.cliFlag !== void 0 && opts.cliFlag.length > 0) {
14111
14146
  return {
14112
14147
  dir: opts.cliFlag,
14113
- statePath: (0, import_node_path10.join)(opts.cliFlag, STATE_FILE_NAME),
14148
+ statePath: (0, import_node_path9.join)(opts.cliFlag, STATE_FILE_NAME),
14114
14149
  source: "flag"
14115
14150
  };
14116
14151
  }
@@ -14118,51 +14153,56 @@ function resolveStateDir(opts = {}) {
14118
14153
  if (envOverride !== void 0 && envOverride.length > 0) {
14119
14154
  return {
14120
14155
  dir: envOverride,
14121
- statePath: (0, import_node_path10.join)(envOverride, STATE_FILE_NAME),
14156
+ statePath: (0, import_node_path9.join)(envOverride, STATE_FILE_NAME),
14122
14157
  source: "env"
14123
14158
  };
14124
14159
  }
14125
- const dir = osDefaultDir(env, opts.platform ?? process.platform, opts.home ?? (0, import_node_os7.homedir)());
14126
- return { dir, statePath: (0, import_node_path10.join)(dir, STATE_FILE_NAME), source: "os-default" };
14160
+ const dir = osDefaultDir(env, opts.platform ?? process.platform, opts.home ?? (0, import_node_os8.homedir)());
14161
+ return { dir, statePath: (0, import_node_path9.join)(dir, STATE_FILE_NAME), source: "os-default" };
14127
14162
  }
14128
14163
  function osDefaultDir(env, platform, home) {
14129
14164
  switch (platform) {
14130
14165
  case "darwin":
14131
- return (0, import_node_path10.join)(home, "Library", "Application Support", APP_NAME);
14166
+ return (0, import_node_path9.join)(home, "Library", "Application Support", APP_NAME);
14132
14167
  case "win32": {
14133
14168
  const appdata = env.APPDATA;
14134
- return appdata !== void 0 && appdata.length > 0 ? (0, import_node_path10.join)(appdata, APP_NAME) : (0, import_node_path10.join)(home, "AppData", "Roaming", APP_NAME);
14169
+ return appdata !== void 0 && appdata.length > 0 ? (0, import_node_path9.join)(appdata, APP_NAME) : (0, import_node_path9.join)(home, "AppData", "Roaming", APP_NAME);
14135
14170
  }
14136
14171
  default: {
14137
14172
  const xdg = env.XDG_CONFIG_HOME;
14138
- return xdg !== void 0 && xdg.length > 0 ? (0, import_node_path10.join)(xdg, APP_NAME) : (0, import_node_path10.join)(home, ".config", APP_NAME);
14173
+ return xdg !== void 0 && xdg.length > 0 ? (0, import_node_path9.join)(xdg, APP_NAME) : (0, import_node_path9.join)(home, ".config", APP_NAME);
14139
14174
  }
14140
14175
  }
14141
14176
  }
14142
14177
 
14143
- // src/passphrase.ts
14178
+ // src/seed.ts
14144
14179
  var import_node_child_process2 = require("child_process");
14145
14180
  var import_node_crypto4 = require("crypto");
14146
- var SERVICE_NAME = "seal-mcp";
14147
- var ACCOUNT_NAME = "state";
14148
- var ENV_VAR = "SEAL_MCP_PASSPHRASE";
14149
- var KEYCHAIN_SERVICE = SERVICE_NAME;
14150
- var KEYCHAIN_ACCOUNT = ACCOUNT_NAME;
14151
- var PASSPHRASE_ENV_VAR = ENV_VAR;
14152
- var KeytarUnavailableError = class extends Error {
14153
- code = "keytar_unavailable";
14154
- constructor(detail) {
14155
- super(`keytar unavailable: ${detail}`);
14156
- this.name = "KeytarUnavailableError";
14157
- }
14158
- };
14159
- var PassphraseNotFoundError = class extends Error {
14160
- code = "passphrase_not_found";
14161
- constructor() {
14181
+ var import_node_fs6 = require("fs");
14182
+ var import_node_path10 = require("path");
14183
+ var SEED_ENV_VAR = "SEAL_SEED";
14184
+ var EPHEMERAL_ENV_VAR = "SEAL_MCP_EPHEMERAL";
14185
+ var PASSPHRASE_ENV_VAR = "SEAL_MCP_PASSPHRASE";
14186
+ var SEED_FILE_NAME = "seed";
14187
+ var KEYCHAIN_SERVICE = "seal-mcp";
14188
+ var KEYCHAIN_ACCOUNT = "seed";
14189
+ var LEGACY_KEYCHAIN_ACCOUNT = "state";
14190
+ var SeedInvalidError = class extends Error {
14191
+ code = "seed_invalid";
14192
+ constructor(where) {
14193
+ super(
14194
+ `${where} is not an agent key: expected 43 base64url characters, as \`sealnet-mcp seed --force\` or \`seal keygen --export --force\` print them`
14195
+ );
14196
+ this.name = "SeedInvalidError";
14197
+ }
14198
+ };
14199
+ var IdentityConflictError = class extends Error {
14200
+ code = "identity_conflict";
14201
+ constructor(statePath) {
14162
14202
  super(
14163
- `No passphrase found. Run \`sealnet-mcp init\` to store one in your OS keychain, or export ${ENV_VAR} for CI/headless use.`
14203
+ `${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.`
14164
14204
  );
14165
- this.name = "PassphraseNotFoundError";
14205
+ this.name = "IdentityConflictError";
14166
14206
  }
14167
14207
  };
14168
14208
  function keyringEntry(keyring, platform, service, account) {
@@ -14197,114 +14237,147 @@ async function loadKeytar() {
14197
14237
  try {
14198
14238
  const mod = await import("@napi-rs/keyring");
14199
14239
  const keyring = mod.default ?? mod;
14200
- keyringEntry(keyring, process.platform, SERVICE_NAME, ACCOUNT_NAME);
14240
+ keyringEntry(keyring, process.platform, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
14201
14241
  keytarCache = keytarOverKeyring(keyring);
14202
14242
  } catch {
14203
14243
  keytarCache = null;
14204
14244
  }
14205
14245
  return keytarCache;
14206
14246
  }
14207
- async function loadKeytarOrThrow() {
14208
- const k = await loadKeytar();
14209
- if (k === null) {
14210
- throw new KeytarUnavailableError(
14211
- "no OS keychain. On Linux start a Secret Service (gnome-keyring or KWallet); on macOS/Windows this should never happen \u2014 file a bug."
14212
- );
14247
+ async function keychainGet(account) {
14248
+ const keytar = await loadKeytar();
14249
+ if (keytar === null) return null;
14250
+ try {
14251
+ const value = await keytar.getPassword(KEYCHAIN_SERVICE, account);
14252
+ return value !== null && value.length > 0 ? value : null;
14253
+ } catch {
14254
+ return null;
14213
14255
  }
14214
- return k;
14215
14256
  }
14216
- async function resolvePassphraseFromStores(env = process.env) {
14257
+ async function keychainDelete(account) {
14258
+ const keytar = await loadKeytar();
14259
+ if (keytar === null) return false;
14260
+ try {
14261
+ return await keytar.deletePassword(KEYCHAIN_SERVICE, account);
14262
+ } catch {
14263
+ return false;
14264
+ }
14265
+ }
14266
+ function parseSeed(text, where) {
14267
+ const seed = Buffer.from(text, "base64url");
14268
+ if (!/^[A-Za-z0-9_-]{43}$/.test(text) || seed.toString("base64url") !== text) throw new SeedInvalidError(where);
14269
+ return new Uint8Array(seed);
14270
+ }
14271
+ async function readSeed(stateDir, env = process.env) {
14272
+ const fromEnv = env[SEED_ENV_VAR]?.trim();
14273
+ if (fromEnv) return { seed: parseSeed(fromEnv, SEED_ENV_VAR), source: "env" };
14274
+ const stored = await keychainGet(KEYCHAIN_ACCOUNT);
14275
+ if (stored !== null) return { seed: parseSeed(stored, `keychain entry ${KEYCHAIN_SERVICE}/${KEYCHAIN_ACCOUNT}`), source: "keychain" };
14276
+ const file = (0, import_node_path10.join)(stateDir, SEED_FILE_NAME);
14277
+ let text;
14278
+ try {
14279
+ text = await import_node_fs6.promises.readFile(file, "utf8");
14280
+ } catch (err2) {
14281
+ if (err2.code === "ENOENT") return null;
14282
+ throw err2;
14283
+ }
14284
+ return { seed: parseSeed(text.trim(), file), source: "file" };
14285
+ }
14286
+ async function storeSeed(stateDir, seed) {
14287
+ const text = Buffer.from(seed).toString("base64url");
14217
14288
  const keytar = await loadKeytar();
14218
14289
  if (keytar !== null) {
14219
14290
  try {
14220
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
14221
- if (stored !== null && stored.length > 0) {
14222
- return { passphrase: stored, source: "keychain" };
14223
- }
14291
+ await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, text);
14292
+ return "keychain";
14224
14293
  } catch {
14225
14294
  }
14226
14295
  }
14227
- const fromEnv = env[ENV_VAR];
14228
- if (fromEnv !== void 0 && fromEnv.length > 0) {
14229
- return { passphrase: fromEnv, source: "env" };
14296
+ const fh = await import_node_fs6.promises.open((0, import_node_path10.join)(stateDir, SEED_FILE_NAME), "wx", 384);
14297
+ try {
14298
+ await fh.writeFile(`${text}
14299
+ `, "utf8");
14300
+ await fh.sync();
14301
+ } finally {
14302
+ await fh.close();
14230
14303
  }
14231
- return null;
14304
+ return "file";
14232
14305
  }
14233
- async function probeKeychain() {
14234
- const keytar = await loadKeytar();
14235
- if (keytar === null) return { available: false, hasEntry: false };
14306
+ function statePassphrase(seed) {
14307
+ return Buffer.from((0, import_node_crypto4.hkdfSync)("sha256", seed, "seal-mcp:state:v1", "passphrase", 32)).toString("base64url");
14308
+ }
14309
+ async function legacyPassphrase(env) {
14310
+ const stored = await keychainGet(LEGACY_KEYCHAIN_ACCOUNT);
14311
+ if (stored !== null) return { passphrase: stored, source: "keychain" };
14312
+ const fromEnv = env[PASSPHRASE_ENV_VAR];
14313
+ return fromEnv ? { passphrase: fromEnv, source: "env" } : null;
14314
+ }
14315
+ async function tryOpen(passphrase, statePath) {
14236
14316
  try {
14237
- const v = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
14238
- return { available: true, hasEntry: v !== null && v.length > 0 };
14239
- } catch {
14240
- return { available: false, hasEntry: false };
14317
+ return await loadState(passphrase, statePath);
14318
+ } catch (err2) {
14319
+ if (err2 instanceof StatePassphraseInvalid) return null;
14320
+ throw err2;
14241
14321
  }
14242
14322
  }
14243
- async function setKeychainPassphrase(passphrase) {
14244
- if (passphrase.length === 0) {
14245
- throw new Error("refusing to store an empty passphrase in the keychain");
14246
- }
14247
- const keytar = await loadKeytarOrThrow();
14248
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, passphrase);
14323
+ async function requireIdentity(view, seed, statePath) {
14324
+ const { secret } = await loadIdentity(view.identity);
14325
+ if (!Buffer.from(secret).equals(Buffer.from(seed))) throw new IdentityConflictError(statePath);
14326
+ }
14327
+ function openAgentState(path, env = process.env) {
14328
+ return withStateLock(path.dir, async () => {
14329
+ let root = await readSeed(path.dir, env);
14330
+ if (!await import_node_fs6.promises.stat(path.statePath).then(() => true, () => false)) {
14331
+ if (root === null) {
14332
+ const { secret } = await generateIdentity();
14333
+ root = { seed: secret, source: await storeSeed(path.dir, secret) };
14334
+ }
14335
+ const passphrase2 = statePassphrase(root.seed);
14336
+ const identity = await identityFromSeed(root.seed);
14337
+ await saveState(createEmptyState(identityToFields(identity)), passphrase2, path.statePath);
14338
+ return { root, passphrase: passphrase2, created: true, moved: null };
14339
+ }
14340
+ if (root !== null) {
14341
+ const view2 = await tryOpen(statePassphrase(root.seed), path.statePath);
14342
+ if (view2 !== null) {
14343
+ await requireIdentity(view2, root.seed, path.statePath);
14344
+ return { root, passphrase: statePassphrase(root.seed), created: false, moved: null };
14345
+ }
14346
+ }
14347
+ const legacy = await legacyPassphrase(env);
14348
+ const view = legacy === null ? null : await tryOpen(legacy.passphrase, path.statePath);
14349
+ if (legacy === null || view === null) throw new IdentityConflictError(path.statePath);
14350
+ if (root !== null) await requireIdentity(view, root.seed, path.statePath);
14351
+ else {
14352
+ const { secret } = await loadIdentity(view.identity);
14353
+ root = { seed: secret, source: await storeSeed(path.dir, secret) };
14354
+ }
14355
+ const passphrase = statePassphrase(root.seed);
14356
+ await saveState(view, passphrase, path.statePath);
14357
+ await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT);
14358
+ return { root, passphrase, created: false, moved: legacy.source };
14359
+ });
14249
14360
  }
14250
- async function deleteKeychainPassphrase() {
14361
+ async function deleteRootSecret(stateDir) {
14362
+ let seedFile = true;
14363
+ await import_node_fs6.promises.unlink((0, import_node_path10.join)(stateDir, SEED_FILE_NAME)).catch((err2) => {
14364
+ if (err2.code !== "ENOENT") throw err2;
14365
+ seedFile = false;
14366
+ });
14367
+ const seed = await keychainDelete(KEYCHAIN_ACCOUNT);
14368
+ const legacy = await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT);
14369
+ return { seedFile, keychain: seed || legacy };
14370
+ }
14371
+ async function probeKeychain() {
14251
14372
  const keytar = await loadKeytar();
14252
- if (keytar === null) return false;
14373
+ if (keytar === null) return { available: false, hasSeed: false };
14253
14374
  try {
14254
- return await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
14375
+ const value = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
14376
+ return { available: true, hasSeed: value !== null && value.length > 0 };
14255
14377
  } catch {
14256
- return false;
14378
+ return { available: false, hasSeed: false };
14257
14379
  }
14258
14380
  }
14259
- async function promptHiddenPassphrase(prompt) {
14260
- const stdin = process.stdin;
14261
- if (!stdin.isTTY) {
14262
- throw new Error(
14263
- `Cannot prompt for passphrase: stdin is not a TTY. Set ${ENV_VAR} for non-interactive use.`
14264
- );
14265
- }
14266
- process.stdout.write(prompt);
14267
- const wasRaw = stdin.isRaw;
14268
- stdin.setRawMode(true);
14269
- stdin.resume();
14270
- stdin.setEncoding("utf8");
14271
- return new Promise((resolve5, reject) => {
14272
- let buf = "";
14273
- const cleanup = () => {
14274
- stdin.removeListener("data", onData);
14275
- stdin.setRawMode(wasRaw);
14276
- stdin.pause();
14277
- process.stdout.write("\n");
14278
- };
14279
- const onData = (chunk) => {
14280
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
14281
- for (let i = 0; i < text.length; i++) {
14282
- const ch = text[i];
14283
- if (ch === "") {
14284
- cleanup();
14285
- reject(new Error("cancelled"));
14286
- return;
14287
- }
14288
- if (ch === "" && buf.length === 0) {
14289
- cleanup();
14290
- reject(new Error("cancelled"));
14291
- return;
14292
- }
14293
- if (ch === "\r" || ch === "\n") {
14294
- cleanup();
14295
- resolve5(buf);
14296
- return;
14297
- }
14298
- if (ch === "\x7F" || ch === "\b") {
14299
- buf = buf.slice(0, -1);
14300
- continue;
14301
- }
14302
- buf += ch;
14303
- }
14304
- };
14305
- stdin.on("data", onData);
14306
- });
14307
- }
14308
14381
  // Annotate the CommonJS export names for ESM import in node:
14309
14382
  0 && (module.exports = {
14310
14383
  APP_NAME,
@@ -14312,16 +14385,20 @@ async function promptHiddenPassphrase(prompt) {
14312
14385
  BackendError,
14313
14386
  DEFAULT_BACKEND_URL,
14314
14387
  DEFAULT_PUBLIC_HOST,
14388
+ EPHEMERAL_ENV_VAR,
14315
14389
  HandoffBothChannelsFailedError,
14390
+ IdentityConflictError,
14316
14391
  KEYCHAIN_ACCOUNT,
14317
14392
  KEYCHAIN_SERVICE,
14318
- KeytarUnavailableError,
14319
14393
  LOCK_FILE_NAME,
14320
14394
  PASSPHRASE_ENV_VAR,
14321
14395
  PUBLIC_HOST_ENV_VAR,
14322
- PassphraseNotFoundError,
14396
+ SEED_ENV_VAR,
14397
+ SEED_FILE_NAME,
14323
14398
  STATE_FILE_NAME,
14324
14399
  SealApi,
14400
+ SeedInvalidError,
14401
+ StateBusyError,
14325
14402
  StateCorrupt,
14326
14403
  StateLockedError,
14327
14404
  StatePassphraseInvalid,
@@ -14329,20 +14406,22 @@ async function promptHiddenPassphrase(prompt) {
14329
14406
  acquireStateLock,
14330
14407
  createEmptyState,
14331
14408
  createServer,
14332
- deleteKeychainPassphrase,
14409
+ deleteRootSecret,
14333
14410
  generateHandle,
14334
14411
  generateIdentity,
14412
+ holderMayRun,
14413
+ identityFromSeed,
14335
14414
  identityToFields,
14336
14415
  isValidHandle,
14337
14416
  loadIdentity,
14338
14417
  loadState,
14418
+ openAgentState,
14339
14419
  performHandoff,
14340
14420
  probeKeychain,
14341
14421
  probeStateLock,
14342
- promptHiddenPassphrase,
14422
+ readSeed,
14343
14423
  recoverPendingOperations,
14344
14424
  resolveBackendUrl,
14345
- resolvePassphraseFromStores,
14346
14425
  resolvePublicHost,
14347
14426
  resolveStateDir,
14348
14427
  saveState,
@@ -14350,7 +14429,9 @@ async function promptHiddenPassphrase(prompt) {
14350
14429
  sealOpen,
14351
14430
  sealRevoke,
14352
14431
  sealShare,
14353
- setKeychainPassphrase
14432
+ statePassphrase,
14433
+ updateState,
14434
+ withStateLock
14354
14435
  });
14355
14436
  /*! Bundled license information:
14356
14437