sealnet-mcp 0.2.5 → 0.4.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/cli.cjs CHANGED
@@ -34,8 +34,9 @@ __export(cli_exports, {
34
34
  main: () => main
35
35
  });
36
36
  module.exports = __toCommonJS(cli_exports);
37
- var import_node_fs6 = require("fs");
37
+ var import_node_fs7 = require("fs");
38
38
  var import_promises10 = require("fs/promises");
39
+ var import_node_path11 = require("path");
39
40
  var import_commander = require("commander");
40
41
 
41
42
  // ../../../node_modules/.pnpm/@noble+hashes@1.8.0/node_modules/@noble/hashes/esm/utils.js
@@ -2889,9 +2890,60 @@ var limits_default = {
2889
2890
  INTAKE_TTL_DEFAULT_SECONDS: 900,
2890
2891
  INTAKE_TTL_MAX_SECONDS: 86400,
2891
2892
  INTAKE_WAIT_MAX_SECONDS: 25,
2892
- PAYMENT_WAIT_MAX_SECONDS: 82800
2893
+ PAYMENT_WAIT_MAX_SECONDS: 82800,
2894
+ ADDRESS_PROOF_TTL_SECONDS: 300,
2895
+ INBOX_MAX_ITEMS: 200
2893
2896
  }
2894
2897
  };
2898
+ var seal_address_v1_default = {
2899
+ version: 1,
2900
+ $comment: "Address of a key: who a seal is for, without a URL (audit/SPEC-ADDRESS.md). Numbers live in limits.json, error codes in error_codes.json.",
2901
+ address: {
2902
+ key: "X25519 public key, 32 bytes",
2903
+ text: "base64url without padding, 43 characters",
2904
+ emoji: "fingerprint_emoji.json over sha256(key)[0..3], the same picture as intake",
2905
+ inbox_hash: "sha256(key), 32 bytes: the server finds an inbox by it"
2906
+ },
2907
+ seed: {
2908
+ env: "SEAL_SEED",
2909
+ text: "base64url without padding of the 32-byte X25519 secret, 43 characters",
2910
+ same_as: [
2911
+ "sealnet-mcp state.identity.x25519_secret_b64",
2912
+ "seal identity.v1.json x25519_secret_b64url",
2913
+ "sealnet (npm) KeyPair.secretKey",
2914
+ "sealnet (PyPI) keygen().secret_key"
2915
+ ]
2916
+ },
2917
+ scopes: {
2918
+ share_link: "seal_share",
2919
+ owner_copy: "seal_owner",
2920
+ address_proof: "seal_address_proof"
2921
+ },
2922
+ proof: {
2923
+ header_address: "X-Seal-Address",
2924
+ header_proof: "X-Seal-Proof",
2925
+ proof_text: "<ts>.<token>: ts is the decimal Unix second from the challenge, token base64url of 32 bytes",
2926
+ challenge: "sealed box of the 32-byte token to the address with scope (address_proof, decimal ts): ephemeral_pub(32) || nonce(12) || ciphertext(48), base64url, 123 characters",
2927
+ token: "opaque to clients: unwrap it and send it back"
2928
+ },
2929
+ items: {
2930
+ delivery: {
2931
+ id: "s.<seal_id>.<share_code>",
2932
+ is: "a share link addressed to this key; fragment = its key wrapped to the address (x25519:, targeted_x25519_v1, scope share_link)"
2933
+ }
2934
+ },
2935
+ request_kinds: ["secret", "file", "any"],
2936
+ vectors: [
2937
+ {
2938
+ comment: "generated once by backend_crypto.sealed_box.seal_key_for_recipient; every client must unwrap it",
2939
+ secret: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc",
2940
+ address: "E75P6uryBMf9M1j8nAByGIHRdCeBKCJ-xnTzf3_pe20",
2941
+ ts: 179e7,
2942
+ challenge: "4Oel-UMfgP_1dBhmf13yZiujhPDouCIQ6sSx1nY1qy-vyzFqDqb9jARbJ0e5A8vv4EDELDS5OPqBQMcsmDA-C_lXO95y_CVUyYtSu-1fltsy5ZihOFDp0T-C9Ro",
2943
+ token: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
2944
+ }
2945
+ ]
2946
+ };
2895
2947
  var upload_default = {
2896
2948
  version: 1,
2897
2949
  upload: {
@@ -3223,13 +3275,24 @@ var SealApi = class _SealApi {
3223
3275
  });
3224
3276
  return { etag: resp.headers.get("ETag") };
3225
3277
  }
3226
- /** `POST /seal/{id}/share` — mint a share link, returns its code. */
3278
+ /**
3279
+ * `POST /seal/{id}/share` — mint a share link, returns its code. With an
3280
+ * address the link also lands in that key's inbox, in the same request.
3281
+ */
3227
3282
  async createShareLink(sealId, ownerToken, encryptedBlobB64, opts = {}) {
3283
+ const headers = _SealApi.ownerHeaders(ownerToken, true);
3284
+ const body = { encrypted_blob: encryptedBlobB64 };
3285
+ if ("to" in opts) {
3286
+ body.to = opts.to;
3287
+ body.fragment = opts.fragment;
3288
+ if (opts.label) body.label = opts.label;
3289
+ if (opts.sender) Object.assign(headers, proofHeaders(opts.sender));
3290
+ }
3228
3291
  const resp = await this.request(`/seal/${encode(sealId)}/share`, {
3229
3292
  what: `POST /seal/${sealId}/share`,
3230
3293
  method: "POST",
3231
- headers: _SealApi.ownerHeaders(ownerToken, true),
3232
- body: JSON.stringify({ encrypted_blob: encryptedBlobB64 }),
3294
+ headers,
3295
+ body: JSON.stringify(body),
3233
3296
  signal: opts.signal
3234
3297
  });
3235
3298
  const data = await resp.json();
@@ -3412,8 +3475,57 @@ var SealApi = class _SealApi {
3412
3475
  });
3413
3476
  return await resp.json();
3414
3477
  }
3478
+ // ---------------------------------------------------------------------------
3479
+ // Inbox (SPEC-ADDRESS F2): the share links addressed to a key
3480
+ // ---------------------------------------------------------------------------
3481
+ /** `POST /intake/proof` — a challenge sealed to `address`. */
3482
+ async requestAddressProof(address, opts = {}) {
3483
+ const resp = await this.request("/intake/proof", {
3484
+ what: "POST /intake/proof",
3485
+ method: "POST",
3486
+ headers: { "Content-Type": "application/json" },
3487
+ body: JSON.stringify({ address }),
3488
+ signal: opts.signal
3489
+ });
3490
+ return await resp.json();
3491
+ }
3492
+ /**
3493
+ * `GET /intake?to=` — every live item. `waitSeconds` holds the answer
3494
+ * while the inbox is empty, or, with `seen`, while its version is still
3495
+ * `seen`; one call waits at most `INTAKE_WAIT_MAX_SECONDS`, longer waits
3496
+ * are the caller's loop.
3497
+ */
3498
+ async listInbox(proof, opts = {}) {
3499
+ const params = new URLSearchParams({ to: proof.address });
3500
+ if (opts.waitSeconds) {
3501
+ params.set("wait", String(Math.min(opts.waitSeconds, limits_default.limits.INTAKE_WAIT_MAX_SECONDS)));
3502
+ }
3503
+ if (opts.seen) params.set("seen", opts.seen);
3504
+ const resp = await this.request(`/intake?${params.toString()}`, {
3505
+ what: "GET /intake",
3506
+ headers: proofHeaders(proof),
3507
+ signal: opts.signal
3508
+ });
3509
+ return await resp.json();
3510
+ }
3511
+ /** `DELETE /intake?to=&item=` — take one item out; the link keeps working. */
3512
+ async dismissInboxItem(itemId, proof, opts = {}) {
3513
+ const params = new URLSearchParams({ to: proof.address, item: itemId });
3514
+ await this.request(`/intake?${params.toString()}`, {
3515
+ what: "DELETE /intake",
3516
+ method: "DELETE",
3517
+ headers: proofHeaders(proof),
3518
+ signal: opts.signal
3519
+ });
3520
+ }
3415
3521
  };
3416
3522
  var NOT_READY_DEFAULT_RETRY_AFTER_S = 3;
3523
+ function proofHeaders(proof) {
3524
+ return {
3525
+ [seal_address_v1_default.proof.header_address]: proof.address,
3526
+ [seal_address_v1_default.proof.header_proof]: proof.proof
3527
+ };
3528
+ }
3417
3529
  function abortableSleep(ms, signal) {
3418
3530
  return new Promise((resolve5, reject) => {
3419
3531
  if (signal?.aborted) {
@@ -6178,6 +6290,7 @@ function parseUrlType(pathname) {
6178
6290
  var TARGETED_PREFIX_V1 = "x25519:";
6179
6291
  var TARGETED_PREFIX_V2_RESERVED = "x25519v2:";
6180
6292
  var SCOPE_TYPE = "seal_share";
6293
+ var ADDRESS_PROOF_SCOPE = "seal_address_proof";
6181
6294
  var X25519_KEY_BYTES = 32;
6182
6295
  var LINK_KEY_BYTES2 = 32;
6183
6296
  var PACKED_BYTES_V1 = 92;
@@ -6284,6 +6397,11 @@ async function unwrapTargetedFragment(fragment, recipientSecret, sealId) {
6284
6397
  }
6285
6398
  return linkKey;
6286
6399
  }
6400
+ async function proveAddress(secret, challenge, ts) {
6401
+ const wasm3 = await ensureWasm();
6402
+ const token = wasm3.wasm_sealbox_unwrap(base64urlToBytes(challenge), secret, ADDRESS_PROOF_SCOPE, String(ts));
6403
+ return `${ts}.${bytesToBase64url(token)}`;
6404
+ }
6287
6405
  var fingerprint_emoji_default = {
6288
6406
  version: 1,
6289
6407
  name: "FINGERPRINT_EMOJI",
@@ -6743,6 +6861,36 @@ async function waitForIntake(api, intakeId, opts = {}) {
6743
6861
  if (view.status === "fulfilled" && view.share_path) return view.share_path;
6744
6862
  }
6745
6863
  }
6864
+ var PROOF_MARGIN_MS = 3e4;
6865
+ var proofs = /* @__PURE__ */ new Map();
6866
+ async function addressProof(api, secret, opts = {}) {
6867
+ const address = bytesToBase64url(await recipientPublicKey(secret));
6868
+ const key = `${api.baseUrl} ${address}`;
6869
+ const kept = proofs.get(key);
6870
+ if (!opts.fresh && kept && kept.until > Date.now()) return kept.proof;
6871
+ const challenge = await api.requestAddressProof(address, opts);
6872
+ const proof = { address, proof: await proveAddress(secret, challenge.challenge, challenge.ts) };
6873
+ proofs.set(key, { proof, until: Date.parse(challenge.expires_at) - PROOF_MARGIN_MS });
6874
+ return proof;
6875
+ }
6876
+ async function withAddressProof(api, secret, signal, call) {
6877
+ const request = signal ? { signal } : {};
6878
+ try {
6879
+ return await call(await addressProof(api, secret, request));
6880
+ } catch (err2) {
6881
+ if (!(err2 instanceof BackendError && err2.appCode === "intake.proof_required")) throw err2;
6882
+ return call(await addressProof(api, secret, { ...request, fresh: true }));
6883
+ }
6884
+ }
6885
+ async function readInbox(api, secret, opts = {}) {
6886
+ return withAddressProof(api, secret, opts.signal, (proof) => api.listInbox(proof, opts));
6887
+ }
6888
+ async function removeFromInbox(api, secret, itemId, opts = {}) {
6889
+ return withAddressProof(api, secret, opts.signal, (proof) => api.dismissInboxItem(itemId, proof, opts));
6890
+ }
6891
+ function inboxItemUrl(site, item) {
6892
+ return `${site.replace(/\/+$/, "")}${item.path}#${item.fragment}`;
6893
+ }
6746
6894
  async function describeFile(file, fileKeys) {
6747
6895
  const keyB64 = file.sha256_hex ? fileKeys[file.sha256_hex] : void 0;
6748
6896
  const base = { id: file.id, size: file.size, sha256Hex: file.sha256_hex };
@@ -6901,6 +7049,9 @@ async function generateIdentity() {
6901
7049
  const { publicKey, secretKey } = await generateRecipientKeyPair();
6902
7050
  return { secret: secretKey, public: publicKey };
6903
7051
  }
7052
+ async function identityFromSeed(seed) {
7053
+ return { secret: seed, public: await recipientPublicKey(seed) };
7054
+ }
6904
7055
  function identityToFields(id) {
6905
7056
  return {
6906
7057
  x25519_secret_b64: toB64(id.secret),
@@ -6934,20 +7085,27 @@ var import_node_crypto = require("crypto");
6934
7085
  var import_node_os = require("os");
6935
7086
  var import_node_path = require("path");
6936
7087
  var LOCK_FILE_NAME = "state.lock";
7088
+ var LOCK_WAIT_MS = 5e3;
7089
+ var LOCK_RETRY_MS = 25;
6937
7090
  var StateLockedError = class extends Error {
6938
7091
  code = "state_locked";
6939
7092
  holder;
6940
7093
  lockPath;
6941
7094
  constructor(lockPath, holder) {
6942
7095
  const who = holder === null ? "holder info unreadable" : `pid ${holder.pid} on ${holder.hostname} since ${holder.acquired_at}`;
6943
- super(
6944
- `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}`
6945
- );
7096
+ super(`state directory is locked by another sealnet-mcp process (${who}). Lock file: ${lockPath}`);
6946
7097
  this.name = "StateLockedError";
6947
7098
  this.holder = holder;
6948
7099
  this.lockPath = lockPath;
6949
7100
  }
6950
7101
  };
7102
+ var StateBusyError = class extends Error {
7103
+ code = "state_busy";
7104
+ constructor(locked) {
7105
+ super(`${locked.message}; waited ${LOCK_WAIT_MS / 1e3} s, nothing was changed, safe to retry`);
7106
+ this.name = "StateBusyError";
7107
+ }
7108
+ };
6951
7109
  var StateDirUnsafeError = class extends Error {
6952
7110
  code = "state_dir_unsafe";
6953
7111
  constructor(stateDir, reason) {
@@ -6966,6 +7124,9 @@ function defaultIsPidAlive(pid) {
6966
7124
  return code === "EPERM";
6967
7125
  }
6968
7126
  }
7127
+ function holderMayRun(holder, deps = {}) {
7128
+ return holder.hostname !== (deps.host ?? (0, import_node_os.hostname)()) || (deps.isPidAlive ?? defaultIsPidAlive)(holder.pid);
7129
+ }
6969
7130
  function parseHolder(raw) {
6970
7131
  try {
6971
7132
  const parsed = JSON.parse(raw);
@@ -7008,7 +7169,7 @@ async function tryCreate(lockPath, info) {
7008
7169
  await (0, import_promises.unlink)(tmp).catch(() => void 0);
7009
7170
  }
7010
7171
  }
7011
- async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
7172
+ async function tryBreakStale(lockPath, probed, deps) {
7012
7173
  const claimPath = `${lockPath}.break-${process.pid}-${(0, import_node_crypto.randomBytes)(4).toString("hex")}`;
7013
7174
  try {
7014
7175
  await (0, import_promises.rename)(lockPath, claimPath);
@@ -7016,8 +7177,7 @@ async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
7016
7177
  return true;
7017
7178
  }
7018
7179
  const claimed = parseHolder(await (0, import_promises.readFile)(claimPath, "utf8").catch(() => ""));
7019
- const claimedStale = claimed === null || claimed.hostname === selfHost && !isAlive(claimed.pid);
7020
- if (sameHolder(claimed, probed) || claimedStale) {
7180
+ if (sameHolder(claimed, probed) || claimed === null || !holderMayRun(claimed, deps)) {
7021
7181
  await (0, import_promises.unlink)(claimPath).catch(() => void 0);
7022
7182
  return true;
7023
7183
  }
@@ -7030,11 +7190,9 @@ async function tryBreakStale(lockPath, probed, isAlive, selfHost) {
7030
7190
  }
7031
7191
  async function acquireStateLock(stateDir, deps = {}) {
7032
7192
  const lockPath = (0, import_node_path.join)(stateDir, LOCK_FILE_NAME);
7033
- const isAlive = deps.isPidAlive ?? defaultIsPidAlive;
7034
- const selfHost = deps.host ?? (0, import_node_os.hostname)();
7035
7193
  const self2 = {
7036
7194
  pid: deps.pid ?? process.pid,
7037
- hostname: selfHost,
7195
+ hostname: deps.host ?? (0, import_node_os.hostname)(),
7038
7196
  acquired_at: (/* @__PURE__ */ new Date()).toISOString()
7039
7197
  };
7040
7198
  await (0, import_promises.mkdir)(stateDir, { recursive: true, mode: 448 });
@@ -7045,11 +7203,10 @@ async function acquireStateLock(stateDir, deps = {}) {
7045
7203
  await (0, import_promises.chmod)(stateDir, 448).catch(() => void 0);
7046
7204
  if (!await tryCreate(lockPath, self2)) {
7047
7205
  const holder = await readHolder(lockPath);
7048
- const stale = holder !== null && holder.hostname === selfHost && !isAlive(holder.pid);
7049
- if (holder !== null && !stale) {
7206
+ if (holder !== null && holderMayRun(holder, deps)) {
7050
7207
  throw new StateLockedError(lockPath, holder);
7051
7208
  }
7052
- if (!await tryBreakStale(lockPath, holder, isAlive, selfHost)) {
7209
+ if (!await tryBreakStale(lockPath, holder, deps)) {
7053
7210
  throw new StateLockedError(lockPath, await readHolder(lockPath));
7054
7211
  }
7055
7212
  if (!await tryCreate(lockPath, self2)) {
@@ -7081,6 +7238,30 @@ async function acquireStateLock(stateDir, deps = {}) {
7081
7238
  }
7082
7239
  };
7083
7240
  }
7241
+ var queues = /* @__PURE__ */ new Map();
7242
+ function withStateLock(stateDir, fn, deps = {}) {
7243
+ const run = (queues.get(stateDir) ?? Promise.resolve()).catch(() => void 0).then(async () => {
7244
+ const deadline = Date.now() + LOCK_WAIT_MS;
7245
+ let lock;
7246
+ for (; ; ) {
7247
+ try {
7248
+ lock = await acquireStateLock(stateDir, deps);
7249
+ break;
7250
+ } catch (err2) {
7251
+ if (!(err2 instanceof StateLockedError)) throw err2;
7252
+ if (Date.now() >= deadline) throw new StateBusyError(err2);
7253
+ await new Promise((resolve5) => setTimeout(resolve5, LOCK_RETRY_MS));
7254
+ }
7255
+ }
7256
+ try {
7257
+ return await fn();
7258
+ } finally {
7259
+ await lock.release();
7260
+ }
7261
+ });
7262
+ queues.set(stateDir, run);
7263
+ return run;
7264
+ }
7084
7265
  async function probeStateLock(stateDir, deps = {}) {
7085
7266
  const lockPath = (0, import_node_path.join)(stateDir, LOCK_FILE_NAME);
7086
7267
  const holder = await readHolder(lockPath);
@@ -7092,182 +7273,7 @@ async function probeStateLock(stateDir, deps = {}) {
7092
7273
  return { held: false, holder: null, stale: false };
7093
7274
  }
7094
7275
  }
7095
- const isAlive = deps.isPidAlive ?? defaultIsPidAlive;
7096
- const sameHost = holder.hostname === (deps.host ?? (0, import_node_os.hostname)());
7097
- const stale = sameHost && !isAlive(holder.pid);
7098
- return { held: true, holder, stale };
7099
- }
7100
-
7101
- // src/passphrase.ts
7102
- var import_node_child_process = require("child_process");
7103
- var import_node_crypto2 = require("crypto");
7104
- var SERVICE_NAME = "seal-mcp";
7105
- var ACCOUNT_NAME = "state";
7106
- var ENV_VAR = "SEAL_MCP_PASSPHRASE";
7107
- var KEYCHAIN_SERVICE = SERVICE_NAME;
7108
- var KEYCHAIN_ACCOUNT = ACCOUNT_NAME;
7109
- var PASSPHRASE_ENV_VAR = ENV_VAR;
7110
- var KeytarUnavailableError = class extends Error {
7111
- code = "keytar_unavailable";
7112
- constructor(detail) {
7113
- super(`keytar unavailable: ${detail}`);
7114
- this.name = "KeytarUnavailableError";
7115
- }
7116
- };
7117
- var PassphraseNotFoundError = class extends Error {
7118
- code = "passphrase_not_found";
7119
- constructor() {
7120
- super(
7121
- `No passphrase found. Run \`sealnet-mcp init\` to store one in your OS keychain, or export ${ENV_VAR} for CI/headless use.`
7122
- );
7123
- this.name = "PassphraseNotFoundError";
7124
- }
7125
- };
7126
- function keyringEntry(keyring, platform, service, account) {
7127
- return platform === "win32" ? keyring.AsyncEntry.withTarget(`${service}/${account}`, service, account) : new keyring.AsyncEntry(service, account, { linux: { store: "secret-service" } });
7128
- }
7129
- function readKeytarLinuxEntry(service, account) {
7130
- return new Promise((resolve5) => {
7131
- (0, import_node_child_process.execFile)("secret-tool", ["lookup", "service", service, "account", account], { timeout: 5e3 }, (err2, stdout) => {
7132
- const value = err2 ? "" : stdout.replace(/\n$/, "");
7133
- resolve5(value.length > 0 ? value : null);
7134
- });
7135
- });
7136
- }
7137
- function keytarOverKeyring(keyring, platform = process.platform, readLegacy = readKeytarLinuxEntry) {
7138
- const entry = (service, account) => keyringEntry(keyring, platform, service, account);
7139
- const store = (service, account, password) => entry(service, account).setSecret(Buffer.from(password, "utf8"));
7140
- return {
7141
- async getPassword(service, account) {
7142
- const secret = await entry(service, account).getSecret();
7143
- if (secret !== void 0) return Buffer.from(secret).toString("utf8");
7144
- const legacy = platform === "linux" ? await readLegacy(service, account) : null;
7145
- if (legacy !== null) await store(service, account, legacy).catch(() => void 0);
7146
- return legacy;
7147
- },
7148
- setPassword: store,
7149
- deletePassword: (service, account) => entry(service, account).deleteCredential()
7150
- };
7151
- }
7152
- var keytarCache = void 0;
7153
- async function loadKeytar() {
7154
- if (keytarCache !== void 0) return keytarCache;
7155
- try {
7156
- const mod = await import("@napi-rs/keyring");
7157
- const keyring = mod.default ?? mod;
7158
- keyringEntry(keyring, process.platform, SERVICE_NAME, ACCOUNT_NAME);
7159
- keytarCache = keytarOverKeyring(keyring);
7160
- } catch {
7161
- keytarCache = null;
7162
- }
7163
- return keytarCache;
7164
- }
7165
- async function loadKeytarOrThrow() {
7166
- const k = await loadKeytar();
7167
- if (k === null) {
7168
- throw new KeytarUnavailableError(
7169
- "no OS keychain. On Linux start a Secret Service (gnome-keyring or KWallet); on macOS/Windows this should never happen \u2014 file a bug."
7170
- );
7171
- }
7172
- return k;
7173
- }
7174
- async function resolvePassphraseFromStores(env = process.env) {
7175
- const keytar = await loadKeytar();
7176
- if (keytar !== null) {
7177
- try {
7178
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
7179
- if (stored !== null && stored.length > 0) {
7180
- return { passphrase: stored, source: "keychain" };
7181
- }
7182
- } catch {
7183
- }
7184
- }
7185
- const fromEnv = env[ENV_VAR];
7186
- if (fromEnv !== void 0 && fromEnv.length > 0) {
7187
- return { passphrase: fromEnv, source: "env" };
7188
- }
7189
- return null;
7190
- }
7191
- async function passphraseForNewState(env = process.env) {
7192
- const stored = await resolvePassphraseFromStores(env);
7193
- if (stored !== null) return { ...stored, fresh: false };
7194
- if (!(await probeKeychain()).available) return null;
7195
- return { passphrase: (0, import_node_crypto2.randomBytes)(32).toString("base64url"), source: "keychain", fresh: true };
7196
- }
7197
- async function probeKeychain() {
7198
- const keytar = await loadKeytar();
7199
- if (keytar === null) return { available: false, hasEntry: false };
7200
- try {
7201
- const v = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
7202
- return { available: true, hasEntry: v !== null && v.length > 0 };
7203
- } catch {
7204
- return { available: false, hasEntry: false };
7205
- }
7206
- }
7207
- async function setKeychainPassphrase(passphrase) {
7208
- if (passphrase.length === 0) {
7209
- throw new Error("refusing to store an empty passphrase in the keychain");
7210
- }
7211
- const keytar = await loadKeytarOrThrow();
7212
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, passphrase);
7213
- }
7214
- async function deleteKeychainPassphrase() {
7215
- const keytar = await loadKeytar();
7216
- if (keytar === null) return false;
7217
- try {
7218
- return await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
7219
- } catch {
7220
- return false;
7221
- }
7222
- }
7223
- async function promptHiddenPassphrase(prompt) {
7224
- const stdin = process.stdin;
7225
- if (!stdin.isTTY) {
7226
- throw new Error(
7227
- `Cannot prompt for passphrase: stdin is not a TTY. Set ${ENV_VAR} for non-interactive use.`
7228
- );
7229
- }
7230
- process.stdout.write(prompt);
7231
- const wasRaw = stdin.isRaw;
7232
- stdin.setRawMode(true);
7233
- stdin.resume();
7234
- stdin.setEncoding("utf8");
7235
- return new Promise((resolve5, reject) => {
7236
- let buf = "";
7237
- const cleanup = () => {
7238
- stdin.removeListener("data", onData);
7239
- stdin.setRawMode(wasRaw);
7240
- stdin.pause();
7241
- process.stdout.write("\n");
7242
- };
7243
- const onData = (chunk) => {
7244
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
7245
- for (let i = 0; i < text.length; i++) {
7246
- const ch = text[i];
7247
- if (ch === "") {
7248
- cleanup();
7249
- reject(new Error("cancelled"));
7250
- return;
7251
- }
7252
- if (ch === "" && buf.length === 0) {
7253
- cleanup();
7254
- reject(new Error("cancelled"));
7255
- return;
7256
- }
7257
- if (ch === "\r" || ch === "\n") {
7258
- cleanup();
7259
- resolve5(buf);
7260
- return;
7261
- }
7262
- if (ch === "\x7F" || ch === "\b") {
7263
- buf = buf.slice(0, -1);
7264
- continue;
7265
- }
7266
- buf += ch;
7267
- }
7268
- };
7269
- stdin.on("data", onData);
7270
- });
7276
+ return { held: true, holder, stale: !holderMayRun(holder, deps) };
7271
7277
  }
7272
7278
 
7273
7279
  // src/paths.ts
@@ -7310,99 +7316,533 @@ function osDefaultDir(env, platform, home) {
7310
7316
  }
7311
7317
  }
7312
7318
 
7313
- // src/server.ts
7314
- var import_node_path10 = require("path");
7315
- var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
7316
- var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
7319
+ // src/seed.ts
7320
+ var import_node_child_process = require("child_process");
7321
+ var import_node_crypto3 = require("crypto");
7322
+ var import_node_fs3 = require("fs");
7323
+ var import_node_path4 = require("path");
7317
7324
 
7318
- // ../../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
7319
- var external_exports = {};
7320
- __export(external_exports, {
7321
- BRAND: () => BRAND,
7322
- DIRTY: () => DIRTY,
7323
- EMPTY_PATH: () => EMPTY_PATH,
7324
- INVALID: () => INVALID,
7325
- NEVER: () => NEVER,
7326
- OK: () => OK,
7327
- ParseStatus: () => ParseStatus,
7328
- Schema: () => ZodType,
7329
- ZodAny: () => ZodAny,
7330
- ZodArray: () => ZodArray,
7331
- ZodBigInt: () => ZodBigInt,
7332
- ZodBoolean: () => ZodBoolean,
7333
- ZodBranded: () => ZodBranded,
7334
- ZodCatch: () => ZodCatch,
7335
- ZodDate: () => ZodDate,
7336
- ZodDefault: () => ZodDefault,
7337
- ZodDiscriminatedUnion: () => ZodDiscriminatedUnion,
7338
- ZodEffects: () => ZodEffects,
7339
- ZodEnum: () => ZodEnum,
7340
- ZodError: () => ZodError,
7341
- ZodFirstPartyTypeKind: () => ZodFirstPartyTypeKind,
7342
- ZodFunction: () => ZodFunction,
7343
- ZodIntersection: () => ZodIntersection,
7344
- ZodIssueCode: () => ZodIssueCode,
7345
- ZodLazy: () => ZodLazy,
7346
- ZodLiteral: () => ZodLiteral,
7347
- ZodMap: () => ZodMap,
7348
- ZodNaN: () => ZodNaN,
7349
- ZodNativeEnum: () => ZodNativeEnum,
7350
- ZodNever: () => ZodNever,
7351
- ZodNull: () => ZodNull,
7352
- ZodNullable: () => ZodNullable,
7353
- ZodNumber: () => ZodNumber,
7354
- ZodObject: () => ZodObject,
7355
- ZodOptional: () => ZodOptional,
7356
- ZodParsedType: () => ZodParsedType,
7357
- ZodPipeline: () => ZodPipeline,
7358
- ZodPromise: () => ZodPromise,
7359
- ZodReadonly: () => ZodReadonly,
7360
- ZodRecord: () => ZodRecord,
7361
- ZodSchema: () => ZodType,
7362
- ZodSet: () => ZodSet,
7363
- ZodString: () => ZodString,
7364
- ZodSymbol: () => ZodSymbol,
7365
- ZodTransformer: () => ZodEffects,
7366
- ZodTuple: () => ZodTuple,
7367
- ZodType: () => ZodType,
7368
- ZodUndefined: () => ZodUndefined,
7369
- ZodUnion: () => ZodUnion,
7370
- ZodUnknown: () => ZodUnknown,
7371
- ZodVoid: () => ZodVoid,
7372
- addIssueToContext: () => addIssueToContext,
7373
- any: () => anyType,
7374
- array: () => arrayType,
7375
- bigint: () => bigIntType,
7376
- boolean: () => booleanType,
7377
- coerce: () => coerce,
7378
- custom: () => custom,
7379
- date: () => dateType,
7380
- datetimeRegex: () => datetimeRegex,
7381
- defaultErrorMap: () => en_default,
7382
- discriminatedUnion: () => discriminatedUnionType,
7383
- effect: () => effectsType,
7384
- enum: () => enumType,
7385
- function: () => functionType,
7386
- getErrorMap: () => getErrorMap,
7387
- getParsedType: () => getParsedType,
7388
- instanceof: () => instanceOfType,
7389
- intersection: () => intersectionType,
7390
- isAborted: () => isAborted,
7391
- isAsync: () => isAsync,
7392
- isDirty: () => isDirty,
7393
- isValid: () => isValid,
7394
- late: () => late,
7395
- lazy: () => lazyType,
7396
- literal: () => literalType,
7397
- makeIssue: () => makeIssue,
7398
- map: () => mapType,
7399
- nan: () => nanType,
7400
- nativeEnum: () => nativeEnumType,
7401
- never: () => neverType,
7402
- null: () => nullType,
7403
- nullable: () => nullableType,
7404
- number: () => numberType,
7405
- object: () => objectType,
7325
+ // src/state.ts
7326
+ var import_node_crypto2 = require("crypto");
7327
+ var import_node_fs2 = require("fs");
7328
+ var import_node_path3 = require("path");
7329
+ var import_argon2 = __toESM(require("argon2"), 1);
7330
+ var FORMAT_TAG = "SEAL_MCP_STATE v1";
7331
+ var STATE_VERSION = 1;
7332
+ var ARGON2_MEMORY_KIB = 65536;
7333
+ var ARGON2_ITERATIONS = 3;
7334
+ var ARGON2_PARALLELISM = 4;
7335
+ var ARGON2_SALT_BYTES = 16;
7336
+ var KDF_BOUNDS = {
7337
+ memory_kib: { min: 8192, max: 1048576 },
7338
+ // 8 MiB .. 1 GiB
7339
+ iterations: { min: 1, max: 16 },
7340
+ parallelism: { min: 1, max: 16 }
7341
+ };
7342
+ var AES_KEY_BYTES = 32;
7343
+ var AES_NONCE_BYTES = 12;
7344
+ var StatePassphraseInvalid = class extends Error {
7345
+ constructor() {
7346
+ super("AES-256-GCM tag check failed: another passphrase, or a tampered file");
7347
+ this.name = "StatePassphraseInvalid";
7348
+ }
7349
+ };
7350
+ var StateCorrupt = class extends Error {
7351
+ constructor(reason) {
7352
+ super(`state.corrupt: ${reason}`);
7353
+ this.name = "StateCorrupt";
7354
+ }
7355
+ };
7356
+ function toB642(bytes) {
7357
+ return Buffer.from(bytes).toString("base64");
7358
+ }
7359
+ function fromB642(s) {
7360
+ return new Uint8Array(Buffer.from(s, "base64"));
7361
+ }
7362
+ var CURRENT_KDF_PARAMS = {
7363
+ memory_kib: ARGON2_MEMORY_KIB,
7364
+ iterations: ARGON2_ITERATIONS,
7365
+ parallelism: ARGON2_PARALLELISM
7366
+ };
7367
+ function boundedKdfParam(name, value) {
7368
+ if (typeof value !== "number" || !Number.isInteger(value)) {
7369
+ throw new StateCorrupt(`kdf.${name} is not an integer: ${String(value)}`);
7370
+ }
7371
+ const { min, max } = KDF_BOUNDS[name];
7372
+ if (value < min || value > max) {
7373
+ throw new StateCorrupt(`kdf.${name}=${value} outside sane bounds [${min}, ${max}]`);
7374
+ }
7375
+ return value;
7376
+ }
7377
+ async function deriveKek(passphrase, saltB64, params) {
7378
+ const salt = fromB642(saltB64);
7379
+ if (salt.length !== ARGON2_SALT_BYTES) {
7380
+ throw new StateCorrupt(`salt length is ${salt.length}, expected ${ARGON2_SALT_BYTES}`);
7381
+ }
7382
+ const kek = await import_argon2.default.hash(passphrase, {
7383
+ type: import_argon2.default.argon2id,
7384
+ raw: true,
7385
+ salt: Buffer.from(salt),
7386
+ memoryCost: params.memory_kib,
7387
+ timeCost: params.iterations,
7388
+ parallelism: params.parallelism,
7389
+ hashLength: AES_KEY_BYTES
7390
+ });
7391
+ return new Uint8Array(kek);
7392
+ }
7393
+ async function aesGcmEncrypt(kek, nonce, plaintext) {
7394
+ const key = await import_node_crypto2.webcrypto.subtle.importKey("raw", kek, "AES-GCM", false, ["encrypt"]);
7395
+ const ct = await import_node_crypto2.webcrypto.subtle.encrypt(
7396
+ { name: "AES-GCM", iv: nonce, tagLength: 128 },
7397
+ key,
7398
+ plaintext
7399
+ );
7400
+ return new Uint8Array(ct);
7401
+ }
7402
+ async function aesGcmDecrypt(kek, nonce, ciphertextWithTag) {
7403
+ const key = await import_node_crypto2.webcrypto.subtle.importKey("raw", kek, "AES-GCM", false, ["decrypt"]);
7404
+ try {
7405
+ const pt = await import_node_crypto2.webcrypto.subtle.decrypt(
7406
+ { name: "AES-GCM", iv: nonce, tagLength: 128 },
7407
+ key,
7408
+ ciphertextWithTag
7409
+ );
7410
+ return new Uint8Array(pt);
7411
+ } catch {
7412
+ throw new StatePassphraseInvalid();
7413
+ }
7414
+ }
7415
+ function createEmptyState(identity) {
7416
+ return {
7417
+ version: STATE_VERSION,
7418
+ identity,
7419
+ handles: {},
7420
+ audit: [],
7421
+ pending: {}
7422
+ };
7423
+ }
7424
+ function validateEnvelope(json) {
7425
+ if (typeof json !== "object" || json === null) {
7426
+ throw new StateCorrupt("envelope is not an object");
7427
+ }
7428
+ const e = json;
7429
+ if (e.version !== STATE_VERSION) {
7430
+ throw new StateCorrupt(`unknown version: ${String(e.version)}`);
7431
+ }
7432
+ if (e.format !== FORMAT_TAG) {
7433
+ throw new StateCorrupt(`unknown format tag: ${String(e.format)}`);
7434
+ }
7435
+ const kdf = e.kdf;
7436
+ if (!kdf || kdf.algo !== "argon2id" || typeof kdf.salt_b64 !== "string") {
7437
+ throw new StateCorrupt("kdf header missing or wrong algo");
7438
+ }
7439
+ const cipher = e.cipher;
7440
+ if (!cipher || cipher.algo !== "aes-256-gcm" || typeof cipher.nonce_b64 !== "string" || typeof cipher.ciphertext_b64 !== "string") {
7441
+ throw new StateCorrupt("cipher header missing or wrong algo");
7442
+ }
7443
+ }
7444
+ function validatePlaintext(json) {
7445
+ if (typeof json !== "object" || json === null) {
7446
+ throw new StateCorrupt("plaintext is not an object");
7447
+ }
7448
+ const s = json;
7449
+ if (s.version !== STATE_VERSION) {
7450
+ throw new StateCorrupt(`plaintext version: ${String(s.version)}`);
7451
+ }
7452
+ const id = s.identity;
7453
+ if (!id || typeof id.x25519_secret_b64 !== "string" || typeof id.x25519_public_b64 !== "string") {
7454
+ throw new StateCorrupt("identity missing or malformed");
7455
+ }
7456
+ if (typeof s.handles !== "object" || s.handles === null) {
7457
+ throw new StateCorrupt("handles is not an object");
7458
+ }
7459
+ if (!Array.isArray(s.audit)) {
7460
+ throw new StateCorrupt("audit is not an array");
7461
+ }
7462
+ if (s.pending !== void 0 && (typeof s.pending !== "object" || s.pending === null)) {
7463
+ throw new StateCorrupt("pending is not an object");
7464
+ }
7465
+ }
7466
+ async function loadState(passphrase, statePath) {
7467
+ const raw = await import_node_fs2.promises.readFile(statePath, "utf8");
7468
+ let envelope;
7469
+ try {
7470
+ envelope = JSON.parse(raw);
7471
+ } catch (err2) {
7472
+ throw new StateCorrupt(`envelope is not valid JSON: ${err2.message}`);
7473
+ }
7474
+ validateEnvelope(envelope);
7475
+ const kek = await deriveKek(passphrase, envelope.kdf.salt_b64, {
7476
+ memory_kib: boundedKdfParam("memory_kib", envelope.kdf.memory_kib),
7477
+ iterations: boundedKdfParam("iterations", envelope.kdf.iterations),
7478
+ parallelism: boundedKdfParam("parallelism", envelope.kdf.parallelism)
7479
+ });
7480
+ const nonce = fromB642(envelope.cipher.nonce_b64);
7481
+ if (nonce.length !== AES_NONCE_BYTES) {
7482
+ throw new StateCorrupt(`nonce length is ${nonce.length}, expected ${AES_NONCE_BYTES}`);
7483
+ }
7484
+ const ct = fromB642(envelope.cipher.ciphertext_b64);
7485
+ const ptBytes = await aesGcmDecrypt(kek, nonce, ct);
7486
+ const ptText = new TextDecoder("utf-8", { fatal: true }).decode(ptBytes);
7487
+ let plaintext;
7488
+ try {
7489
+ plaintext = JSON.parse(ptText);
7490
+ } catch (err2) {
7491
+ throw new StateCorrupt(`plaintext is not valid JSON: ${err2.message}`);
7492
+ }
7493
+ validatePlaintext(plaintext);
7494
+ if (plaintext.pending === void 0) {
7495
+ plaintext.pending = {};
7496
+ }
7497
+ return plaintext;
7498
+ }
7499
+ async function saveState(state, passphrase, statePath) {
7500
+ validatePlaintext(state);
7501
+ const salt = (0, import_node_crypto2.randomBytes)(ARGON2_SALT_BYTES);
7502
+ const nonce = (0, import_node_crypto2.randomBytes)(AES_NONCE_BYTES);
7503
+ const kek = await deriveKek(passphrase, toB642(salt), CURRENT_KDF_PARAMS);
7504
+ const ptBytes = new TextEncoder().encode(JSON.stringify(state));
7505
+ const ct = await aesGcmEncrypt(kek, nonce, ptBytes);
7506
+ const envelope = {
7507
+ version: STATE_VERSION,
7508
+ format: FORMAT_TAG,
7509
+ kdf: {
7510
+ algo: "argon2id",
7511
+ memory_kib: ARGON2_MEMORY_KIB,
7512
+ iterations: ARGON2_ITERATIONS,
7513
+ parallelism: ARGON2_PARALLELISM,
7514
+ salt_b64: toB642(salt)
7515
+ },
7516
+ cipher: {
7517
+ algo: "aes-256-gcm",
7518
+ nonce_b64: toB642(nonce),
7519
+ ciphertext_b64: toB642(ct),
7520
+ tag_included: true
7521
+ }
7522
+ };
7523
+ const dir = (0, import_node_path3.dirname)(statePath);
7524
+ await import_node_fs2.promises.mkdir(dir, { recursive: true, mode: 448 });
7525
+ const tmp = `${statePath}.tmp-${process.pid}-${Date.now()}`;
7526
+ const fh = await import_node_fs2.promises.open(tmp, "w", 384);
7527
+ try {
7528
+ await fh.writeFile(JSON.stringify(envelope), "utf8");
7529
+ await fh.sync();
7530
+ } finally {
7531
+ await fh.close();
7532
+ }
7533
+ await import_node_fs2.promises.rename(tmp, statePath);
7534
+ try {
7535
+ const dh = await import_node_fs2.promises.open(dir, "r");
7536
+ try {
7537
+ await dh.sync();
7538
+ } finally {
7539
+ await dh.close();
7540
+ }
7541
+ } catch {
7542
+ }
7543
+ await import_node_fs2.promises.chmod(dir, 448).catch(() => {
7544
+ });
7545
+ }
7546
+ function updateState(passphrase, statePath, mutate) {
7547
+ return withStateLock((0, import_node_path3.dirname)(statePath), async () => {
7548
+ const view = await loadState(passphrase, statePath);
7549
+ if (await mutate(view) !== false) await saveState(view, passphrase, statePath);
7550
+ return view;
7551
+ });
7552
+ }
7553
+
7554
+ // src/seed.ts
7555
+ var SEED_ENV_VAR = "SEAL_SEED";
7556
+ var EPHEMERAL_ENV_VAR = "SEAL_MCP_EPHEMERAL";
7557
+ var PASSPHRASE_ENV_VAR = "SEAL_MCP_PASSPHRASE";
7558
+ var SEED_FILE_NAME = "seed";
7559
+ var KEYCHAIN_SERVICE = "seal-mcp";
7560
+ var KEYCHAIN_ACCOUNT = "seed";
7561
+ var LEGACY_KEYCHAIN_ACCOUNT = "state";
7562
+ var SeedInvalidError = class extends Error {
7563
+ code = "seed_invalid";
7564
+ constructor(where) {
7565
+ super(
7566
+ `${where} is not an agent key: expected 43 base64url characters, as \`sealnet-mcp seed --force\` or \`seal keygen --export --force\` print them`
7567
+ );
7568
+ this.name = "SeedInvalidError";
7569
+ }
7570
+ };
7571
+ var IdentityConflictError = class extends Error {
7572
+ code = "identity_conflict";
7573
+ constructor(statePath) {
7574
+ super(
7575
+ `${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.`
7576
+ );
7577
+ this.name = "IdentityConflictError";
7578
+ }
7579
+ };
7580
+ function keyringEntry(keyring, platform, service, account) {
7581
+ return platform === "win32" ? keyring.AsyncEntry.withTarget(`${service}/${account}`, service, account) : new keyring.AsyncEntry(service, account, { linux: { store: "secret-service" } });
7582
+ }
7583
+ function readKeytarLinuxEntry(service, account) {
7584
+ return new Promise((resolve5) => {
7585
+ (0, import_node_child_process.execFile)("secret-tool", ["lookup", "service", service, "account", account], { timeout: 5e3 }, (err2, stdout) => {
7586
+ const value = err2 ? "" : stdout.replace(/\n$/, "");
7587
+ resolve5(value.length > 0 ? value : null);
7588
+ });
7589
+ });
7590
+ }
7591
+ function keytarOverKeyring(keyring, platform = process.platform, readLegacy = readKeytarLinuxEntry) {
7592
+ const entry = (service, account) => keyringEntry(keyring, platform, service, account);
7593
+ const store = (service, account, password) => entry(service, account).setSecret(Buffer.from(password, "utf8"));
7594
+ return {
7595
+ async getPassword(service, account) {
7596
+ const secret = await entry(service, account).getSecret();
7597
+ if (secret !== void 0) return Buffer.from(secret).toString("utf8");
7598
+ const legacy = platform === "linux" ? await readLegacy(service, account) : null;
7599
+ if (legacy !== null) await store(service, account, legacy).catch(() => void 0);
7600
+ return legacy;
7601
+ },
7602
+ setPassword: store,
7603
+ deletePassword: (service, account) => entry(service, account).deleteCredential()
7604
+ };
7605
+ }
7606
+ var keytarCache = void 0;
7607
+ async function loadKeytar() {
7608
+ if (keytarCache !== void 0) return keytarCache;
7609
+ try {
7610
+ const mod = await import("@napi-rs/keyring");
7611
+ const keyring = mod.default ?? mod;
7612
+ keyringEntry(keyring, process.platform, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
7613
+ keytarCache = keytarOverKeyring(keyring);
7614
+ } catch {
7615
+ keytarCache = null;
7616
+ }
7617
+ return keytarCache;
7618
+ }
7619
+ async function keychainGet(account) {
7620
+ const keytar = await loadKeytar();
7621
+ if (keytar === null) return null;
7622
+ try {
7623
+ const value = await keytar.getPassword(KEYCHAIN_SERVICE, account);
7624
+ return value !== null && value.length > 0 ? value : null;
7625
+ } catch {
7626
+ return null;
7627
+ }
7628
+ }
7629
+ async function keychainDelete(account) {
7630
+ const keytar = await loadKeytar();
7631
+ if (keytar === null) return false;
7632
+ try {
7633
+ return await keytar.deletePassword(KEYCHAIN_SERVICE, account);
7634
+ } catch {
7635
+ return false;
7636
+ }
7637
+ }
7638
+ function parseSeed(text, where) {
7639
+ const seed = Buffer.from(text, "base64url");
7640
+ if (!/^[A-Za-z0-9_-]{43}$/.test(text) || seed.toString("base64url") !== text) throw new SeedInvalidError(where);
7641
+ return new Uint8Array(seed);
7642
+ }
7643
+ async function readSeed(stateDir, env = process.env) {
7644
+ const fromEnv = env[SEED_ENV_VAR]?.trim();
7645
+ if (fromEnv) return { seed: parseSeed(fromEnv, SEED_ENV_VAR), source: "env" };
7646
+ const stored = await keychainGet(KEYCHAIN_ACCOUNT);
7647
+ if (stored !== null) return { seed: parseSeed(stored, `keychain entry ${KEYCHAIN_SERVICE}/${KEYCHAIN_ACCOUNT}`), source: "keychain" };
7648
+ const file = (0, import_node_path4.join)(stateDir, SEED_FILE_NAME);
7649
+ let text;
7650
+ try {
7651
+ text = await import_node_fs3.promises.readFile(file, "utf8");
7652
+ } catch (err2) {
7653
+ if (err2.code === "ENOENT") return null;
7654
+ throw err2;
7655
+ }
7656
+ return { seed: parseSeed(text.trim(), file), source: "file" };
7657
+ }
7658
+ async function storeSeed(stateDir, seed) {
7659
+ const text = Buffer.from(seed).toString("base64url");
7660
+ const keytar = await loadKeytar();
7661
+ if (keytar !== null) {
7662
+ try {
7663
+ await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, text);
7664
+ return "keychain";
7665
+ } catch {
7666
+ }
7667
+ }
7668
+ const fh = await import_node_fs3.promises.open((0, import_node_path4.join)(stateDir, SEED_FILE_NAME), "wx", 384);
7669
+ try {
7670
+ await fh.writeFile(`${text}
7671
+ `, "utf8");
7672
+ await fh.sync();
7673
+ } finally {
7674
+ await fh.close();
7675
+ }
7676
+ return "file";
7677
+ }
7678
+ function statePassphrase(seed) {
7679
+ return Buffer.from((0, import_node_crypto3.hkdfSync)("sha256", seed, "seal-mcp:state:v1", "passphrase", 32)).toString("base64url");
7680
+ }
7681
+ async function legacyPassphrase(env) {
7682
+ const stored = await keychainGet(LEGACY_KEYCHAIN_ACCOUNT);
7683
+ if (stored !== null) return { passphrase: stored, source: "keychain" };
7684
+ const fromEnv = env[PASSPHRASE_ENV_VAR];
7685
+ return fromEnv ? { passphrase: fromEnv, source: "env" } : null;
7686
+ }
7687
+ async function tryOpen(passphrase, statePath) {
7688
+ try {
7689
+ return await loadState(passphrase, statePath);
7690
+ } catch (err2) {
7691
+ if (err2 instanceof StatePassphraseInvalid) return null;
7692
+ throw err2;
7693
+ }
7694
+ }
7695
+ async function requireIdentity(view, seed, statePath) {
7696
+ const { secret } = await loadIdentity(view.identity);
7697
+ if (!Buffer.from(secret).equals(Buffer.from(seed))) throw new IdentityConflictError(statePath);
7698
+ }
7699
+ function openAgentState(path, env = process.env) {
7700
+ return withStateLock(path.dir, async () => {
7701
+ let root = await readSeed(path.dir, env);
7702
+ if (!await import_node_fs3.promises.stat(path.statePath).then(() => true, () => false)) {
7703
+ if (root === null) {
7704
+ const { secret } = await generateIdentity();
7705
+ root = { seed: secret, source: await storeSeed(path.dir, secret) };
7706
+ }
7707
+ const passphrase2 = statePassphrase(root.seed);
7708
+ const identity = await identityFromSeed(root.seed);
7709
+ await saveState(createEmptyState(identityToFields(identity)), passphrase2, path.statePath);
7710
+ return { root, passphrase: passphrase2, created: true, moved: null };
7711
+ }
7712
+ if (root !== null) {
7713
+ const view2 = await tryOpen(statePassphrase(root.seed), path.statePath);
7714
+ if (view2 !== null) {
7715
+ await requireIdentity(view2, root.seed, path.statePath);
7716
+ return { root, passphrase: statePassphrase(root.seed), created: false, moved: null };
7717
+ }
7718
+ }
7719
+ const legacy = await legacyPassphrase(env);
7720
+ const view = legacy === null ? null : await tryOpen(legacy.passphrase, path.statePath);
7721
+ if (legacy === null || view === null) throw new IdentityConflictError(path.statePath);
7722
+ if (root !== null) await requireIdentity(view, root.seed, path.statePath);
7723
+ else {
7724
+ const { secret } = await loadIdentity(view.identity);
7725
+ root = { seed: secret, source: await storeSeed(path.dir, secret) };
7726
+ }
7727
+ const passphrase = statePassphrase(root.seed);
7728
+ await saveState(view, passphrase, path.statePath);
7729
+ await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT);
7730
+ return { root, passphrase, created: false, moved: legacy.source };
7731
+ });
7732
+ }
7733
+ async function deleteRootSecret(stateDir) {
7734
+ let seedFile = true;
7735
+ await import_node_fs3.promises.unlink((0, import_node_path4.join)(stateDir, SEED_FILE_NAME)).catch((err2) => {
7736
+ if (err2.code !== "ENOENT") throw err2;
7737
+ seedFile = false;
7738
+ });
7739
+ const seed = await keychainDelete(KEYCHAIN_ACCOUNT);
7740
+ const legacy = await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT);
7741
+ return { seedFile, keychain: seed || legacy };
7742
+ }
7743
+ async function probeKeychain() {
7744
+ const keytar = await loadKeytar();
7745
+ if (keytar === null) return { available: false, hasSeed: false };
7746
+ try {
7747
+ const value = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
7748
+ return { available: true, hasSeed: value !== null && value.length > 0 };
7749
+ } catch {
7750
+ return { available: false, hasSeed: false };
7751
+ }
7752
+ }
7753
+
7754
+ // src/server.ts
7755
+ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
7756
+ var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
7757
+
7758
+ // ../../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
7759
+ var external_exports = {};
7760
+ __export(external_exports, {
7761
+ BRAND: () => BRAND,
7762
+ DIRTY: () => DIRTY,
7763
+ EMPTY_PATH: () => EMPTY_PATH,
7764
+ INVALID: () => INVALID,
7765
+ NEVER: () => NEVER,
7766
+ OK: () => OK,
7767
+ ParseStatus: () => ParseStatus,
7768
+ Schema: () => ZodType,
7769
+ ZodAny: () => ZodAny,
7770
+ ZodArray: () => ZodArray,
7771
+ ZodBigInt: () => ZodBigInt,
7772
+ ZodBoolean: () => ZodBoolean,
7773
+ ZodBranded: () => ZodBranded,
7774
+ ZodCatch: () => ZodCatch,
7775
+ ZodDate: () => ZodDate,
7776
+ ZodDefault: () => ZodDefault,
7777
+ ZodDiscriminatedUnion: () => ZodDiscriminatedUnion,
7778
+ ZodEffects: () => ZodEffects,
7779
+ ZodEnum: () => ZodEnum,
7780
+ ZodError: () => ZodError,
7781
+ ZodFirstPartyTypeKind: () => ZodFirstPartyTypeKind,
7782
+ ZodFunction: () => ZodFunction,
7783
+ ZodIntersection: () => ZodIntersection,
7784
+ ZodIssueCode: () => ZodIssueCode,
7785
+ ZodLazy: () => ZodLazy,
7786
+ ZodLiteral: () => ZodLiteral,
7787
+ ZodMap: () => ZodMap,
7788
+ ZodNaN: () => ZodNaN,
7789
+ ZodNativeEnum: () => ZodNativeEnum,
7790
+ ZodNever: () => ZodNever,
7791
+ ZodNull: () => ZodNull,
7792
+ ZodNullable: () => ZodNullable,
7793
+ ZodNumber: () => ZodNumber,
7794
+ ZodObject: () => ZodObject,
7795
+ ZodOptional: () => ZodOptional,
7796
+ ZodParsedType: () => ZodParsedType,
7797
+ ZodPipeline: () => ZodPipeline,
7798
+ ZodPromise: () => ZodPromise,
7799
+ ZodReadonly: () => ZodReadonly,
7800
+ ZodRecord: () => ZodRecord,
7801
+ ZodSchema: () => ZodType,
7802
+ ZodSet: () => ZodSet,
7803
+ ZodString: () => ZodString,
7804
+ ZodSymbol: () => ZodSymbol,
7805
+ ZodTransformer: () => ZodEffects,
7806
+ ZodTuple: () => ZodTuple,
7807
+ ZodType: () => ZodType,
7808
+ ZodUndefined: () => ZodUndefined,
7809
+ ZodUnion: () => ZodUnion,
7810
+ ZodUnknown: () => ZodUnknown,
7811
+ ZodVoid: () => ZodVoid,
7812
+ addIssueToContext: () => addIssueToContext,
7813
+ any: () => anyType,
7814
+ array: () => arrayType,
7815
+ bigint: () => bigIntType,
7816
+ boolean: () => booleanType,
7817
+ coerce: () => coerce,
7818
+ custom: () => custom,
7819
+ date: () => dateType,
7820
+ datetimeRegex: () => datetimeRegex,
7821
+ defaultErrorMap: () => en_default,
7822
+ discriminatedUnion: () => discriminatedUnionType,
7823
+ effect: () => effectsType,
7824
+ enum: () => enumType,
7825
+ function: () => functionType,
7826
+ getErrorMap: () => getErrorMap,
7827
+ getParsedType: () => getParsedType,
7828
+ instanceof: () => instanceOfType,
7829
+ intersection: () => intersectionType,
7830
+ isAborted: () => isAborted,
7831
+ isAsync: () => isAsync,
7832
+ isDirty: () => isDirty,
7833
+ isValid: () => isValid,
7834
+ late: () => late,
7835
+ lazy: () => lazyType,
7836
+ literal: () => literalType,
7837
+ makeIssue: () => makeIssue,
7838
+ map: () => mapType,
7839
+ nan: () => nanType,
7840
+ nativeEnum: () => nativeEnumType,
7841
+ never: () => neverType,
7842
+ null: () => nullType,
7843
+ nullable: () => nullableType,
7844
+ number: () => numberType,
7845
+ object: () => objectType,
7406
7846
  objectUtil: () => objectUtil,
7407
7847
  oboolean: () => oboolean,
7408
7848
  onumber: () => onumber,
@@ -11371,7 +11811,7 @@ var seal_mcp_v1_default = {
11371
11811
  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`."
11372
11812
  },
11373
11813
  server_instructions: {
11374
- 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.",
11814
+ 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). Files sent to your key arrive in seal_inbox; open them with seal_open item. Never read or print a SEAL handoff file.",
11375
11815
  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."
11376
11816
  },
11377
11817
  tools: {
@@ -11421,7 +11861,7 @@ var seal_mcp_v1_default = {
11421
11861
  required: ["handle", "receipt", "handoff_channel"],
11422
11862
  properties: {
11423
11863
  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." },
11424
- 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." },
11864
+ 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." },
11425
11865
  handoff_channel: { type: "string", enum: ["clipboard", "file"] },
11426
11866
  handoff_file: { type: "string", description: "ONLY when handoff_channel='file'. Absolute path to a 0o600 file containing the share link and a newline." },
11427
11867
  expires_at: { type: "string", format: "date-time", description: "ISO 8601 UTC expiry timestamp." }
@@ -11439,7 +11879,8 @@ var seal_mcp_v1_default = {
11439
11879
  share_url: { type: "string", format: "uri", description: "`targeted_x25519_v1` link: the wrapped key is in the fragment, useless without the recipient's X25519 secret; there is no separate password." },
11440
11880
  expires_at: { type: "string", format: "date-time" },
11441
11881
  max_reads: { type: "integer", const: 1 },
11442
- mode: { type: "string", const: "forward" }
11882
+ mode: { type: "string", const: "forward" },
11883
+ delivered: { type: "boolean", description: "The link also lies in the recipient's inbox (SPEC-ADDRESS F2): the server keeps its wrapped key in the link's row, and the holder of the key finds it with seal_inbox without being handed the URL." }
11443
11884
  },
11444
11885
  policy: {
11445
11886
  max_expire_minutes: 30,
@@ -11507,9 +11948,11 @@ var seal_mcp_v1_default = {
11507
11948
  annotations: { readOnlyHint: false, destructiveHint: false },
11508
11949
  annotations_note: "Set on the tool, not per mode: `inline` and `file` consume an open, so the tool as a whole is not read-only.",
11509
11950
  input: {
11510
- required: ["url"],
11951
+ one_of: ["url", "item"],
11952
+ one_of_note: "Exactly one of `url` and `item`; `server.ts` takes both as optional and the handler refuses anything else with `url_invalid`.",
11511
11953
  properties: {
11512
11954
  url: { type: "string", format: "uri", description: "Full SEAL share URL including fragment." },
11955
+ item: { type: "string", pattern: "^in_", description: "An inbox handle from seal_inbox (`in_\u2026`), instead of a URL." },
11513
11956
  mode: { type: "string", enum: ["metadata", "inline", "file"], default: "metadata", description: "`metadata` reads nothing; `file` saves to disk; `inline` returns small text." },
11514
11957
  dir: { type: "string", description: "mode=file: directory to save into; default ~/Downloads, created if missing. An existing file is never overwritten: the new one becomes `name (1).ext`." }
11515
11958
  }
@@ -11554,16 +11997,50 @@ var seal_mcp_v1_default = {
11554
11997
  max_reads: { type: "integer", minimum: 1 },
11555
11998
  expires_at: { type: "string", format: "date-time" }
11556
11999
  },
11557
- does_not_consume_counters: true
11558
- },
11559
- output_inline_allowed_mimes: ["application/json", "text/csv", "text/plain"],
11560
- output_inline_max_bytes: 102400,
11561
- output_inline_refusals: {
11562
- "secrets.json::mime": "inline_blocked.secret",
11563
- "text/html": "inline_blocked.injection_risk",
11564
- "application/pdf": "inline_blocked.injection_risk",
11565
- "application/xhtml+xml": "inline_blocked.injection_risk",
11566
- _binary_default: "inline_blocked.binary"
12000
+ does_not_consume_counters: true
12001
+ },
12002
+ output_inline_allowed_mimes: ["application/json", "text/csv", "text/plain"],
12003
+ output_inline_max_bytes: 102400,
12004
+ output_inline_refusals: {
12005
+ "secrets.json::mime": "inline_blocked.secret",
12006
+ "text/html": "inline_blocked.injection_risk",
12007
+ "application/pdf": "inline_blocked.injection_risk",
12008
+ "application/xhtml+xml": "inline_blocked.injection_risk",
12009
+ _binary_default: "inline_blocked.binary"
12010
+ }
12011
+ },
12012
+ seal_inbox: {
12013
+ title: "Check the inbox",
12014
+ description: "Seals other agents or people sent to your key (your address). Returns handles; open one with seal_open item=<handle>. `wait`: seconds to wait for something new, 0 to 300.",
12015
+ annotations: { readOnlyHint: true, openWorldHint: true },
12016
+ input: {
12017
+ properties: {
12018
+ wait: { type: "integer", minimum: 0, maximum: 300, default: 0, description: "Seconds to wait for an item this server has not seen yet; 0 returns the inbox now." }
12019
+ }
12020
+ },
12021
+ flow: "`GET /intake?to=<this server's address>` under the proof of the key (seal_address.v1.json proof), cached until 30 s before it expires. The first call in a process returns the list at once; later ones long-poll by 25 s with `seen` = the version of the last answer until an id this process has not seen arrives or `wait` runs out. Handles, rows and the version live in process memory.",
12022
+ output: {
12023
+ type: "object",
12024
+ required: ["address_emoji", "items"],
12025
+ properties: {
12026
+ address_emoji: { type: "string", description: "Three emoji of this server's address (`fingerprint_emoji.json`)." },
12027
+ items: {
12028
+ type: "array",
12029
+ items: {
12030
+ type: "object",
12031
+ required: ["handle", "from", "label", "received_at", "expires_at"],
12032
+ additionalProperties: false,
12033
+ properties: {
12034
+ handle: { type: "string", pattern: "^in_", description: "`in_` + the inbox item id; pass as seal_open `item`." },
12035
+ from: { type: ["string", "null"], description: "Three emoji of the sender's address when the server verified it, else null." },
12036
+ label: { type: ["string", "null"], description: "What the sender called itself (an agent's name, `sealnet`), in the clear: never content." },
12037
+ received_at: { type: "string", format: "date-time" },
12038
+ expires_at: { type: "string", format: "date-time" }
12039
+ }
12040
+ }
12041
+ }
12042
+ },
12043
+ MUST_NOT_contain: ["path", "fragment", "url", "seal_id", "share_code"]
11567
12044
  }
11568
12045
  },
11569
12046
  seal_list: {
@@ -11757,7 +12234,7 @@ var seal_mcp_v1_default = {
11757
12234
  type: "object",
11758
12235
  required: ["x25519_secret_b64", "x25519_public_b64"],
11759
12236
  properties: {
11760
- 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." },
12237
+ 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." },
11761
12238
  x25519_public_b64: { type: "string", description: "32 raw bytes, derived from secret. Safe to publish; advertised so other parties can target this MCP." }
11762
12239
  }
11763
12240
  },
@@ -11778,7 +12255,7 @@ var seal_mcp_v1_default = {
11778
12255
  },
11779
12256
  pending: {
11780
12257
  type: "object",
11781
- 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.",
12258
+ 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.",
11782
12259
  additionalProperties: {
11783
12260
  type: "object",
11784
12261
  required: ["tool", "mode", "started_at", "owner_token_b64", "expires_at", "step"],
@@ -11791,6 +12268,12 @@ var seal_mcp_v1_default = {
11791
12268
  step: { type: "string", enum: ["preparing", "awaiting_payment", "uploaded", "delivering", "compensating"] },
11792
12269
  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." },
11793
12270
  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)." },
12271
+ owner: {
12272
+ type: "object",
12273
+ required: ["pid", "hostname"],
12274
+ properties: { pid: { type: "integer" }, hostname: { type: "string" } },
12275
+ 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."
12276
+ },
11794
12277
  compensation: {
11795
12278
  type: "object",
11796
12279
  required: ["action", "outcome", "error", "at"],
@@ -11822,27 +12305,20 @@ var seal_mcp_v1_default = {
11822
12305
  }
11823
12306
  }
11824
12307
  },
11825
- passphrase_delivery: {
11826
- ordered_priority: [
11827
- {
11828
- rank: 1,
11829
- method: "os_keychain",
11830
- 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.",
11831
- default: true
11832
- },
11833
- {
11834
- rank: 2,
11835
- method: "env_var",
11836
- 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."
11837
- },
11838
- {
11839
- rank: 3,
11840
- method: "REFUSED",
11841
- implementation: "Cleartext passphrase file on disk is explicitly refused even if user passes `--passphrase-file \u2026`. Error message points at options 1 and 2."
11842
- }
12308
+ root_secret: {
12309
+ 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.",
12310
+ ordered_sources: [
12311
+ { rank: 1, source: "env", implementation: "$SEAL_SEED. How a container or a cloud session gets the same address as at home." },
12312
+ { 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." },
12313
+ { 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." }
11843
12314
  ],
11844
- 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.",
11845
- 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)."
12315
+ 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.",
12316
+ 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.",
12317
+ 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.",
12318
+ 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.",
12319
+ 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.",
12320
+ 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`.",
12321
+ 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."
11846
12322
  },
11847
12323
  handoff_delivery: {
11848
12324
  primary_channel: "system_clipboard",
@@ -11890,6 +12366,10 @@ var seal_mcp_v1_default = {
11890
12366
  share_blob_corrupt: "Share blob decoded but a file_key is not valid base64url or has the wrong length (32 bytes expected). Backend bug or share-blob tampering.",
11891
12367
  share_blob_failed: "`encryptShareBlob` threw during seal_share mint (Web Crypto failure or input shape bug). Caller-facing in seal_share path only.",
11892
12368
  share_link_failed: "POST /seal/{id}/share returned an error (network, 5xx, owner_token mismatch).",
12369
+ inbox_proof_failed: "seal_inbox / seal_open item / seal_share forward: the server refused the proof of this server's key (`intake.proof_required`) even after one fresh challenge.",
12370
+ inbox_full: "seal_share forward: the recipient's inbox holds `limits.json::INBOX_MAX_ITEMS` live items from other senders (`intake.inbox_full`). The seal is revoked; retry later.",
12371
+ inbox_item_gone: "seal_open item: the handle is not in this process's inbox and not in a fresh read of it \u2014 the seal or link was revoked or expired, or the item was removed.",
12372
+ inbox_failed: "seal_inbox / seal_open item: reading or cleaning the inbox failed for a reason other than the proof (network, 5xx, rate limit); the message has the status. Safe to retry.",
11893
12373
  targeted_fragment_invalid: "seal_open of `x25519:` URL: fragment is malformed base64url, wrong packed length, or AEAD-failed for a non-secret reason. Distinct from `targeted_decrypt_failed` which is the wrong-recipient case.",
11894
12374
  targeted_decrypt_failed: "seal_open of `x25519:` URL: AEAD verification failed under THIS device's identity. The URL was not addressed to this MCP's pubkey (or was tampered, or seal_id from path-segment was substituted). Recipient should ask the sender to re-mint with `--to <our pubkey>`.",
11895
12375
  auth_required: "Backend returned 401/403 because the seal has OTP / NDA / login gating; interactive auth is not supported in MCP. Open the URL in a browser.",
@@ -11918,11 +12398,12 @@ var seal_mcp_v1_default = {
11918
12398
  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.",
11919
12399
  pro_failed: "seal_pro_*: SEAL Pro answered with another error; the message has the status.",
11920
12400
  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.",
12401
+ state_busy: "Another sealnet-mcp process held the state lock for more than 5 s. Nothing was changed by this call; safe to retry.",
11921
12402
  runtime_unsupported: "Node runtime is missing a global the bundle assumes (e.g. `globalThis.File` or `globalThis.Blob`). Upgrade to Node \u226520.",
11922
12403
  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.",
11923
- "state.passphrase_invalid": "AES-256-GCM tag check failed. Likely wrong passphrase in keychain / env; could be tampered file. Server refuses to start.",
12404
+ 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`.",
11924
12405
  "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.",
11925
- 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.",
12406
+ 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.",
11926
12407
  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."
11927
12408
  },
11928
12409
  current_format_version: 1,
@@ -11949,7 +12430,7 @@ function safeFilename(name) {
11949
12430
  return cleaned.length > 0 ? cleaned : "seal-file";
11950
12431
  }
11951
12432
  async function receiveToDisk(opened, dir, name) {
11952
- const fs3 = await import(
12433
+ const fs4 = await import(
11953
12434
  /* webpackIgnore: true */
11954
12435
  /* @vite-ignore */
11955
12436
  "fs/promises"
@@ -11959,10 +12440,10 @@ async function receiveToDisk(opened, dir, name) {
11959
12440
  /* @vite-ignore */
11960
12441
  "path"
11961
12442
  );
11962
- await fs3.mkdir(dir, { recursive: true });
12443
+ await fs4.mkdir(dir, { recursive: true });
11963
12444
  const suffix = Array.from(crypto.getRandomValues(new Uint8Array(6)), (b) => b.toString(16).padStart(2, "0")).join("");
11964
12445
  const tmp = path.join(dir, `.seal-partial-${suffix}`);
11965
- const fh = await fs3.open(tmp, "wx", 384);
12446
+ const fh = await fs4.open(tmp, "wx", 384);
11966
12447
  let placed = false;
11967
12448
  try {
11968
12449
  await opened.stream.pipeTo(
@@ -11980,12 +12461,12 @@ async function receiveToDisk(opened, dir, name) {
11980
12461
  for (let i = 0; i < 1e3; i++) {
11981
12462
  const candidate = path.join(dir, i === 0 ? name : `${stem} (${i})${ext}`);
11982
12463
  try {
11983
- await fs3.link(tmp, candidate);
12464
+ await fs4.link(tmp, candidate);
11984
12465
  } catch (err2) {
11985
12466
  if (err2.code === "EEXIST") continue;
11986
12467
  throw err2;
11987
12468
  }
11988
- await fs3.unlink(tmp);
12469
+ await fs4.unlink(tmp);
11989
12470
  placed = true;
11990
12471
  return { path: candidate, sizeBytes, sha256: sha2562 };
11991
12472
  }
@@ -11993,7 +12474,7 @@ async function receiveToDisk(opened, dir, name) {
11993
12474
  } finally {
11994
12475
  if (!placed) {
11995
12476
  await fh.close().catch(() => void 0);
11996
- await fs3.unlink(tmp).catch(() => void 0);
12477
+ await fs4.unlink(tmp).catch(() => void 0);
11997
12478
  }
11998
12479
  }
11999
12480
  }
@@ -12379,6 +12860,10 @@ async function recoverPendingOperations(view, client, opts = {}) {
12379
12860
  log4(`[seal-mcp] recovery: skipping unknown pending op tool=${String(op.tool)} handle=${handle}`);
12380
12861
  continue;
12381
12862
  }
12863
+ if (op.owner !== void 0 && op.owner.pid !== process.pid && holderMayRun(op.owner)) {
12864
+ log4(`[seal-mcp] recovery: left ${handle} alone \u2014 pid ${op.owner.pid} on ${op.owner.hostname} may still run it`);
12865
+ continue;
12866
+ }
12382
12867
  if (op.seal_id === void 0) {
12383
12868
  delete view.pending[handle];
12384
12869
  report.dropped.push(handle);
@@ -12399,274 +12884,52 @@ async function recoverPendingOperations(view, client, opts = {}) {
12399
12884
  op.step = "compensating";
12400
12885
  op.compensation = {
12401
12886
  action: "revoke",
12402
- outcome: "failed",
12403
- error: `backend mismatch: op belongs to ${op.backend_url}, server configured for ${client.baseUrl}`,
12404
- at: nowIso(opts.now)
12405
- };
12406
- report.stillFailing.push(handle);
12407
- changed = true;
12408
- log4(
12409
- `[seal-mcp] recovery: NOT compensating ${handle} \u2014 it belongs to ${op.backend_url} but this server talks to ${client.baseUrl}; kept in journal until the server runs against the original backend`
12410
- );
12411
- continue;
12412
- }
12413
- try {
12414
- await client.revoke(op.seal_id, op.owner_token_b64);
12415
- delete view.pending[handle];
12416
- report.revoked.push(handle);
12417
- changed = true;
12418
- log4(`[seal-mcp] recovery: revoked orphaned seal for ${handle} (step was '${op.step}')`);
12419
- } catch (err2) {
12420
- const msg = err2 instanceof Error ? err2.message : String(err2);
12421
- op.step = "compensating";
12422
- op.compensation = { action: "revoke", outcome: "failed", error: msg, at: nowIso(opts.now) };
12423
- report.stillFailing.push(handle);
12424
- changed = true;
12425
- log4(
12426
- `[seal-mcp] recovery: revoke retry for ${handle} failed (${msg}) \u2014 kept in journal, seal expires by TTL at ${op.expires_at}`
12427
- );
12428
- }
12429
- }
12430
- return { report, changed };
12431
- }
12432
- function adoptAsHandle(view, handle, op, now) {
12433
- const sealId = op.seal_id;
12434
- if (view.handles[handle] === void 0) {
12435
- view.handles[handle] = {
12436
- seal_id: sealId,
12437
- owner_token_b64: op.owner_token_b64,
12438
- created_at: op.started_at,
12439
- mode: op.mode,
12440
- expires_at: op.expires_at,
12441
- // Propagate provenance so a later seal_revoke on the adopted
12442
- // handle still refuses to target the wrong backend.
12443
- backend_url: op.backend_url
12444
- };
12445
- view.audit.push({ ts: nowIso(now), tool: "seal_share", handle, mode: op.mode });
12446
- }
12447
- delete view.pending[handle];
12448
- }
12449
-
12450
- // src/state.ts
12451
- var import_node_crypto3 = require("crypto");
12452
- var import_node_fs2 = require("fs");
12453
- var import_node_path3 = require("path");
12454
- var import_argon2 = __toESM(require("argon2"), 1);
12455
- var FORMAT_TAG = "SEAL_MCP_STATE v1";
12456
- var STATE_VERSION = 1;
12457
- var ARGON2_MEMORY_KIB = 65536;
12458
- var ARGON2_ITERATIONS = 3;
12459
- var ARGON2_PARALLELISM = 4;
12460
- var ARGON2_SALT_BYTES = 16;
12461
- var KDF_BOUNDS = {
12462
- memory_kib: { min: 8192, max: 1048576 },
12463
- // 8 MiB .. 1 GiB
12464
- iterations: { min: 1, max: 16 },
12465
- parallelism: { min: 1, max: 16 }
12466
- };
12467
- var AES_KEY_BYTES = 32;
12468
- var AES_NONCE_BYTES = 12;
12469
- var StatePassphraseInvalid = class extends Error {
12470
- constructor() {
12471
- super("state.passphrase_invalid: AES-256-GCM tag check failed");
12472
- this.name = "StatePassphraseInvalid";
12473
- }
12474
- };
12475
- var StateCorrupt = class extends Error {
12476
- constructor(reason) {
12477
- super(`state.corrupt: ${reason}`);
12478
- this.name = "StateCorrupt";
12479
- }
12480
- };
12481
- function toB642(bytes) {
12482
- return Buffer.from(bytes).toString("base64");
12483
- }
12484
- function fromB642(s) {
12485
- return new Uint8Array(Buffer.from(s, "base64"));
12486
- }
12487
- var CURRENT_KDF_PARAMS = {
12488
- memory_kib: ARGON2_MEMORY_KIB,
12489
- iterations: ARGON2_ITERATIONS,
12490
- parallelism: ARGON2_PARALLELISM
12491
- };
12492
- function boundedKdfParam(name, value) {
12493
- if (typeof value !== "number" || !Number.isInteger(value)) {
12494
- throw new StateCorrupt(`kdf.${name} is not an integer: ${String(value)}`);
12495
- }
12496
- const { min, max } = KDF_BOUNDS[name];
12497
- if (value < min || value > max) {
12498
- throw new StateCorrupt(`kdf.${name}=${value} outside sane bounds [${min}, ${max}]`);
12499
- }
12500
- return value;
12501
- }
12502
- async function deriveKek(passphrase, saltB64, params) {
12503
- const salt = fromB642(saltB64);
12504
- if (salt.length !== ARGON2_SALT_BYTES) {
12505
- throw new StateCorrupt(`salt length is ${salt.length}, expected ${ARGON2_SALT_BYTES}`);
12506
- }
12507
- const kek = await import_argon2.default.hash(passphrase, {
12508
- type: import_argon2.default.argon2id,
12509
- raw: true,
12510
- salt: Buffer.from(salt),
12511
- memoryCost: params.memory_kib,
12512
- timeCost: params.iterations,
12513
- parallelism: params.parallelism,
12514
- hashLength: AES_KEY_BYTES
12515
- });
12516
- return new Uint8Array(kek);
12517
- }
12518
- async function aesGcmEncrypt(kek, nonce, plaintext) {
12519
- const key = await import_node_crypto3.webcrypto.subtle.importKey("raw", kek, "AES-GCM", false, ["encrypt"]);
12520
- const ct = await import_node_crypto3.webcrypto.subtle.encrypt(
12521
- { name: "AES-GCM", iv: nonce, tagLength: 128 },
12522
- key,
12523
- plaintext
12524
- );
12525
- return new Uint8Array(ct);
12526
- }
12527
- async function aesGcmDecrypt(kek, nonce, ciphertextWithTag) {
12528
- const key = await import_node_crypto3.webcrypto.subtle.importKey("raw", kek, "AES-GCM", false, ["decrypt"]);
12529
- try {
12530
- const pt = await import_node_crypto3.webcrypto.subtle.decrypt(
12531
- { name: "AES-GCM", iv: nonce, tagLength: 128 },
12532
- key,
12533
- ciphertextWithTag
12534
- );
12535
- return new Uint8Array(pt);
12536
- } catch {
12537
- throw new StatePassphraseInvalid();
12538
- }
12539
- }
12540
- function createEmptyState(identity) {
12541
- return {
12542
- version: STATE_VERSION,
12543
- identity,
12544
- handles: {},
12545
- audit: [],
12546
- pending: {}
12547
- };
12548
- }
12549
- function validateEnvelope(json) {
12550
- if (typeof json !== "object" || json === null) {
12551
- throw new StateCorrupt("envelope is not an object");
12552
- }
12553
- const e = json;
12554
- if (e.version !== STATE_VERSION) {
12555
- throw new StateCorrupt(`unknown version: ${String(e.version)}`);
12556
- }
12557
- if (e.format !== FORMAT_TAG) {
12558
- throw new StateCorrupt(`unknown format tag: ${String(e.format)}`);
12559
- }
12560
- const kdf = e.kdf;
12561
- if (!kdf || kdf.algo !== "argon2id" || typeof kdf.salt_b64 !== "string") {
12562
- throw new StateCorrupt("kdf header missing or wrong algo");
12563
- }
12564
- const cipher = e.cipher;
12565
- if (!cipher || cipher.algo !== "aes-256-gcm" || typeof cipher.nonce_b64 !== "string" || typeof cipher.ciphertext_b64 !== "string") {
12566
- throw new StateCorrupt("cipher header missing or wrong algo");
12567
- }
12568
- }
12569
- function validatePlaintext(json) {
12570
- if (typeof json !== "object" || json === null) {
12571
- throw new StateCorrupt("plaintext is not an object");
12572
- }
12573
- const s = json;
12574
- if (s.version !== STATE_VERSION) {
12575
- throw new StateCorrupt(`plaintext version: ${String(s.version)}`);
12576
- }
12577
- const id = s.identity;
12578
- if (!id || typeof id.x25519_secret_b64 !== "string" || typeof id.x25519_public_b64 !== "string") {
12579
- throw new StateCorrupt("identity missing or malformed");
12580
- }
12581
- if (typeof s.handles !== "object" || s.handles === null) {
12582
- throw new StateCorrupt("handles is not an object");
12583
- }
12584
- if (!Array.isArray(s.audit)) {
12585
- throw new StateCorrupt("audit is not an array");
12586
- }
12587
- if (s.pending !== void 0 && (typeof s.pending !== "object" || s.pending === null)) {
12588
- throw new StateCorrupt("pending is not an object");
12589
- }
12590
- }
12591
- async function loadState(passphrase, statePath) {
12592
- const raw = await import_node_fs2.promises.readFile(statePath, "utf8");
12593
- let envelope;
12594
- try {
12595
- envelope = JSON.parse(raw);
12596
- } catch (err2) {
12597
- throw new StateCorrupt(`envelope is not valid JSON: ${err2.message}`);
12598
- }
12599
- validateEnvelope(envelope);
12600
- const kek = await deriveKek(passphrase, envelope.kdf.salt_b64, {
12601
- memory_kib: boundedKdfParam("memory_kib", envelope.kdf.memory_kib),
12602
- iterations: boundedKdfParam("iterations", envelope.kdf.iterations),
12603
- parallelism: boundedKdfParam("parallelism", envelope.kdf.parallelism)
12604
- });
12605
- const nonce = fromB642(envelope.cipher.nonce_b64);
12606
- if (nonce.length !== AES_NONCE_BYTES) {
12607
- throw new StateCorrupt(`nonce length is ${nonce.length}, expected ${AES_NONCE_BYTES}`);
12608
- }
12609
- const ct = fromB642(envelope.cipher.ciphertext_b64);
12610
- const ptBytes = await aesGcmDecrypt(kek, nonce, ct);
12611
- const ptText = new TextDecoder("utf-8", { fatal: true }).decode(ptBytes);
12612
- let plaintext;
12613
- try {
12614
- plaintext = JSON.parse(ptText);
12615
- } catch (err2) {
12616
- throw new StateCorrupt(`plaintext is not valid JSON: ${err2.message}`);
12617
- }
12618
- validatePlaintext(plaintext);
12619
- if (plaintext.pending === void 0) {
12620
- plaintext.pending = {};
12621
- }
12622
- return plaintext;
12623
- }
12624
- async function saveState(state, passphrase, statePath) {
12625
- validatePlaintext(state);
12626
- const salt = (0, import_node_crypto3.randomBytes)(ARGON2_SALT_BYTES);
12627
- const nonce = (0, import_node_crypto3.randomBytes)(AES_NONCE_BYTES);
12628
- const kek = await deriveKek(passphrase, toB642(salt), CURRENT_KDF_PARAMS);
12629
- const ptBytes = new TextEncoder().encode(JSON.stringify(state));
12630
- const ct = await aesGcmEncrypt(kek, nonce, ptBytes);
12631
- const envelope = {
12632
- version: STATE_VERSION,
12633
- format: FORMAT_TAG,
12634
- kdf: {
12635
- algo: "argon2id",
12636
- memory_kib: ARGON2_MEMORY_KIB,
12637
- iterations: ARGON2_ITERATIONS,
12638
- parallelism: ARGON2_PARALLELISM,
12639
- salt_b64: toB642(salt)
12640
- },
12641
- cipher: {
12642
- algo: "aes-256-gcm",
12643
- nonce_b64: toB642(nonce),
12644
- ciphertext_b64: toB642(ct),
12645
- tag_included: true
12887
+ outcome: "failed",
12888
+ error: `backend mismatch: op belongs to ${op.backend_url}, server configured for ${client.baseUrl}`,
12889
+ at: nowIso(opts.now)
12890
+ };
12891
+ report.stillFailing.push(handle);
12892
+ changed = true;
12893
+ log4(
12894
+ `[seal-mcp] recovery: NOT compensating ${handle} \u2014 it belongs to ${op.backend_url} but this server talks to ${client.baseUrl}; kept in journal until the server runs against the original backend`
12895
+ );
12896
+ continue;
12646
12897
  }
12647
- };
12648
- const dir = (0, import_node_path3.dirname)(statePath);
12649
- await import_node_fs2.promises.mkdir(dir, { recursive: true, mode: 448 });
12650
- const tmp = `${statePath}.tmp-${process.pid}-${Date.now()}`;
12651
- const fh = await import_node_fs2.promises.open(tmp, "w", 384);
12652
- try {
12653
- await fh.writeFile(JSON.stringify(envelope), "utf8");
12654
- await fh.sync();
12655
- } finally {
12656
- await fh.close();
12657
- }
12658
- await import_node_fs2.promises.rename(tmp, statePath);
12659
- try {
12660
- const dh = await import_node_fs2.promises.open(dir, "r");
12661
12898
  try {
12662
- await dh.sync();
12663
- } finally {
12664
- await dh.close();
12899
+ await client.revoke(op.seal_id, op.owner_token_b64);
12900
+ delete view.pending[handle];
12901
+ report.revoked.push(handle);
12902
+ changed = true;
12903
+ log4(`[seal-mcp] recovery: revoked orphaned seal for ${handle} (step was '${op.step}')`);
12904
+ } catch (err2) {
12905
+ const msg = err2 instanceof Error ? err2.message : String(err2);
12906
+ op.step = "compensating";
12907
+ op.compensation = { action: "revoke", outcome: "failed", error: msg, at: nowIso(opts.now) };
12908
+ report.stillFailing.push(handle);
12909
+ changed = true;
12910
+ log4(
12911
+ `[seal-mcp] recovery: revoke retry for ${handle} failed (${msg}) \u2014 kept in journal, seal expires by TTL at ${op.expires_at}`
12912
+ );
12665
12913
  }
12666
- } catch {
12667
12914
  }
12668
- await import_node_fs2.promises.chmod(dir, 448).catch(() => {
12669
- });
12915
+ return { report, changed };
12916
+ }
12917
+ function adoptAsHandle(view, handle, op, now) {
12918
+ const sealId = op.seal_id;
12919
+ if (view.handles[handle] === void 0) {
12920
+ view.handles[handle] = {
12921
+ seal_id: sealId,
12922
+ owner_token_b64: op.owner_token_b64,
12923
+ created_at: op.started_at,
12924
+ mode: op.mode,
12925
+ expires_at: op.expires_at,
12926
+ // Propagate provenance so a later seal_revoke on the adopted
12927
+ // handle still refuses to target the wrong backend.
12928
+ backend_url: op.backend_url
12929
+ };
12930
+ view.audit.push({ ts: nowIso(now), tool: "seal_share", handle, mode: op.mode });
12931
+ }
12932
+ delete view.pending[handle];
12670
12933
  }
12671
12934
 
12672
12935
  // src/tools/context.ts
@@ -12685,19 +12948,18 @@ var HANDLE_REGEX = /^h_[A-Za-z0-9_-]{12,}$/;
12685
12948
  function isValidHandle(handle) {
12686
12949
  return typeof handle === "string" && HANDLE_REGEX.test(handle);
12687
12950
  }
12951
+ function auditEntry(ctx, tool, handle, mode) {
12952
+ return { ts: (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString(), tool, handle, mode };
12953
+ }
12688
12954
  function appendAudit(ctx, tool, handle, mode) {
12689
- ctx.state.audit.push({
12690
- ts: (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
12691
- tool,
12692
- handle,
12693
- mode
12694
- });
12955
+ const entry = auditEntry(ctx, tool, handle, mode);
12956
+ return ctx.update((state) => void state.audit.push(entry));
12695
12957
  }
12696
- function insertHandle(ctx, handle, entry) {
12697
- if (ctx.state.handles[handle]) {
12958
+ function insertHandle(state, handle, entry) {
12959
+ if (state.handles[handle]) {
12698
12960
  throw new Error(`insertHandle: collision on ${handle}`);
12699
12961
  }
12700
- ctx.state.handles[handle] = entry;
12962
+ state.handles[handle] = entry;
12701
12963
  }
12702
12964
  function lookupHandle(ctx, handle) {
12703
12965
  const entry = ctx.state.handles[handle];
@@ -12717,9 +12979,76 @@ var ToolError = class extends Error {
12717
12979
  }
12718
12980
  };
12719
12981
 
12982
+ // src/tools/inbox.ts
12983
+ var HANDLE_PREFIX2 = "in_";
12984
+ function inboxError(err2) {
12985
+ if (!(err2 instanceof BackendError)) return err2;
12986
+ if (err2.appCode === "intake.proof_required") {
12987
+ return new ToolError("inbox_proof_failed", `the server refused the proof of this server's key: ${err2.message}`, err2);
12988
+ }
12989
+ return new ToolError("inbox_failed", `the inbox failed (${err2.status}): ${err2.message}`, err2);
12990
+ }
12991
+ function memoryOf(ctx) {
12992
+ if (!ctx.inbox) throw new ToolError("internal_inconsistency", "this server keeps no inbox memory");
12993
+ return ctx.inbox;
12994
+ }
12995
+ async function sealInbox(ctx, input = {}) {
12996
+ const memory = memoryOf(ctx);
12997
+ const deadline = Date.now() + (memory.version === null ? 0 : (input.wait ?? 0) * 1e3);
12998
+ let seen = memory.version ?? void 0;
12999
+ let inbox;
13000
+ for (; ; ) {
13001
+ const left = Math.ceil((deadline - Date.now()) / 1e3);
13002
+ try {
13003
+ inbox = await readInbox(ctx.client, ctx.identity.secret, left > 0 ? { waitSeconds: left, seen } : {});
13004
+ } catch (err2) {
13005
+ throw inboxError(err2);
13006
+ }
13007
+ const arrived = inbox.items.some((item) => !memory.items.has(HANDLE_PREFIX2 + item.id));
13008
+ if (left <= 0 || arrived || Date.now() >= deadline) break;
13009
+ seen = inbox.version;
13010
+ }
13011
+ memory.items.clear();
13012
+ for (const item of inbox.items) memory.items.set(HANDLE_PREFIX2 + item.id, item);
13013
+ memory.version = inbox.version;
13014
+ return {
13015
+ addressEmoji: keyEmoji(ctx.identity.public),
13016
+ items: inbox.items.map((item) => ({
13017
+ handle: HANDLE_PREFIX2 + item.id,
13018
+ from: item.sender === null ? null : keyEmoji(base64urlToBytes(item.sender)),
13019
+ label: item.label,
13020
+ receivedAt: item.at,
13021
+ expiresAt: item.expires_at
13022
+ }))
13023
+ };
13024
+ }
13025
+ async function inboxItem(ctx, handle) {
13026
+ const memory = memoryOf(ctx);
13027
+ if (!memory.items.has(handle)) await sealInbox(ctx, {});
13028
+ const item = memory.items.get(handle);
13029
+ if (!item) {
13030
+ throw new ToolError(
13031
+ "inbox_item_gone",
13032
+ `${handle} is not in the inbox: the seal or link was revoked or expired, or the item was removed`
13033
+ );
13034
+ }
13035
+ return item;
13036
+ }
13037
+ async function forgetInboxItem(ctx, handle) {
13038
+ const item = memoryOf(ctx).items.get(handle);
13039
+ if (!item) return;
13040
+ try {
13041
+ await removeFromInbox(ctx.client, ctx.identity.secret, item.id);
13042
+ } catch (err2) {
13043
+ throw inboxError(err2);
13044
+ }
13045
+ memoryOf(ctx).items.delete(handle);
13046
+ }
13047
+
12720
13048
  // src/tools/list.ts
12721
13049
  var SINGLE_FILE_COUNT = 1;
12722
13050
  async function sealList(ctx, input = {}) {
13051
+ await appendAudit(ctx, "seal_list", null, null);
12723
13052
  const now = (ctx.now?.() ?? /* @__PURE__ */ new Date()).getTime();
12724
13053
  const entries = [];
12725
13054
  for (const [handle, entry] of Object.entries(ctx.state.handles)) {
@@ -12736,15 +13065,13 @@ async function sealList(ctx, input = {}) {
12736
13065
  });
12737
13066
  }
12738
13067
  entries.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
12739
- appendAudit(ctx, "seal_list", null, null);
12740
- await ctx.persist();
12741
13068
  return { entries };
12742
13069
  }
12743
13070
 
12744
13071
  // src/tools/open.ts
12745
13072
  var import_promises3 = require("fs/promises");
12746
13073
  var import_node_os3 = require("os");
12747
- var import_node_path4 = require("path");
13074
+ var import_node_path5 = require("path");
12748
13075
  var INLINE_ALLOWED_MIMES = ["application/json", "text/csv", "text/plain"];
12749
13076
  var INLINE_INJECTION_RISK_MIMES = /* @__PURE__ */ new Set([
12750
13077
  "text/html",
@@ -12809,11 +13136,28 @@ async function download(ctx, sealId, file) {
12809
13136
  return resp.body;
12810
13137
  }
12811
13138
  async function sealOpen(ctx, input) {
13139
+ if (input.url === void 0 === (input.item === void 0)) {
13140
+ throw new ToolError("url_invalid", "give exactly one of `url` (a SEAL link) and `item` (an inbox handle from seal_inbox)");
13141
+ }
13142
+ const recipient = { recipientSecret: ctx.identity.secret };
13143
+ if (input.url !== void 0) return openSeal(ctx, input, () => openShareLink(ctx.client, input.url, recipient));
13144
+ const handle = input.item;
13145
+ const item = await inboxItem(ctx, handle);
13146
+ try {
13147
+ return await openSeal(ctx, input, () => openShareLink(ctx.client, inboxItemUrl(resolvePublicHost(), item), recipient));
13148
+ } catch (err2) {
13149
+ if (err2 instanceof ToolError && (err2.code === "seal_revoked" || err2.code === "share_link_revoked")) {
13150
+ await forgetInboxItem(ctx, handle).catch(() => void 0);
13151
+ }
13152
+ throw err2;
13153
+ }
13154
+ }
13155
+ async function openSeal(ctx, input, read) {
12812
13156
  const mode = input.mode ?? "metadata";
12813
13157
  const handle = generateHandle();
12814
13158
  let seal;
12815
13159
  try {
12816
- seal = await openShareLink(ctx.client, input.url, { recipientSecret: ctx.identity.secret });
13160
+ seal = await read();
12817
13161
  } catch (err2) {
12818
13162
  throw asToolError(err2);
12819
13163
  }
@@ -12822,13 +13166,11 @@ async function sealOpen(ctx, input) {
12822
13166
  if (mode === "inline") {
12823
13167
  const refusal = classifyInlineRefusal(first.mime);
12824
13168
  if (refusal !== null) {
12825
- appendAudit(ctx, "seal_open", handle, mode);
12826
- await ctx.persist();
13169
+ await appendAudit(ctx, "seal_open", handle, mode);
12827
13170
  return { handle, refusal, mime: first.mime, note: refusalNote(refusal, first.mime) };
12828
13171
  }
12829
13172
  if (first.size > INLINE_CIPHERTEXT_MAX_BYTES) {
12830
- appendAudit(ctx, "seal_open", handle, mode);
12831
- await ctx.persist();
13173
+ await appendAudit(ctx, "seal_open", handle, mode);
12832
13174
  return {
12833
13175
  handle,
12834
13176
  refusal: "inline_blocked.too_large",
@@ -12864,12 +13206,10 @@ async function sealOpen(ctx, input) {
12864
13206
  err2
12865
13207
  );
12866
13208
  }
12867
- appendAudit(ctx, "seal_open", handle, mode);
12868
- await ctx.persist();
13209
+ await appendAudit(ctx, "seal_open", handle, mode);
12869
13210
  return { handle, mime: bareMime(first.mime), sizeBytes: plaintext.byteLength, encoding: "utf-8", content };
12870
13211
  }
12871
- appendAudit(ctx, "seal_open", handle, mode);
12872
- await ctx.persist();
13212
+ await appendAudit(ctx, "seal_open", handle, mode);
12873
13213
  return {
12874
13214
  handle,
12875
13215
  filename: first.name,
@@ -12891,7 +13231,7 @@ async function openToDisk(ctx, seal, handle, input) {
12891
13231
  `the link carries no key for any file in seal ${seal.sealId} (files appended after it was minted); ask the sender for a fresh link`
12892
13232
  );
12893
13233
  }
12894
- const dir = (0, import_node_path4.resolve)(input.dir ?? (0, import_node_path4.join)((0, import_node_os3.homedir)(), "Downloads"));
13234
+ const dir = (0, import_node_path5.resolve)(input.dir ?? (0, import_node_path5.join)((0, import_node_os3.homedir)(), "Downloads"));
12895
13235
  try {
12896
13236
  await (0, import_promises3.mkdir)(dir, { recursive: true });
12897
13237
  } catch (err2) {
@@ -12915,15 +13255,14 @@ async function openToDisk(ctx, seal, handle, input) {
12915
13255
  throw streamError(err2, file.id);
12916
13256
  }
12917
13257
  }
12918
- appendAudit(ctx, "seal_open", handle, "file");
12919
- await ctx.persist();
13258
+ await appendAudit(ctx, "seal_open", handle, "file");
12920
13259
  return { handle, files: out };
12921
13260
  }
12922
13261
 
12923
13262
  // src/tools/pro.ts
12924
13263
  var import_promises4 = require("fs/promises");
12925
13264
  var import_node_os4 = require("os");
12926
- var import_node_path5 = require("path");
13265
+ var import_node_path6 = require("path");
12927
13266
  var SECRET_CODES = {
12928
13267
  not_found: "pro_secret_not_found",
12929
13268
  not_granted: "pro_secret_not_granted",
@@ -12948,9 +13287,9 @@ async function asTool(fn, codes = SECRET_CODES) {
12948
13287
  }
12949
13288
  async function sealProSecretUse(source, input) {
12950
13289
  const got = await asTool(() => source.getSecret(input.secret, input.version));
12951
- const dir = await (0, import_promises4.mkdtemp)((0, import_node_path5.join)((0, import_node_os4.tmpdir)(), "seal-mcp-pro-"));
13290
+ const dir = await (0, import_promises4.mkdtemp)((0, import_node_path6.join)((0, import_node_os4.tmpdir)(), "seal-mcp-pro-"));
12952
13291
  const name = (got.path?.split("/").pop() || got.id).replace(/[^A-Za-z0-9._-]/g, "_");
12953
- const path = (0, import_node_path5.join)(dir, name);
13292
+ const path = (0, import_node_path6.join)(dir, name);
12954
13293
  try {
12955
13294
  await (0, import_promises4.writeFile)(path, got.value, { mode: 384, flag: "wx" });
12956
13295
  return { path, sizeBytes: got.value.length, secretId: got.id, version: got.version };
@@ -12964,7 +13303,7 @@ async function sealProSecretRequest(source, input) {
12964
13303
  return asTool(() => source.waitForAccess(asked));
12965
13304
  }
12966
13305
  async function sealProFileGet(source, input) {
12967
- const dir = (0, import_node_path5.resolve)(input.dir ?? (0, import_node_path5.join)((0, import_node_os4.homedir)(), "Downloads"));
13306
+ const dir = (0, import_node_path6.resolve)(input.dir ?? (0, import_node_path6.join)((0, import_node_os4.homedir)(), "Downloads"));
12968
13307
  const ids = input.file ? [input.file] : (await asTool(() => source.listFiles(input.seal), FILE_CODES)).map((f) => f.id);
12969
13308
  const files = [];
12970
13309
  for (const id of ids) {
@@ -12976,16 +13315,16 @@ async function sealProFileGet(source, input) {
12976
13315
 
12977
13316
  // src/tools/request.ts
12978
13317
  var import_node_child_process2 = require("child_process");
12979
- var import_node_fs3 = require("fs");
13318
+ var import_node_fs4 = require("fs");
12980
13319
  var import_promises5 = require("fs/promises");
12981
13320
  var import_node_os5 = require("os");
12982
- var import_node_path6 = require("path");
13321
+ var import_node_path7 = require("path");
12983
13322
  function findSealCli() {
12984
13323
  const names = process.platform === "win32" ? ["seal.exe", "seal.cmd"] : ["seal"];
12985
- for (const dir of (process.env.PATH ?? "").split(import_node_path6.delimiter)) {
13324
+ for (const dir of (process.env.PATH ?? "").split(import_node_path7.delimiter)) {
12986
13325
  for (const name of names) {
12987
- const candidate = (0, import_node_path6.join)(dir, name);
12988
- if (dir && (0, import_node_fs3.existsSync)(candidate)) return candidate;
13326
+ const candidate = (0, import_node_path7.join)(dir, name);
13327
+ if (dir && (0, import_node_fs4.existsSync)(candidate)) return candidate;
12989
13328
  }
12990
13329
  }
12991
13330
  return null;
@@ -13019,8 +13358,7 @@ async function sealRequest(ctx, input, deps) {
13019
13358
  if (input.whereUrl) cliArgs.push("--where", input.whereUrl);
13020
13359
  const local = await (deps.runCli ?? runSealCli)(cliArgs);
13021
13360
  if (local && "files" in local) {
13022
- appendAudit(ctx, "seal_request", handle, "local");
13023
- await ctx.persist();
13361
+ await appendAudit(ctx, "seal_request", handle, "local");
13024
13362
  return {
13025
13363
  handle,
13026
13364
  source: "local",
@@ -13060,8 +13398,7 @@ async function sealRequest(ctx, input, deps) {
13060
13398
  }
13061
13399
  return receiveThroughLink(ctx, handle, intake.id, deps, input.onProgress);
13062
13400
  }
13063
- appendAudit(ctx, "seal_request", handle, "link");
13064
- await ctx.persist();
13401
+ await appendAudit(ctx, "seal_request", handle, "link");
13065
13402
  return { handle, url, emoji, expiresAt: intake.expires_at };
13066
13403
  }
13067
13404
  async function receiveThroughLink(ctx, handle, intakeId, deps, onProgress) {
@@ -13078,12 +13415,11 @@ async function receiveThroughLink(ctx, handle, intakeId, deps, onProgress) {
13078
13415
  );
13079
13416
  }
13080
13417
  await deps.elicitationDone?.(intakeId).catch(() => void 0);
13081
- const dir = await (0, import_promises5.mkdtemp)((0, import_node_path6.join)((0, import_node_os5.tmpdir)(), "seal-mcp-request-"));
13418
+ const dir = await (0, import_promises5.mkdtemp)((0, import_node_path7.join)((0, import_node_os5.tmpdir)(), "seal-mcp-request-"));
13082
13419
  const opened = await sealOpen(ctx, { url: `${deps.publicHost}${sharePath}`, mode: "file", dir, onProgress });
13083
13420
  if (!("files" in opened)) throw new ToolError("internal_inconsistency", "seal_open mode=file returned no files");
13084
13421
  ctx.requests?.delete(handle);
13085
- appendAudit(ctx, "seal_request", handle, "link");
13086
- await ctx.persist();
13422
+ await appendAudit(ctx, "seal_request", handle, "link");
13087
13423
  return {
13088
13424
  handle,
13089
13425
  source: "link",
@@ -13096,6 +13432,7 @@ async function sealRevoke(ctx, input) {
13096
13432
  if (!isValidHandle(input.handle)) {
13097
13433
  throw new ToolError("handle_invalid", `handle does not match contract pattern: ${input.handle}`);
13098
13434
  }
13435
+ await ctx.update(() => false);
13099
13436
  const entry = lookupHandle(ctx, input.handle);
13100
13437
  const now = (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
13101
13438
  if (entry.revoked_at) {
@@ -13130,16 +13467,18 @@ async function sealRevoke(ctx, input) {
13130
13467
  err2
13131
13468
  );
13132
13469
  }
13133
- entry.revoked_at = now;
13134
- appendAudit(ctx, "seal_revoke", input.handle, entry.mode);
13135
- await ctx.persist();
13470
+ await ctx.update((state) => {
13471
+ state.handles[input.handle] = { ...entry, revoked_at: now };
13472
+ state.audit.push(auditEntry(ctx, "seal_revoke", input.handle, entry.mode));
13473
+ });
13136
13474
  return { revoked: true, revokedAt: now };
13137
13475
  }
13138
13476
 
13139
13477
  // src/tools/share.ts
13140
- var import_node_fs5 = require("fs");
13478
+ var import_node_fs6 = require("fs");
13141
13479
  var import_promises9 = require("fs/promises");
13142
- var import_node_path9 = require("path");
13480
+ var import_node_os8 = require("os");
13481
+ var import_node_path10 = require("path");
13143
13482
 
13144
13483
  // ../../contracts/limits.json
13145
13484
  var limits_default2 = {
@@ -13166,14 +13505,16 @@ var limits_default2 = {
13166
13505
  INTAKE_TTL_DEFAULT_SECONDS: 900,
13167
13506
  INTAKE_TTL_MAX_SECONDS: 86400,
13168
13507
  INTAKE_WAIT_MAX_SECONDS: 25,
13169
- PAYMENT_WAIT_MAX_SECONDS: 82800
13508
+ PAYMENT_WAIT_MAX_SECONDS: 82800,
13509
+ ADDRESS_PROOF_TTL_SECONDS: 300,
13510
+ INBOX_MAX_ITEMS: 200
13170
13511
  }
13171
13512
  };
13172
13513
 
13173
13514
  // src/handoff.ts
13174
13515
  var import_promises6 = require("fs/promises");
13175
13516
  var import_node_os6 = require("os");
13176
- var import_node_path7 = require("path");
13517
+ var import_node_path8 = require("path");
13177
13518
  var ONE_HOUR_MS = 36e5;
13178
13519
  var HandoffBothChannelsFailedError = class extends Error {
13179
13520
  code = "handoff_both_channels_failed";
@@ -13189,10 +13530,10 @@ var HandoffBothChannelsFailedError = class extends Error {
13189
13530
  function handoffDir(deps) {
13190
13531
  const xdg = process.env.XDG_RUNTIME_DIR;
13191
13532
  const base = deps.tmpdirOverride ?? (xdg && xdg.length > 0 ? xdg : (0, import_node_os6.tmpdir)());
13192
- return (0, import_node_path7.join)(base, "seal-mcp");
13533
+ return (0, import_node_path8.join)(base, "seal-mcp");
13193
13534
  }
13194
13535
  function handoffPath(deps, handle) {
13195
- return (0, import_node_path7.join)(handoffDir(deps), `handoff-${handle}.txt`);
13536
+ return (0, import_node_path8.join)(handoffDir(deps), `handoff-${handle}.txt`);
13196
13537
  }
13197
13538
  async function writeClipboard(text) {
13198
13539
  const mod = await import("clipboardy");
@@ -13259,10 +13600,10 @@ async function performHandoff(payload, deps = {}) {
13259
13600
  }
13260
13601
 
13261
13602
  // src/zip.ts
13262
- var import_node_fs4 = require("fs");
13603
+ var import_node_fs5 = require("fs");
13263
13604
  var import_promises7 = require("fs/promises");
13264
13605
  var import_node_os7 = require("os");
13265
- var import_node_path8 = require("path");
13606
+ var import_node_path9 = require("path");
13266
13607
  var import_node_stream = require("stream");
13267
13608
  var import_promises8 = require("stream/promises");
13268
13609
  var ZIP32_MAX_BYTES = 4294967295;
@@ -13270,16 +13611,16 @@ async function collectFiles(dir) {
13270
13611
  const found = [];
13271
13612
  const walk = async (current) => {
13272
13613
  for (const dirent of await (0, import_promises7.readdir)(current, { withFileTypes: true })) {
13273
- const abs = (0, import_node_path8.join)(current, dirent.name);
13614
+ const abs = (0, import_node_path9.join)(current, dirent.name);
13274
13615
  if (dirent.isDirectory()) await walk(abs);
13275
- else if (dirent.isFile()) found.push({ rel: (0, import_node_path8.relative)(dir, abs).split(import_node_path8.sep).join("/"), abs });
13616
+ else if (dirent.isFile()) found.push({ rel: (0, import_node_path9.relative)(dir, abs).split(import_node_path9.sep).join("/"), abs });
13276
13617
  }
13277
13618
  };
13278
13619
  await walk(dir);
13279
13620
  found.sort((a, b) => a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
13280
13621
  return Promise.all(
13281
13622
  found.map(async ({ rel, abs }) => {
13282
- const file = new File([await (0, import_node_fs4.openAsBlob)(abs)], (0, import_node_path8.basename)(abs));
13623
+ const file = new File([await (0, import_node_fs5.openAsBlob)(abs)], (0, import_node_path9.basename)(abs));
13283
13624
  return Object.defineProperty(file, "webkitRelativePath", { value: rel });
13284
13625
  })
13285
13626
  );
@@ -13289,19 +13630,19 @@ async function planFolderArchive(dir) {
13289
13630
  if (files.length === 0) {
13290
13631
  throw new Error(`directory contains no regular files: ${dir}`);
13291
13632
  }
13292
- const folderName = (0, import_node_path8.basename)((0, import_node_path8.resolve)(dir)) || "folder";
13633
+ const folderName = (0, import_node_path9.basename)((0, import_node_path9.resolve)(dir)) || "folder";
13293
13634
  const size = estimateZipSize({ files, folderName });
13294
13635
  if (size > ZIP32_MAX_BYTES) {
13295
13636
  throw new Error(`the folder's archive would be ${size} bytes, over the ZIP limit of ${ZIP32_MAX_BYTES}`);
13296
13637
  }
13297
13638
  const write = async () => {
13298
- const tempDir = await (0, import_promises7.mkdtemp)((0, import_node_path8.join)((0, import_node_os7.tmpdir)(), "seal-mcp-zip-"));
13639
+ const tempDir = await (0, import_promises7.mkdtemp)((0, import_node_path9.join)((0, import_node_os7.tmpdir)(), "seal-mcp-zip-"));
13299
13640
  const cleanup = () => (0, import_promises7.rm)(tempDir, { recursive: true, force: true });
13300
- const path = (0, import_node_path8.join)(tempDir, `${folderName}.zip`);
13641
+ const path = (0, import_node_path9.join)(tempDir, `${folderName}.zip`);
13301
13642
  try {
13302
13643
  await (0, import_promises8.pipeline)(
13303
13644
  import_node_stream.Readable.fromWeb(createZipStream2({ files, folderName })),
13304
- (0, import_node_fs4.createWriteStream)(path, { mode: 384 })
13645
+ (0, import_node_fs5.createWriteStream)(path, { mode: 384 })
13305
13646
  );
13306
13647
  const written = (await (0, import_promises7.stat)(path)).size;
13307
13648
  if (written !== size) {
@@ -13393,31 +13734,46 @@ var FORWARD_MAX_READS_FORCED = 1;
13393
13734
  function nowIso2(ctx) {
13394
13735
  return (ctx.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
13395
13736
  }
13396
- async function persistBestEffort(ctx) {
13737
+ function journal(ctx, handle, op) {
13738
+ return ctx.update((state) => {
13739
+ state.pending[handle] = op;
13740
+ });
13741
+ }
13742
+ function dropJournal(ctx, handle) {
13743
+ return ctx.update((state) => {
13744
+ delete state.pending[handle];
13745
+ });
13746
+ }
13747
+ function complete(ctx, handle, entry) {
13748
+ return ctx.update((state) => {
13749
+ insertHandle(state, handle, entry);
13750
+ state.audit.push(auditEntry(ctx, "seal_share", handle, entry.mode));
13751
+ delete state.pending[handle];
13752
+ });
13753
+ }
13754
+ async function bestEffort(write) {
13397
13755
  try {
13398
- await ctx.persist();
13756
+ await write;
13399
13757
  } catch (err2) {
13400
13758
  const msg = err2 instanceof Error ? err2.message : String(err2);
13401
- process.stderr.write(`[seal-mcp] warn: journal persist failed during compensation: ${msg}
13759
+ process.stderr.write(`[seal-mcp] warn: journal write failed: ${msg}
13402
13760
  `);
13403
13761
  }
13404
13762
  }
13405
- async function compensateRevoke(ctx, handle) {
13406
- const op = ctx.state.pending[handle];
13407
- const sealId = op?.seal_id;
13408
- if (!op || sealId === void 0) {
13763
+ async function compensateRevoke(ctx, handle, op) {
13764
+ const sealId = op.seal_id;
13765
+ if (sealId === void 0) {
13409
13766
  return "no live seal to revoke";
13410
13767
  }
13411
13768
  try {
13412
13769
  await ctx.client.revoke(sealId, op.owner_token_b64);
13413
- delete ctx.state.pending[handle];
13414
- await persistBestEffort(ctx);
13770
+ await bestEffort(dropJournal(ctx, handle));
13415
13771
  return "seal auto-revoked";
13416
13772
  } catch (err2) {
13417
13773
  const msg = err2 instanceof Error ? err2.message : String(err2);
13418
13774
  op.step = "compensating";
13419
13775
  op.compensation = { action: "revoke", outcome: "failed", error: msg, at: nowIso2(ctx) };
13420
- await persistBestEffort(ctx);
13776
+ await bestEffort(journal(ctx, handle, op));
13421
13777
  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`;
13422
13778
  }
13423
13779
  }
@@ -13442,7 +13798,7 @@ async function openSource(path, mime, requestedTier) {
13442
13798
  } catch (err2) {
13443
13799
  throw failed("unable to open", err2);
13444
13800
  }
13445
- let name = (0, import_node_path9.basename)(path);
13801
+ let name = (0, import_node_path10.basename)(path);
13446
13802
  let source = path;
13447
13803
  let cleanup = async () => void 0;
13448
13804
  let tier;
@@ -13468,7 +13824,7 @@ async function openSource(path, mime, requestedTier) {
13468
13824
  }
13469
13825
  let fileBlob;
13470
13826
  try {
13471
- fileBlob = await (0, import_node_fs5.openAsBlob)(source);
13827
+ fileBlob = await (0, import_node_fs6.openAsBlob)(source);
13472
13828
  } catch (err2) {
13473
13829
  await cleanup();
13474
13830
  throw failed("unable to open", err2);
@@ -13589,13 +13945,14 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13589
13945
  // backend this seal is actually created on — recovery refuses to
13590
13946
  // "revoke" against a different backend whose 404 would be misread
13591
13947
  // as success.
13592
- backend_url: ctx.client.baseUrl
13948
+ backend_url: ctx.client.baseUrl,
13949
+ // SPEC-ADDRESS F1 §4: another MCP host starting now must not recover
13950
+ // (revoke) this upload while it runs.
13951
+ owner: { pid: process.pid, hostname: (0, import_node_os8.hostname)() }
13593
13952
  };
13594
- ctx.state.pending[handle] = op;
13595
13953
  try {
13596
- await ctx.persist();
13954
+ await journal(ctx, handle, op);
13597
13955
  } catch (err2) {
13598
- delete ctx.state.pending[handle];
13599
13956
  throw new ToolError(
13600
13957
  "state_persist_failed",
13601
13958
  `refusing to upload: could not journal the operation before its first remote effect: ${err2 instanceof Error ? err2.message : String(err2)}`,
@@ -13616,7 +13973,7 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13616
13973
  // `uploaded` checkpoint after this call is the durability gate.
13617
13974
  onSealCreated: async (sealId) => {
13618
13975
  op.seal_id = sealId;
13619
- await persistBestEffort(ctx);
13976
+ await bestEffort(journal(ctx, handle, op));
13620
13977
  }
13621
13978
  });
13622
13979
  } catch (err2) {
@@ -13625,15 +13982,14 @@ async function uploadAndDeliver(ctx, input, deps, prepared) {
13625
13982
  return awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, expiresAt, session });
13626
13983
  }
13627
13984
  if (op.seal_id !== void 0) {
13628
- const outcome = await compensateRevoke(ctx, handle);
13985
+ const outcome = await compensateRevoke(ctx, handle, op);
13629
13986
  throw new ToolError(
13630
13987
  "upload_failed",
13631
13988
  `seal upload failed after the seal was created: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
13632
13989
  err2
13633
13990
  );
13634
13991
  }
13635
- delete ctx.state.pending[handle];
13636
- await persistBestEffort(ctx);
13992
+ await bestEffort(dropJournal(ctx, handle));
13637
13993
  throw new ToolError(
13638
13994
  "upload_failed",
13639
13995
  `seal upload failed: ${err2 instanceof Error ? err2.message : String(err2)}`,
@@ -13646,9 +14002,9 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13646
14002
  op.seal_id = uploaded.sealId;
13647
14003
  op.step = "uploaded";
13648
14004
  try {
13649
- await ctx.persist();
14005
+ await journal(ctx, handle, op);
13650
14006
  } catch (err2) {
13651
- const outcome = await compensateRevoke(ctx, handle);
14007
+ const outcome = await compensateRevoke(ctx, handle, op);
13652
14008
  throw new ToolError(
13653
14009
  "state_persist_failed",
13654
14010
  `could not journal the live seal after upload: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13661,7 +14017,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13661
14017
  try {
13662
14018
  shareBlobBytes = await encryptShareBlob(fileKeysB64, shareLinkKey, void 0);
13663
14019
  } catch (err2) {
13664
- const outcome = await compensateRevoke(ctx, handle);
14020
+ const outcome = await compensateRevoke(ctx, handle, op);
13665
14021
  throw new ToolError(
13666
14022
  "share_blob_failed",
13667
14023
  `share blob encryption failed: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
@@ -13669,17 +14025,6 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13669
14025
  );
13670
14026
  }
13671
14027
  const shareBlobB64 = bytesToBase64url(shareBlobBytes);
13672
- let shareCode;
13673
- try {
13674
- shareCode = await ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64);
13675
- } catch (err2) {
13676
- const outcome = await compensateRevoke(ctx, handle);
13677
- throw new ToolError(
13678
- "share_link_failed",
13679
- `POST /seal/${uploaded.sealId}/share failed: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
13680
- err2
13681
- );
13682
- }
13683
14028
  let fragment;
13684
14029
  if (mode === "forward") {
13685
14030
  if (recipientPub === null) {
@@ -13691,7 +14036,7 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13691
14036
  try {
13692
14037
  fragment = await buildTargetedFragment(shareLinkKey, recipientPub, uploaded.sealId);
13693
14038
  } catch (err2) {
13694
- const outcome = await compensateRevoke(ctx, handle);
14039
+ const outcome = await compensateRevoke(ctx, handle, op);
13695
14040
  const message = err2 instanceof Error ? err2.message : String(err2);
13696
14041
  if (message.includes("non-contributory")) {
13697
14042
  throw new ToolError(
@@ -13709,10 +14054,33 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13709
14054
  } else {
13710
14055
  fragment = bytesToBase64url(shareLinkKey);
13711
14056
  }
14057
+ if (mode === "forward") await journalDelivering(ctx, handle, op);
14058
+ let shareCode;
14059
+ try {
14060
+ shareCode = mode === "forward" && recipientPub !== null ? await withAddressProof(
14061
+ ctx.client,
14062
+ ctx.identity.secret,
14063
+ void 0,
14064
+ (sender) => ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64, {
14065
+ to: bytesToBase64url(recipientPub),
14066
+ fragment,
14067
+ label: deps.label?.slice(0, 80),
14068
+ sender
14069
+ })
14070
+ ) : await ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64);
14071
+ } catch (err2) {
14072
+ const outcome = await compensateRevoke(ctx, handle, op);
14073
+ const appCode = err2 instanceof BackendError ? err2.appCode : void 0;
14074
+ throw new ToolError(
14075
+ appCode === "intake.inbox_full" ? "inbox_full" : appCode === "intake.proof_required" ? "inbox_proof_failed" : "share_link_failed",
14076
+ `POST /seal/${uploaded.sealId}/share failed: ${messageOf3(err2)} (${outcome})`,
14077
+ err2
14078
+ );
14079
+ }
13712
14080
  const host = resolvePublicHost(deps.publicHost);
13713
14081
  const shareUrl = `${host}/s/${uploaded.sealId}/${shareCode}#${fragment}`;
13714
14082
  if (mode === "forward") {
13715
- insertHandle(ctx, handle, {
14083
+ await complete(ctx, handle, {
13716
14084
  seal_id: uploaded.sealId,
13717
14085
  owner_token_b64: ownerTokenB64,
13718
14086
  created_at: nowIso2(ctx),
@@ -13720,41 +14088,29 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13720
14088
  expires_at: expiresAt,
13721
14089
  backend_url: ctx.client.baseUrl
13722
14090
  });
13723
- appendAudit(ctx, "seal_share", handle, mode);
13724
- delete ctx.state.pending[handle];
13725
- await ctx.persist();
13726
14091
  return {
13727
14092
  handle,
13728
14093
  shareUrl,
13729
14094
  expiresAt,
13730
14095
  maxReads: FORWARD_MAX_READS_FORCED,
13731
- mode: "forward"
14096
+ mode: "forward",
14097
+ delivered: true
13732
14098
  };
13733
14099
  }
13734
- op.step = "delivering";
13735
14100
  op.handoff_channels_attempted = deps.handoff?.disableClipboard ? ["file"] : ["clipboard", "file"];
13736
- try {
13737
- await ctx.persist();
13738
- } catch (err2) {
13739
- const outcome = await compensateRevoke(ctx, handle);
13740
- throw new ToolError(
13741
- "state_persist_failed",
13742
- `could not journal the delivery attempt before handing off the URL: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
13743
- err2
13744
- );
13745
- }
14101
+ await journalDelivering(ctx, handle, op);
13746
14102
  let handoff;
13747
14103
  try {
13748
14104
  handoff = await performHandoff({ handle, shareUrl, expireMs }, deps.handoff);
13749
14105
  } catch (err2) {
13750
- const outcome = await compensateRevoke(ctx, handle);
14106
+ const outcome = await compensateRevoke(ctx, handle, op);
13751
14107
  throw new ToolError(
13752
14108
  "handoff_both_channels_failed",
13753
14109
  `both clipboard and file handoff channels failed \u2014 ${outcome}`,
13754
14110
  err2
13755
14111
  );
13756
14112
  }
13757
- insertHandle(ctx, handle, {
14113
+ await complete(ctx, handle, {
13758
14114
  seal_id: uploaded.sealId,
13759
14115
  owner_token_b64: ownerTokenB64,
13760
14116
  created_at: nowIso2(ctx),
@@ -13763,9 +14119,6 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13763
14119
  handoff_channel: handoff.channel,
13764
14120
  backend_url: ctx.client.baseUrl
13765
14121
  });
13766
- appendAudit(ctx, "seal_share", handle, mode);
13767
- delete ctx.state.pending[handle];
13768
- await ctx.persist();
13769
14122
  const delivered = handoff.channel === "clipboard" ? HANDOFF_OUTPUT.receipt_clipboard : HANDOFF_OUTPUT.receipt_file.replace("{file}", handoff.file ?? "");
13770
14123
  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.`;
13771
14124
  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;
@@ -13780,16 +14133,29 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13780
14133
  function messageOf3(err2) {
13781
14134
  return err2 instanceof Error ? err2.message : String(err2);
13782
14135
  }
14136
+ async function journalDelivering(ctx, handle, op) {
14137
+ op.step = "delivering";
14138
+ try {
14139
+ await journal(ctx, handle, op);
14140
+ } catch (err2) {
14141
+ const outcome = await compensateRevoke(ctx, handle, op);
14142
+ throw new ToolError(
14143
+ "state_persist_failed",
14144
+ `could not journal the delivery attempt before the link could reach anyone: ${messageOf3(err2)} (${outcome})`,
14145
+ err2
14146
+ );
14147
+ }
14148
+ }
13783
14149
  async function awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, session }) {
13784
14150
  const tier = prepared.tier;
13785
14151
  const sealId = op.seal_id;
13786
14152
  op.step = "awaiting_payment";
13787
- await persistBestEffort(ctx);
14153
+ await bestEffort(journal(ctx, handle, op));
13788
14154
  let paymentUrl;
13789
14155
  try {
13790
14156
  paymentUrl = await ctx.client.createCheckout(sealId, ownerTokenB64, tier);
13791
14157
  } catch (err2) {
13792
- const outcome = await compensateRevoke(ctx, handle);
14158
+ const outcome = await compensateRevoke(ctx, handle, op);
13793
14159
  throw new ToolError("payment_failed", `POST /billing/checkout failed: ${messageOf3(err2)} (${outcome})`, err2);
13794
14160
  }
13795
14161
  const deadline = paymentDeadline(op.expires_at);
@@ -13799,7 +14165,7 @@ async function awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, se
13799
14165
  clearTimeout(timer);
13800
14166
  ctx.payments?.delete(handle);
13801
14167
  await prepared.cleanup();
13802
- throw new ToolError(code, `${why} (${await compensateRevoke(ctx, handle)})`);
14168
+ throw new ToolError(code, `${why} (${await compensateRevoke(ctx, handle, op)})`);
13803
14169
  };
13804
14170
  const finish = async (onProgress) => {
13805
14171
  if (running) throw new ToolError("payment_invalid", `share ${handle} is already being finished`);
@@ -13871,7 +14237,8 @@ function shareOutputToWire(out) {
13871
14237
  share_url: out.shareUrl,
13872
14238
  expires_at: out.expiresAt,
13873
14239
  max_reads: out.maxReads,
13874
- mode: out.mode
14240
+ mode: out.mode,
14241
+ delivered: out.delivered
13875
14242
  };
13876
14243
  }
13877
14244
  const wire = {
@@ -13938,6 +14305,18 @@ function openOutputToWire(out) {
13938
14305
  expires_at: out.expiresAt
13939
14306
  };
13940
14307
  }
14308
+ function inboxOutputToWire(out) {
14309
+ return {
14310
+ address_emoji: out.addressEmoji,
14311
+ items: out.items.map((item) => ({
14312
+ handle: item.handle,
14313
+ from: item.from,
14314
+ label: item.label,
14315
+ received_at: item.receivedAt,
14316
+ expires_at: item.expiresAt
14317
+ }))
14318
+ };
14319
+ }
13941
14320
  function listOutputToWire(out) {
13942
14321
  return {
13943
14322
  entries: out.entries.map((entry) => ({
@@ -13968,7 +14347,7 @@ function proFileGetOutputToWire(out) {
13968
14347
  }
13969
14348
 
13970
14349
  // src/server.ts
13971
- var SERVER_VERSION = "0.2.5";
14350
+ var SERVER_VERSION = "0.4.0";
13972
14351
  var toolMeta = (name) => {
13973
14352
  const { title, description, annotations } = seal_mcp_v1_default.tools[name];
13974
14353
  return { title, description, annotations };
@@ -13976,6 +14355,7 @@ var toolMeta = (name) => {
13976
14355
  var SHARE_INPUT = seal_mcp_v1_default.tools.seal_share.input.properties;
13977
14356
  var REQUEST_INPUT = seal_mcp_v1_default.tools.seal_request.input.properties;
13978
14357
  var OPEN_INPUT = seal_mcp_v1_default.tools.seal_open.input.properties;
14358
+ var INBOX_INPUT = seal_mcp_v1_default.tools.seal_inbox.input.properties;
13979
14359
  var PRO_SECRET_USE = seal_mcp_v1_default.pro_tools.seal_pro_secret_use;
13980
14360
  var PRO_SECRET_REQUEST = seal_mcp_v1_default.pro_tools.seal_pro_secret_request;
13981
14361
  var PRO_FILE_GET = seal_mcp_v1_default.pro_tools.seal_pro_file_get;
@@ -13995,44 +14375,33 @@ function progressReporter(extra) {
13995
14375
  }
13996
14376
  async function createServer(options) {
13997
14377
  logEngineToStderr();
13998
- let view;
13999
- let identity;
14000
- let lock = null;
14001
14378
  const client = new SealApi({ baseUrl: resolveBackendUrl(options.backendBaseUrl) });
14002
14379
  const disk = options.state;
14380
+ let view;
14381
+ const update = async (mutate) => {
14382
+ if (disk === null) await mutate(view);
14383
+ else view = await updateState(disk.passphrase, disk.path, mutate);
14384
+ };
14385
+ let identity;
14003
14386
  if (disk === null) {
14004
14387
  identity = await generateIdentity();
14005
14388
  view = createEmptyState(identityToFields(identity));
14006
14389
  } else {
14007
- const held = await acquireStateLock((0, import_node_path10.dirname)(disk.path));
14008
- lock = held;
14009
- try {
14010
- view = await loadState(disk.passphrase, disk.path);
14011
- identity = await loadIdentity(view.identity);
14012
- const { changed } = await recoverPendingOperations(view, client);
14013
- if (changed) {
14014
- await saveState(view, disk.passphrase, disk.path);
14015
- }
14016
- } catch (err2) {
14017
- await held.release();
14018
- throw err2;
14019
- }
14390
+ view = await updateState(disk.passphrase, disk.path, async (state) => (await recoverPendingOperations(state, client)).changed);
14391
+ identity = await loadIdentity(view.identity);
14020
14392
  }
14021
14393
  const publicHost = resolvePublicHost(options.publicHost);
14022
- let persistChain = Promise.resolve();
14023
- const persist = () => {
14024
- if (disk === null) return Promise.resolve();
14025
- persistChain = persistChain.catch(() => void 0).then(() => saveState(view, disk.passphrase, disk.path));
14026
- return persistChain;
14027
- };
14028
14394
  const ctx = {
14029
- state: view,
14395
+ get state() {
14396
+ return view;
14397
+ },
14030
14398
  identity,
14031
14399
  client,
14032
- persist,
14400
+ update,
14033
14401
  ephemeral: disk === null,
14034
14402
  requests: /* @__PURE__ */ new Map(),
14035
- payments: /* @__PURE__ */ new Map()
14403
+ payments: /* @__PURE__ */ new Map(),
14404
+ inbox: { items: /* @__PURE__ */ new Map(), version: null }
14036
14405
  };
14037
14406
  const mcp = new import_mcp.McpServer(
14038
14407
  { name: "sealnet-mcp", version: SERVER_VERSION },
@@ -14079,6 +14448,7 @@ async function createServer(options) {
14079
14448
  },
14080
14449
  {
14081
14450
  publicHost,
14451
+ label: client2.getClientVersion()?.name,
14082
14452
  handoff: disableClipboard ? { disableClipboard: true } : void 0,
14083
14453
  elicitUrl: urlMode ? async (params) => (await client2.elicitInput({ mode: "url", ...params })).action : void 0,
14084
14454
  elicitationDone: urlMode ? (id) => client2.createElicitationCompletionNotifier(id)() : void 0
@@ -14132,19 +14502,37 @@ async function createServer(options) {
14132
14502
  {
14133
14503
  ...toolMeta("seal_open"),
14134
14504
  inputSchema: {
14135
- url: external_exports.string().url().describe(OPEN_INPUT.url.description),
14505
+ // Exactly one of `url` and `item`: the handler refuses anything else (contract `one_of`).
14506
+ url: external_exports.string().url().optional().describe(OPEN_INPUT.url.description),
14507
+ item: external_exports.string().regex(/^in_/).optional().describe(OPEN_INPUT.item.description),
14136
14508
  mode: external_exports.enum(["metadata", "inline", "file"]).default("metadata").describe(OPEN_INPUT.mode.description),
14137
14509
  dir: external_exports.string().optional().describe(OPEN_INPUT.dir.description)
14138
14510
  }
14139
14511
  },
14140
14512
  async (args, extra) => {
14141
14513
  const result = await wrapTool(
14142
- () => sealOpen(ctx, { url: args.url, mode: args.mode, dir: args.dir, onProgress: progressReporter(extra) }),
14514
+ () => sealOpen(ctx, {
14515
+ url: args.url,
14516
+ item: args.item,
14517
+ mode: args.mode,
14518
+ dir: args.dir,
14519
+ onProgress: progressReporter(extra)
14520
+ }),
14143
14521
  openOutputToWire
14144
14522
  );
14145
14523
  return result;
14146
14524
  }
14147
14525
  );
14526
+ mcp.registerTool(
14527
+ "seal_inbox",
14528
+ {
14529
+ ...toolMeta("seal_inbox"),
14530
+ inputSchema: {
14531
+ wait: external_exports.number().int().min(0).max(300).default(0).describe(INBOX_INPUT.wait.description)
14532
+ }
14533
+ },
14534
+ async (args) => wrapTool(() => sealInbox(ctx, { wait: args.wait }), inboxOutputToWire)
14535
+ );
14148
14536
  mcp.registerTool(
14149
14537
  "seal_list",
14150
14538
  {
@@ -14232,18 +14620,12 @@ async function createServer(options) {
14232
14620
  );
14233
14621
  }
14234
14622
  const transport = new import_stdio.StdioServerTransport();
14235
- try {
14236
- await mcp.connect(transport);
14237
- } catch (err2) {
14238
- await lock?.release();
14239
- throw err2;
14240
- }
14623
+ await mcp.connect(transport);
14241
14624
  const close = async () => {
14242
14625
  try {
14243
14626
  await mcp.close();
14244
14627
  } finally {
14245
14628
  await transport.close().catch(() => void 0);
14246
- await lock?.release();
14247
14629
  }
14248
14630
  };
14249
14631
  return { mcp, transport, close };
@@ -14257,7 +14639,7 @@ async function wrapTool(fn, toWire) {
14257
14639
  structuredContent: wire
14258
14640
  };
14259
14641
  } catch (err2) {
14260
- if (err2 instanceof ToolError) {
14642
+ if (err2 instanceof ToolError || err2 instanceof StateBusyError) {
14261
14643
  return {
14262
14644
  isError: true,
14263
14645
  content: [
@@ -14279,7 +14661,6 @@ async function wrapTool(fn, toWire) {
14279
14661
  // src/cli.ts
14280
14662
  var PROGRAM_NAME = "sealnet-mcp";
14281
14663
  var DESCRIPTION = "MCP server for SEAL \u2014 share files via Cursor / Claude Desktop without handing the model the share link. Handoff mode (default) delivers the link out-of-band (clipboard / 0o600 file); the model only gets an opaque handle.";
14282
- var MIN_PASSPHRASE_LEN = 8;
14283
14664
  function logStderr(line) {
14284
14665
  process.stderr.write(`${line}
14285
14666
  `);
@@ -14288,118 +14669,33 @@ function logStdout(line) {
14288
14669
  process.stdout.write(`${line}
14289
14670
  `);
14290
14671
  }
14291
- async function pathExists(p) {
14292
- try {
14293
- await (0, import_promises10.stat)(p);
14294
- return true;
14295
- } catch {
14296
- return false;
14297
- }
14672
+ async function agentKey(path) {
14673
+ return await readSeed(path.dir) ?? (await openAgentState(path)).root;
14298
14674
  }
14299
14675
  async function runInit(opts) {
14300
14676
  const path = resolveStateDir({ cliFlag: opts.stateDir });
14301
- if (!opts.force && await pathExists(path.statePath)) {
14677
+ if (opts.force) await withStateLock(path.dir, () => import_node_fs7.promises.rm(path.statePath, { force: true }));
14678
+ const opened = await openAgentState(path);
14679
+ if (!opened.created) {
14302
14680
  logStderr(`${PROGRAM_NAME} init: state file already exists at ${path.statePath}`);
14303
- logStderr("Re-run with --force to overwrite (you will lose existing handles + identity).");
14681
+ logStderr("Re-run with --force to replace it with an empty one (its handles are lost; the address stays).");
14304
14682
  process.exit(2);
14305
14683
  }
14306
- const fromEnv = process.env[PASSPHRASE_ENV_VAR];
14307
- let passphrase;
14308
- if (fromEnv !== void 0 && fromEnv.length > 0) {
14309
- passphrase = fromEnv;
14310
- logStderr(
14311
- `${PROGRAM_NAME} init: using passphrase from ${PASSPHRASE_ENV_VAR} (will also save to OS keychain).`
14312
- );
14313
- } else {
14314
- passphrase = await promptHiddenPassphrase("Choose a passphrase (will not echo): ");
14315
- if (passphrase.length < MIN_PASSPHRASE_LEN) {
14316
- throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`);
14317
- }
14318
- const confirm = await promptHiddenPassphrase("Confirm passphrase: ");
14319
- if (confirm !== passphrase) {
14320
- throw new Error("Passphrases do not match.");
14321
- }
14322
- }
14323
- logStderr(`${PROGRAM_NAME} init: generating X25519 identity ...`);
14324
- const identity = await generateIdentity();
14325
- const identityFields = identityToFields(identity);
14326
- const state = createEmptyState(identityFields);
14327
- logStderr(`${PROGRAM_NAME} init: writing encrypted state to ${path.statePath} ...`);
14328
- const lock = await acquireStateLock(path.dir);
14329
- let keychainStored = false;
14330
- try {
14331
- if (!opts.force && await pathExists(path.statePath)) {
14332
- logStderr(`${PROGRAM_NAME} init: state file already exists at ${path.statePath}`);
14333
- logStderr("Re-run with --force to overwrite (you will lose existing handles + identity).");
14334
- process.exit(2);
14335
- }
14336
- await saveState(state, passphrase, path.statePath);
14337
- try {
14338
- await setKeychainPassphrase(passphrase);
14339
- keychainStored = true;
14340
- logStderr(
14341
- `${PROGRAM_NAME} init: passphrase stored in OS keychain (service=${KEYCHAIN_SERVICE} account=${KEYCHAIN_ACCOUNT}).`
14342
- );
14343
- } catch (err2) {
14344
- if (err2 instanceof KeytarUnavailableError) {
14345
- logStderr(`${PROGRAM_NAME} init: warning: ${err2.message}`);
14346
- logStderr(
14347
- `${PROGRAM_NAME} init: state.json IS encrypted, but you must export ${PASSPHRASE_ENV_VAR} before every \`${PROGRAM_NAME} serve\` until the OS keychain works.`
14348
- );
14349
- } else {
14350
- throw err2;
14351
- }
14352
- }
14353
- } finally {
14354
- await lock.release();
14355
- }
14684
+ const identity = await identityFromSeed(opened.root.seed);
14356
14685
  logStdout(`${PROGRAM_NAME} init: ready.`);
14357
- logStdout(` state.json: ${path.statePath}`);
14358
- logStdout(` passphrase source: ${keychainStored ? "OS keychain" : `${PASSPHRASE_ENV_VAR} env`}`);
14359
- logStdout(` pubkey (base64): ${identityFields.x25519_public_b64}`);
14686
+ logStdout(` state.json: ${path.statePath}`);
14687
+ logStdout(` key source: ${opened.root.source}`);
14688
+ logStdout(` address: ${Buffer.from(identity.public).toString("base64url")}`);
14360
14689
  logStdout("");
14361
- logStdout(
14362
- "Share the pubkey with senders so they can target SEALs to your MCP server (see `seal send --to <pubkey>` from `seal-cli`)."
14363
- );
14364
- }
14365
- async function createStateWithoutPrompt(path) {
14366
- const resolved = await passphraseForNewState();
14367
- if (resolved === null) return null;
14368
- const lock = await acquireStateLock(path.dir);
14369
- try {
14370
- if (await pathExists(path.statePath)) return void 0;
14371
- if (resolved.fresh) await setKeychainPassphrase(resolved.passphrase);
14372
- const identity = await generateIdentity();
14373
- await saveState(createEmptyState(identityToFields(identity)), resolved.passphrase, path.statePath);
14374
- } finally {
14375
- await lock.release();
14376
- }
14377
- logStderr(`${PROGRAM_NAME} serve: first start, state created at ${path.statePath}`);
14378
- return resolved;
14690
+ logStdout("Senders use the address to deliver a link only this agent opens (`seal send --to <address>`).");
14379
14691
  }
14380
14692
  async function stateForServe(path) {
14381
- if (!await pathExists(path.statePath)) {
14382
- const created = await createStateWithoutPrompt(path);
14383
- if (created !== void 0) return created;
14384
- }
14385
- const resolved = await resolvePassphraseFromStores();
14386
- if (resolved === null) {
14387
- throw new PassphraseNotFoundError();
14388
- }
14389
- if (resolved.source === "env") {
14390
- logStderr(
14391
- `${PROGRAM_NAME} serve: warning: passphrase read from ${PASSPHRASE_ENV_VAR}. Environment variables are readable via /proc/<pid>/environ on Linux and the process listing on Windows. Prefer the OS keychain (run \`${PROGRAM_NAME} init\`).`
14392
- );
14393
- }
14693
+ let opened;
14394
14694
  try {
14395
- const view = await loadState(resolved.passphrase, path.statePath);
14396
- await loadIdentity(view.identity);
14695
+ opened = await openAgentState(path);
14397
14696
  } catch (err2) {
14398
- if (err2 instanceof StatePassphraseInvalid) {
14399
- logStderr(`${PROGRAM_NAME} serve: passphrase rejected (AES-256-GCM tag check failed).`);
14400
- logStderr(
14401
- `If you reset your OS keychain, re-run \`${PROGRAM_NAME} init --force\` (you will lose existing handles).`
14402
- );
14697
+ if (err2 instanceof IdentityConflictError) {
14698
+ logStderr(`${PROGRAM_NAME} serve: ${err2.message}`);
14403
14699
  process.exit(3);
14404
14700
  }
14405
14701
  if (err2 instanceof StateCorrupt) {
@@ -14408,29 +14704,27 @@ async function stateForServe(path) {
14408
14704
  }
14409
14705
  throw err2;
14410
14706
  }
14411
- return resolved;
14707
+ if (opened.created) logStderr(`${PROGRAM_NAME} serve: first start, state created at ${path.statePath}`);
14708
+ if (opened.moved !== null) logStderr(`${PROGRAM_NAME} serve: moved the 0.2 state under the agent's key (${opened.root.source})`);
14709
+ if (opened.moved === "env") {
14710
+ logStderr(
14711
+ `${PROGRAM_NAME} serve: ${PASSPHRASE_ENV_VAR} is no longer used; to move this agent to another machine, set ${SEED_ENV_VAR} there (\`${PROGRAM_NAME} seed --force\` prints it).`
14712
+ );
14713
+ }
14714
+ return opened;
14412
14715
  }
14413
14716
  async function runServe(opts) {
14414
14717
  const path = resolveStateDir({ cliFlag: opts.stateDir });
14415
- const resolved = opts.ephemeral ? null : await stateForServe(path);
14718
+ const opened = opts.ephemeral || process.env[EPHEMERAL_ENV_VAR] === "1" ? null : await stateForServe(path);
14416
14719
  logStderr(
14417
- resolved === null ? `${PROGRAM_NAME} serve: ephemeral \u2014 identity and handles live in memory until exit, nothing on disk` : `${PROGRAM_NAME} serve: state OK at ${path.statePath} (passphrase=${resolved.source})`
14720
+ opened === null ? `${PROGRAM_NAME} serve: ephemeral \u2014 a new key, identity and handles live in memory until exit, nothing on disk` : `${PROGRAM_NAME} serve: state OK at ${path.statePath} (key=${opened.root.source})`
14418
14721
  );
14419
14722
  logStderr(`${PROGRAM_NAME} serve: starting stdio MCP server ...`);
14420
- let handle;
14421
- try {
14422
- handle = await createServer({
14423
- state: resolved === null ? null : { path: path.statePath, passphrase: resolved.passphrase },
14424
- backendBaseUrl: opts.backendUrl,
14425
- publicHost: opts.publicHost
14426
- });
14427
- } catch (err2) {
14428
- if (err2 instanceof StateLockedError) {
14429
- logStderr(`${PROGRAM_NAME} serve: ${err2.message}`);
14430
- process.exit(5);
14431
- }
14432
- throw err2;
14433
- }
14723
+ const handle = await createServer({
14724
+ state: opened === null ? null : { path: path.statePath, passphrase: opened.passphrase },
14725
+ backendBaseUrl: opts.backendUrl,
14726
+ publicHost: opts.publicHost
14727
+ });
14434
14728
  const shutdown = async (signal) => {
14435
14729
  logStderr(`${PROGRAM_NAME} serve: received ${signal}, shutting down ...`);
14436
14730
  try {
@@ -14449,28 +14743,39 @@ async function runServe(opts) {
14449
14743
  });
14450
14744
  }
14451
14745
  async function runPubkey(opts) {
14452
- const path = resolveStateDir({ cliFlag: opts.stateDir });
14453
- if (!await pathExists(path.statePath)) {
14454
- throw new Error(`No state found at ${path.statePath}. Run \`${PROGRAM_NAME} init\` first.`);
14746
+ const root = await agentKey(resolveStateDir({ cliFlag: opts.stateDir }));
14747
+ const { public: address } = await identityFromSeed(root.seed);
14748
+ logStdout(Buffer.from(address).toString("base64url"));
14749
+ logStderr(`${keyEmoji(address)} key: ${root.source}`);
14750
+ }
14751
+ async function runSeed(opts) {
14752
+ if (!opts.force) {
14753
+ throw new Error(`seed prints the agent's secret key \u2014 re-run with --force to confirm, to set ${SEED_ENV_VAR} elsewhere.`);
14455
14754
  }
14456
- const resolved = await resolvePassphraseFromStores();
14457
- if (resolved === null) throw new PassphraseNotFoundError();
14458
- const view = await loadState(resolved.passphrase, path.statePath);
14459
- logStdout(view.identity.x25519_public_b64);
14755
+ const root = await agentKey(resolveStateDir({ cliFlag: opts.stateDir }));
14756
+ logStderr(
14757
+ `${PROGRAM_NAME} seed: this is the agent's secret key. Whoever holds it opens the links sent to this agent; put it only in ${SEED_ENV_VAR} of a machine you control.`
14758
+ );
14759
+ logStdout(Buffer.from(root.seed).toString("base64url"));
14760
+ }
14761
+ async function perms(file) {
14762
+ return (0, import_promises10.stat)(file).then(
14763
+ (st) => (st.mode & 511).toString(8).padStart(3, "0"),
14764
+ () => null
14765
+ );
14460
14766
  }
14461
14767
  async function runDoctor(opts) {
14462
14768
  const path = resolveStateDir({ cliFlag: opts.stateDir });
14463
- const envPass = process.env[PASSPHRASE_ENV_VAR];
14464
- let stateExists = false;
14465
- let statePerms = null;
14466
- let stateOwnerUid = null;
14769
+ const seedFile = (0, import_node_path11.join)(path.dir, SEED_FILE_NAME);
14770
+ let seedSource = null;
14771
+ let seedError = null;
14467
14772
  try {
14468
- const st = await (0, import_promises10.stat)(path.statePath);
14469
- stateExists = true;
14470
- statePerms = (st.mode & 511).toString(8).padStart(3, "0");
14471
- stateOwnerUid = typeof st.uid === "number" ? st.uid : null;
14472
- } catch {
14773
+ seedSource = (await readSeed(path.dir))?.source ?? null;
14774
+ } catch (err2) {
14775
+ seedError = err2 instanceof Error ? err2.message : String(err2);
14473
14776
  }
14777
+ const statePerms = await perms(path.statePath);
14778
+ const seedPerms = await perms(seedFile);
14474
14779
  const kc = await probeKeychain();
14475
14780
  const lockProbe = await probeStateLock(path.dir);
14476
14781
  const client = new SealApi({ baseUrl: resolveBackendUrl(opts.backendUrl) });
@@ -14491,103 +14796,101 @@ async function runDoctor(opts) {
14491
14796
  backendReachable = false;
14492
14797
  backendError = err2 instanceof Error ? err2.message : String(err2);
14493
14798
  }
14494
- const report = {
14495
- state_path: path.statePath,
14496
- state_exists: stateExists,
14497
- state_perms_octal: statePerms,
14498
- state_owner_uid: stateOwnerUid,
14499
- lock_held: lockProbe.held,
14500
- lock_holder_pid: lockProbe.holder?.pid ?? null,
14501
- lock_stale: lockProbe.stale,
14502
- keychain_available: kc.available,
14503
- keychain_has_entry: kc.hasEntry,
14504
- env_passphrase_set: envPass !== void 0 && envPass.length > 0,
14505
- backend_url: client.baseUrl,
14506
- public_host: resolvePublicHost(opts.publicHost),
14507
- backend_reachable: backendReachable,
14508
- backend_status: backendStatus,
14509
- backend_error: backendError,
14510
- platform: process.platform,
14511
- node_version: process.version
14512
- };
14513
- logStdout(JSON.stringify(report, null, 2));
14514
- if (!stateExists) {
14515
- logStderr(`hint: no state.json yet \u2014 the first \`${PROGRAM_NAME} serve\` creates it.`);
14516
- } else if (statePerms !== "600") {
14517
- logStderr(`hint: state.json perms are ${statePerms}, expected 600. Run \`chmod 600 \u2026\`.`);
14799
+ logStdout(
14800
+ JSON.stringify(
14801
+ {
14802
+ state_path: path.statePath,
14803
+ state_exists: statePerms !== null,
14804
+ state_perms_octal: statePerms,
14805
+ seed_source: seedSource,
14806
+ seed_error: seedError,
14807
+ seed_file_perms_octal: seedPerms,
14808
+ keychain_available: kc.available,
14809
+ keychain_has_seed: kc.hasSeed,
14810
+ env_seed_set: Boolean(process.env[SEED_ENV_VAR]),
14811
+ env_passphrase_set: Boolean(process.env[PASSPHRASE_ENV_VAR]),
14812
+ lock_held: lockProbe.held,
14813
+ lock_holder_pid: lockProbe.holder?.pid ?? null,
14814
+ lock_stale: lockProbe.stale,
14815
+ backend_url: client.baseUrl,
14816
+ public_host: resolvePublicHost(opts.publicHost),
14817
+ backend_reachable: backendReachable,
14818
+ backend_status: backendStatus,
14819
+ backend_error: backendError,
14820
+ platform: process.platform,
14821
+ node_version: process.version
14822
+ },
14823
+ null,
14824
+ 2
14825
+ )
14826
+ );
14827
+ if (seedError !== null) logStderr(`hint: ${seedError}`);
14828
+ else if (seedSource === null) {
14829
+ logStderr(`hint: no agent key yet \u2014 the first \`${PROGRAM_NAME} serve\` makes it (${kc.available ? "in the OS keychain" : seedFile}).`);
14518
14830
  }
14519
- if (!kc.available) {
14520
- logStderr(
14521
- `hint: keychain unavailable. On Linux start a Secret Service (gnome-keyring or KWallet), or rely on ${PASSPHRASE_ENV_VAR}; with neither, \`serve\` runs ephemeral (handles live until restart).`
14522
- );
14523
- } else if (stateExists && !kc.hasEntry && !report.env_passphrase_set) {
14524
- logStderr(`hint: keychain has no entry and ${PASSPHRASE_ENV_VAR} is unset \u2014 \`serve\` will fail.`);
14831
+ for (const [file, got] of [[path.statePath, statePerms], [seedFile, seedPerms]]) {
14832
+ if (got !== null && got !== "600") logStderr(`hint: ${file} perms are ${got}, expected 600. Run \`chmod 600 \u2026\`.`);
14525
14833
  }
14526
14834
  if (backendReachable === false) {
14527
14835
  logStderr(`hint: backend ${client.baseUrl} unreachable (${backendError ?? "unknown"}).`);
14528
14836
  }
14529
- if (lockProbe.held && !lockProbe.stale) {
14530
- logStderr(
14531
- `hint: state dir is locked by pid ${lockProbe.holder?.pid ?? "?"} \u2014 a serve instance is running.`
14532
- );
14533
- } else if (lockProbe.held && lockProbe.stale) {
14534
- logStderr("hint: stale state.lock detected (holder dead) \u2014 next serve/init will break it automatically.");
14837
+ if (lockProbe.held && lockProbe.stale) {
14838
+ logStderr("hint: stale state.lock detected (holder dead) \u2014 the next write breaks it automatically.");
14535
14839
  }
14536
14840
  }
14537
14841
  async function runReset(opts) {
14538
14842
  if (!opts.force) {
14539
14843
  throw new Error(
14540
- "reset is destructive \u2014 re-run with --force to confirm. This wipes state.json AND your keychain passphrase entry."
14844
+ "reset is destructive \u2014 re-run with --force to confirm. This wipes state.json AND the agent's key: its address is lost for good."
14541
14845
  );
14542
14846
  }
14543
14847
  const path = resolveStateDir({ cliFlag: opts.stateDir });
14544
- const lock = await acquireStateLock(path.dir);
14545
- let stateDeleted = false;
14546
- let keychainDeleted = false;
14547
- try {
14548
- try {
14549
- await import_node_fs6.promises.unlink(path.statePath);
14550
- stateDeleted = true;
14551
- } catch (err2) {
14552
- const code = err2.code;
14553
- if (code !== "ENOENT") {
14554
- throw err2;
14555
- }
14556
- }
14557
- keychainDeleted = await deleteKeychainPassphrase();
14558
- } finally {
14559
- await lock.release();
14560
- }
14848
+ const result = await withStateLock(path.dir, async () => {
14849
+ let stateDeleted = true;
14850
+ await import_node_fs7.promises.unlink(path.statePath).catch((err2) => {
14851
+ if (err2.code !== "ENOENT") throw err2;
14852
+ stateDeleted = false;
14853
+ });
14854
+ return { stateDeleted, ...await deleteRootSecret(path.dir) };
14855
+ });
14561
14856
  logStdout(
14562
14857
  JSON.stringify(
14563
14858
  {
14564
- state_deleted: stateDeleted,
14565
- keychain_deleted: keychainDeleted,
14859
+ state_deleted: result.stateDeleted,
14860
+ seed_file_deleted: result.seedFile,
14861
+ keychain_deleted: result.keychain,
14566
14862
  state_path: path.statePath
14567
14863
  },
14568
14864
  null,
14569
14865
  2
14570
14866
  )
14571
14867
  );
14868
+ logStderr(
14869
+ `${PROGRAM_NAME} reset: the agent's address is gone for good \u2014 links sent to it can no longer be opened, and whoever sends to this agent needs its new address.`
14870
+ );
14871
+ if (process.env[SEED_ENV_VAR]) {
14872
+ logStderr(`${PROGRAM_NAME} reset: ${SEED_ENV_VAR} is still set: the next start uses the key from the environment.`);
14873
+ }
14572
14874
  }
14573
14875
  function buildCli() {
14574
14876
  const program = new import_commander.Command();
14575
14877
  program.name(PROGRAM_NAME).description(DESCRIPTION).version(SERVER_VERSION, "-V, --version", `Print the ${PROGRAM_NAME} version and exit.`);
14576
14878
  program.command("init").description(
14577
- "Optional setup with a passphrase you choose: generate X25519 identity, store the passphrase in OS keychain, and write encrypted state.json (`serve` does this itself with a random passphrase when there is no state)."
14578
- ).option("--state-dir <path>", "Override default state directory").option("--force", "Overwrite an existing state.json (DESTRUCTIVE)", false).action(async (opts) => runInit(opts));
14879
+ "Make the agent's key (SEAL_SEED, else the OS keychain, else a 0600 file) and an empty encrypted state.json, without starting the server (`serve` does this itself on its first start)."
14880
+ ).option("--state-dir <path>", "Override default state directory").option("--force", "Replace an existing state.json with an empty one (DESTRUCTIVE; the address stays)", false).action(async (opts) => runInit(opts));
14579
14881
  program.command("serve", { isDefault: true }).description(`Start the stdio MCP server (DEFAULT \u2014 bare \`${PROGRAM_NAME}\` runs this).`).option("--state-dir <path>", "Override default state directory").option("--backend-url <url>", "SEAL backend base URL (overrides SEAL_MCP_BACKEND_URL; default https://api.seal.net)").option(
14580
14882
  "--public-host <url>",
14581
14883
  "Public origin for minted share URLs (overrides SEAL_MCP_PUBLIC_HOST; default https://seal.net). Set BOTH this and --backend-url for self-hosted deployments."
14582
- ).option("--ephemeral", "Keep identity and handles in memory only; write nothing to disk", false).action(async (opts) => runServe(opts));
14884
+ ).option("--ephemeral", `A new key each start, handles in memory only, nothing on disk (also ${EPHEMERAL_ENV_VAR}=1)`, false).action(async (opts) => runServe(opts));
14583
14885
  program.command("pubkey").description(
14584
- "Print this server's X25519 public key (base64). Senders use it as the `--to` argument of `seal send` to deliver targeted SEALs."
14886
+ "Print the agent's address, its X25519 public key (base64url), on stdout; its emoji and the key's source on stderr. Senders use it as the `--to` of `seal send` to deliver targeted SEALs."
14585
14887
  ).option("--state-dir <path>", "Override default state directory").action(async (opts) => runPubkey(opts));
14888
+ program.command("seed").description(`Print the agent's secret key, to give another machine the same address through ${SEED_ENV_VAR}.`).option("--state-dir <path>", "Override default state directory").option("--force", "Confirm printing the secret key", false).action(async (opts) => runSeed(opts));
14586
14889
  program.command("doctor").description(
14587
- "Diagnose keychain availability, state-file permissions, env-var presence, and backend reachability. Prints a JSON report to stdout + hints to stderr."
14890
+ "Diagnose the agent's key, keychain availability, file permissions and backend reachability. Prints a JSON report to stdout + hints to stderr."
14588
14891
  ).option("--state-dir <path>", "Override default state directory").option("--backend-url <url>", "Backend URL to probe").option("--public-host <url>", "Public share-URL origin to report (overrides SEAL_MCP_PUBLIC_HOST)").action(async (opts) => runDoctor(opts));
14589
14892
  program.command("reset").description(
14590
- "Wipe state.json + keychain passphrase entry. Use only after passphrase loss; destroys all locally-tracked handles and the X25519 identity."
14893
+ "Wipe state.json and the agent's key (seed file and keychain entries): destroys all locally-tracked handles, and the address is lost for good."
14591
14894
  ).option("--state-dir <path>", "Override default state directory").option("--force", "Confirm destructive operation", false).action(async (opts) => runReset(opts));
14592
14895
  return program;
14593
14896
  }