run402 4.69.8 → 4.70.1

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.
Files changed (40) hide show
  1. package/README.md +3 -1
  2. package/gitvault-surface.json +5 -1
  3. package/lib/command-manifest.mjs +2 -0
  4. package/lib/gitvault-capabilities.mjs +6 -0
  5. package/lib/harness-context.mjs +50 -0
  6. package/lib/messages.mjs +83 -1
  7. package/lib/repos.mjs +321 -9
  8. package/lib/rooms-context.mjs +23 -13
  9. package/package.json +1 -1
  10. package/sdk/dist/errors.d.ts +1 -1
  11. package/sdk/dist/errors.d.ts.map +1 -1
  12. package/sdk/dist/errors.js.map +1 -1
  13. package/sdk/dist/namespaces/gitvault.d.ts +227 -0
  14. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.js +612 -2
  16. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  17. package/sdk/dist/namespaces/rooms.d.ts +20 -1
  18. package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/rooms.js +93 -0
  20. package/sdk/dist/namespaces/rooms.js.map +1 -1
  21. package/sdk/dist/namespaces/rooms.types.d.ts +73 -0
  22. package/sdk/dist/namespaces/rooms.types.d.ts.map +1 -1
  23. package/sdk/dist/node/gitvault-address.d.ts +1 -0
  24. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  25. package/sdk/dist/node/gitvault-address.js +5 -1
  26. package/sdk/dist/node/gitvault-address.js.map +1 -1
  27. package/sdk/dist/node/gitvault-handoff.d.ts +109 -31
  28. package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
  29. package/sdk/dist/node/gitvault-handoff.js +264 -137
  30. package/sdk/dist/node/gitvault-handoff.js.map +1 -1
  31. package/sdk/dist/node/gitvault-keystore.d.ts +2 -2
  32. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  33. package/sdk/dist/node/gitvault-restore.d.ts +19 -0
  34. package/sdk/dist/node/gitvault-restore.d.ts.map +1 -1
  35. package/sdk/dist/node/gitvault-restore.js +59 -2
  36. package/sdk/dist/node/gitvault-restore.js.map +1 -1
  37. package/sdk/dist/node/index.d.ts +4 -4
  38. package/sdk/dist/node/index.d.ts.map +1 -1
  39. package/sdk/dist/node/index.js +9 -5
  40. package/sdk/dist/node/index.js.map +1 -1
@@ -53,6 +53,7 @@ function rethrowFatalReconcile(e) {
53
53
  if (isRun402Error(e) && RECONCILE_FATAL_CODES.has(String(e.code)))
54
54
  throw e;
55
55
  }
56
+ import { Rooms } from "./rooms.js";
56
57
  /** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
57
58
  function safePath(derive, repoId) {
58
59
  if (!repoId)
@@ -109,6 +110,17 @@ export function handoffVaultFromWire(wire, address = null) {
109
110
  export function handoffMembershipFromWire(wire) {
110
111
  return { organization_id: wire.org_id, role: wire.role, status: wire.status };
111
112
  }
113
+ // ─── Invite / join result shapes (kygit-invite design D4/D5/D9) ─────────────
114
+ /**
115
+ * An invite mints at `developer` unless `--role` narrows it or the minter's
116
+ * own role is narrower — the ONE descriptor difference from a handoff, which
117
+ * mints at the minter's own role (kygit-invite design D1). Declared here
118
+ * because the client must predict the gateway's attenuated answer EXACTLY:
119
+ * the `writer_admission_grant` is signed with `minted_role` inside it before
120
+ * the mint call, and a disagreement is a `VALIDATION_FAILED` after the
121
+ * checkpoint has already been captured and pushed.
122
+ */
123
+ export const INVITE_DEFAULT_ROLE = "developer";
112
124
  /**
113
125
  * gitvault-byo-primary-bucket task 3.3 — the number of `{key, object_kind}`
114
126
  * entries `GITVAULT_BYO_OBJECT_MISSING`'s `details.missing` lists before
@@ -1249,10 +1261,15 @@ export class Gitvault {
1249
1261
  // it: the pin is the only LOCAL ground truth a slug-form address's own
1250
1262
  // URL text does not carry.
1251
1263
  let pinned = null;
1264
+ // kygit-invite design D9: whether `.git/info/exclude` already carries
1265
+ // `.run402/` — a pure read, computed alongside the pin.
1266
+ let messagingCacheExcluded = null;
1252
1267
  if (options.repo_dir) {
1253
1268
  const { readPinnedGitvaultRepo } = await this.#address();
1254
1269
  const p = await readPinnedGitvaultRepo(options.repo_dir);
1255
- pinned = p ? { repo_id: p.repo_id, resolved_from: p.resolved_from } : null;
1270
+ pinned = p ? { repo_id: p.repo_id, resolved_from: p.resolved_from, room: p.room } : null;
1271
+ const { isMessagingCacheExcludedFromGit } = await this.#restore();
1272
+ messagingCacheExcluded = await isMessagingCacheExcludedFromGit(options.repo_dir);
1256
1273
  }
1257
1274
  // The local git remote, when there is a repository to read it from. A
1258
1275
  // pure read: `status` must never write git configuration.
@@ -1372,6 +1389,7 @@ export class Gitvault {
1372
1389
  },
1373
1390
  remote,
1374
1391
  pinned,
1392
+ messaging_cache_excluded: messagingCacheExcluded,
1375
1393
  refs,
1376
1394
  head_target: headTarget,
1377
1395
  pins: { highest_authenticated: authenticated, highest_materialized: materialized },
@@ -1676,6 +1694,270 @@ export class Gitvault {
1676
1694
  const repoId = await this.#resolveRepoId(options);
1677
1695
  return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/handoffs/${encodeURIComponent(handoffId)}`, { method: "DELETE", context: "revoking a handoff" });
1678
1696
  }
1697
+ // ── Invite / join (kygit-invite design D1-D10) ─────────────────────────────
1698
+ /**
1699
+ * Mint an Invite Key: capture a stash-shaped checkpoint exactly as
1700
+ * {@link Gitvault.handoff} does (design D1), push it (retained, on no
1701
+ * branch), register the INVITER's own presence in the invite's room
1702
+ * (design D4 — before the mint, so the row can carry
1703
+ * `inviter_presence_id`; a registration failure is reported and the mint
1704
+ * proceeds without one), seal the vault's current epoch key under a fresh
1705
+ * `wrap_key`, mint through the gateway at `developer` (or `role`,
1706
+ * attenuated to never exceed the minter's own), and post ONE fact from
1707
+ * the inviter's presence naming the checkpoint and the invite id (never
1708
+ * the key) — a fact-post failure is reported in `room_fact` and never
1709
+ * voids the mint (design D4). The assembled `kgi1_…` key is returned
1710
+ * exactly ONCE — nothing here or downstream persists it. Creating an
1711
+ * invite never touches the inviter's worktree, index, branch, refs, or
1712
+ * access.
1713
+ *
1714
+ * `options.note` omits `capture` — this method fills it with the real
1715
+ * capture figures and runs the client-side secret scan BEFORE the invite
1716
+ * commit is written (design D10/kygit-invite D3: no override flag).
1717
+ */
1718
+ async invite(options) {
1719
+ const [{ deployRefTransaction }, { captureHandoffSnapshot, snapshotCommitment }] = await Promise.all([this.#publication(), this.#snapshot()]);
1720
+ const ho = await nodeOnly(() => import("../node/gitvault-handoff.js"), "invite");
1721
+ const { assembleInviteKey, deriveInviteSecrets, sealInviteEnvelope, assertInviteNoteHasNoSecret, buildWriterAdmissionGrant, deriveInviteWriterAdmissionSeed, INVITE_ENVELOPE_V2_KIND } = ho;
1722
+ const handle = options.address ? (await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: false })).handle : await this.open(options);
1723
+ const repoDir = options.repo_dir ?? process.cwd();
1724
+ const repoFile = handle.keystore.readRepo(handle.repo_id);
1725
+ if (!repoFile) {
1726
+ throw new LocalError(`no local key material for ${handle.repo_id} — this principal is not yet a member with a materialized envelope (push once first)`, "minting an invite", { code: "GITVAULT_VAULT_UNRESOLVED" });
1727
+ }
1728
+ const kRepo = hexToBytes(repoFile.k_repo_hex);
1729
+ // gitvault-multi-writer rev 47 (kygit-invite design D4): only an ACTIVE
1730
+ // WRITER may mint — the gateway enforces it authoritatively
1731
+ // (`403 INVITE_MINT_REQUIRES_WRITER`, carrying `request_writer_sync`),
1732
+ // but failing that late would mean this call already paid for a
1733
+ // checkpoint capture + push + envelope seal for nothing. Identical
1734
+ // local fail-fast to `handoff()`'s, against the `writer_set_pin` this
1735
+ // vault's `open()` just refreshed — cheap and typed, never the source
1736
+ // of truth.
1737
+ const identity = handle.keystore.readIdentity();
1738
+ const localWriterKeyId = identity?.signing_fingerprint ?? null;
1739
+ if (!localWriterKeyId || !repoFile.writer_set_pin?.writers.some((w) => w.writer_key_id === localWriterKeyId)) {
1740
+ throw new LocalError("this keystore's signing key is not an admitted writer of this vault — minting an invite requires an ACTIVE writer (the gateway's own refusal is INVITE_MINT_REQUIRES_WRITER); run `run402 repos access sync` from a live writer, or push once to reconcile if a pending admission already exists", "minting an invite", { code: "GITVAULT_WRITER_NOT_ADMITTED", details: { next_action: "request_writer_sync", command: "run402 repos access sync" } });
1741
+ }
1742
+ // The grant's `minted_role` must EXACTLY predict what the gateway's own
1743
+ // role attenuation will compute (invite defaults to `developer` rather
1744
+ // than the minter's own role — the ONE descriptor difference, design
1745
+ // D1), or the grant fails VALIDATION_FAILED after this call has already
1746
+ // paid for the checkpoint push below.
1747
+ const { predictMintedRole } = await this.#writerState();
1748
+ const vaultRecord = await this.get(handle.repo_id);
1749
+ const whoMints = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's org role for the invite grant" });
1750
+ const inviterMembership = whoMints.memberships.find((m) => m.org_id === vaultRecord.org_id && m.status === "active");
1751
+ if (!inviterMembership) {
1752
+ throw new LocalError(`this principal has no active membership on ${vaultRecord.org_id} — an active writer must also be an active org member to mint an invite`, "minting an invite", { code: "GITVAULT_ACCESS_DENIED" });
1753
+ }
1754
+ const grantMintedRole = predictMintedRole(options.role ?? INVITE_DEFAULT_ROLE, inviterMembership.role);
1755
+ const snapshot = await captureHandoffSnapshot({
1756
+ dir: repoDir,
1757
+ ...(options.includeSensitive !== undefined ? { includeSensitive: options.includeSensitive } : {}),
1758
+ message: (stats) => {
1759
+ const note = {
1760
+ ...options.note,
1761
+ capture: {
1762
+ base_head: stats.base_head_oid,
1763
+ branch: stats.branch,
1764
+ modified_captured: stats.modified_captured.length,
1765
+ untracked_captured: stats.untracked_captured.length,
1766
+ sensitive_excluded: stats.sensitive_excluded,
1767
+ ignored_not_transferred_count: stats.ignored_not_transferred_count,
1768
+ },
1769
+ };
1770
+ assertInviteNoteHasNoSecret(note);
1771
+ return JSON.stringify(note);
1772
+ },
1773
+ });
1774
+ options.onCommitLine?.(`invite checkpoint ${snapshot.oid}`);
1775
+ const materialized = await handle.vault.materialize();
1776
+ const pushResult = await handle.vault.push({
1777
+ transaction: deployRefTransaction(materialized.refs, snapshot.oid),
1778
+ head_target: snapshot.head,
1779
+ protocol_refs: "allow",
1780
+ }).catch((e) => { throw this.#enrichEpochRotationRequired(e, handle.repo_id); });
1781
+ const snapshotOidHmac = snapshotCommitment(kRepo, handle.repo_id, repoFile.epoch, snapshot.oid);
1782
+ // design D4: room resolution — the project's default room (its own id)
1783
+ // unless a named org room was given.
1784
+ const roomKey = options.roomKey ?? repoFile.project_id;
1785
+ const rooms = new Rooms(this.#client);
1786
+ // design D4: register the inviter's OWN presence BEFORE minting, so the
1787
+ // row can carry `inviter_presence_id` — a failure here is reported and
1788
+ // the mint proceeds without one (never blocks the mint).
1789
+ let inviterPresence = null;
1790
+ let inviterPresenceReport = { registered: false };
1791
+ try {
1792
+ // No default `task`: a session key RESUMES the inviter's existing
1793
+ // presence, and a resumption refreshes `task` — a made-up label here
1794
+ // would overwrite whatever the agent is actually working on. The CLI
1795
+ // passes the harness's own thread title when it has one.
1796
+ const registration = await rooms.registerPresence(repoFile.org_id, roomKey, {
1797
+ ...(options.task !== undefined ? { task: options.task } : {}),
1798
+ ...(options.program !== undefined ? { program: options.program } : {}),
1799
+ ...(options.model !== undefined ? { model: options.model } : {}),
1800
+ ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
1801
+ });
1802
+ inviterPresence = { presence_id: registration.presence_id, name: registration.name };
1803
+ inviterPresenceReport = { registered: true, presence_id: registration.presence_id, name: registration.name };
1804
+ }
1805
+ catch (e) {
1806
+ inviterPresenceReport = { registered: false, error: e instanceof Error ? e.message : String(e) };
1807
+ }
1808
+ const inviteId = randomHandoffUuid();
1809
+ const { key, invite_id_bytes, master_secret } = assembleInviteKey(inviteId, randomBytes(32));
1810
+ const secrets = deriveInviteSecrets(invite_id_bytes, master_secret);
1811
+ // gitvault-multi-writer rev 47 (kygit-invite design D4) — the MINTER's
1812
+ // own writer key signs `writer_admission_grant`, authorizing whoever
1813
+ // claims this invite to become a writer under its OWN key. Built and
1814
+ // SIGNED BEFORE the envelope is sealed ("no hash cycle: grant first,
1815
+ // then seal") so the v2 envelope below can embed the grant's own
1816
+ // stored-bytes SHA-256 and `join()` can cross-check the claim
1817
+ // response's grant against what this call actually sealed, independent
1818
+ // of anything the gateway could alter.
1819
+ //
1820
+ // The grant object, the `add_writer_key` authorization kind, and the
1821
+ // acceptance signature domain are all spelled `handoff` on the wire and
1822
+ // stay that way (design D11): the r402s/v0 bytes are frozen by
1823
+ // conformance vectors, so an invite admission rides the SAME bytes with
1824
+ // the INVITE id in `handoff_id`. One admission door, two product doors.
1825
+ const signingKeypair = handle.keystore.signingKeypair(identity); // non-null: the writer precheck above already required identity.signing_fingerprint
1826
+ if (!signingKeypair) {
1827
+ throw new LocalError("this keystore has no local signing seed — it can read the vault's writer identity but cannot sign a writer_admission_grant from here (a read-only recovery identity); mint from a checkout that holds the full signing seed", "minting an invite", { code: "VAULT_UNRECOVERABLE" });
1828
+ }
1829
+ const inviteAdmissionSeed = deriveInviteWriterAdmissionSeed(invite_id_bytes, master_secret);
1830
+ const grant = buildWriterAdmissionGrant({
1831
+ repo_id: handle.repo_id,
1832
+ handoff_id: inviteId,
1833
+ auth_hash: secrets.auth_hash_hex,
1834
+ checkpoint_generation: pushResult.generation,
1835
+ checkpoint_head_sha256: pushResult.head_sha256,
1836
+ minted_role: grantMintedRole,
1837
+ claim_not_after: new Date(Date.now() + (options.ttlSeconds ?? 3600) * 1000).toISOString(),
1838
+ grantor_signing_seed: signingKeypair.seed,
1839
+ handoff_admission_pubkey: ed25519PublicKey(inviteAdmissionSeed),
1840
+ });
1841
+ const grantSha256Local = sha256Hex(jcs(grant));
1842
+ const sealed = sealInviteEnvelope(invite_id_bytes, secrets.wrap_key, {
1843
+ v: 2,
1844
+ kind: "invite",
1845
+ repo_id: handle.repo_id,
1846
+ epoch: repoFile.epoch,
1847
+ k_e_hex: repoFile.k_repo_hex,
1848
+ // Every epoch key this keystore holds, not only the current one: an
1849
+ // invite minted AFTER an epoch rotation must let the joiner open the
1850
+ // pre-rotation generations too ("membership grants FULL history"),
1851
+ // and no rotation ever re-wraps old epochs to a principal that did
1852
+ // not exist then.
1853
+ epoch_keys: { ...(repoFile.epoch_keys ?? {}), [repoFile.epoch]: repoFile.k_repo_hex },
1854
+ checkpoint: { generation: pushResult.generation, commit_oid: snapshot.oid },
1855
+ note_schema: "kygit.invite-note.v1",
1856
+ writer_admission_grant_sha256: grantSha256Local,
1857
+ });
1858
+ // The wire shape mirrors the handoff mint's documented one (llms-full.txt
1859
+ // "Handoff / resume", gitvault-invite spec): `role` (the minted role),
1860
+ // `repo_id` / `org_id` / `project_id`, `room`, a verbatim `warning`
1861
+ // sentence plus its machine-readable `warnings[]` twin.
1862
+ const response = await this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(handle.repo_id)}/invites`, {
1863
+ method: "POST",
1864
+ body: {
1865
+ invite_id: inviteId,
1866
+ ...(options.role !== undefined ? { role: options.role } : {}),
1867
+ ...(options.ttlSeconds !== undefined ? { expires_in_seconds: options.ttlSeconds } : {}),
1868
+ // An explicit room_key is validated as a slug by the gateway; an
1869
+ // empty one (a keystore file with no project id) is OMITTED so the
1870
+ // gateway applies its own default — the vault's project — instead
1871
+ // of refusing a blank.
1872
+ ...(roomKey ? { room_key: roomKey } : {}),
1873
+ ...(inviterPresence ? { inviter_presence_id: inviterPresence.presence_id } : {}),
1874
+ checkpoint: { generation: pushResult.generation, snapshot_oid_hmac: snapshotOidHmac },
1875
+ sealed_envelope: sealed.sealed_envelope,
1876
+ envelope_kind: sealed.envelope_kind ?? INVITE_ENVELOPE_V2_KIND,
1877
+ auth_hash: secrets.auth_hash_hex,
1878
+ writer_admission_grant: toBase64url(jcs(grant)),
1879
+ },
1880
+ context: "minting an invite key",
1881
+ });
1882
+ if (response.invite_id !== inviteId) {
1883
+ throw new LocalError(`the gateway minted a different invite_id (${response.invite_id}) than requested (${inviteId}) — the assembled key would not match; retry`, "minting an invite key", { code: "INVITE_ID_MISMATCH", details: { requested: inviteId, minted: response.invite_id } });
1884
+ }
1885
+ // The gateway names back the SHA-256 of the EXACT writer_admission_grant
1886
+ // bytes it stored — verified against this call's own local computation
1887
+ // (never the other way around) so a gateway that silently altered the
1888
+ // grant is caught here, before the key is ever handed to a joiner.
1889
+ if (response.writer_admission_grant_sha256 !== grantSha256Local) {
1890
+ throw new LocalError(`the gateway echoed a writer_admission_grant_sha256 (${response.writer_admission_grant_sha256}) that does not match what this call sent (${grantSha256Local}) — the stored grant may not be the one this call signed; do not distribute this invite key`, "minting an invite key", { code: "INVITE_MINT_GRANT_MISMATCH", details: { expected: grantSha256Local, received: response.writer_admission_grant_sha256 } });
1891
+ }
1892
+ // design D4: post the fact AFTER the mint succeeds — a mint refusal must
1893
+ // leave no orphan message, and a fact-post failure never voids a mint
1894
+ // the caller may already have copied the key from.
1895
+ let roomFact = { posted: false, reason: "inviter presence was not registered" };
1896
+ if (inviterPresence) {
1897
+ const receiptShort = snapshot.oid.slice(0, 12);
1898
+ const inviteShort = response.invite_id.slice(0, 8);
1899
+ try {
1900
+ const sent = await rooms.sendMessage(repoFile.org_id, roomKey, {
1901
+ body: `Invited another agent from checkpoint ${receiptShort} (invite ${inviteShort}, expires ${response.expires_at}).`,
1902
+ presenceId: inviterPresence.presence_id,
1903
+ ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
1904
+ idempotencyKey: `invite:${response.invite_id}:minted`,
1905
+ });
1906
+ roomFact = { posted: true, message_id: sent.message_id, cursor: sent.cursor };
1907
+ }
1908
+ catch (e) {
1909
+ roomFact = { posted: false, reason: e instanceof Error ? e.message : String(e) };
1910
+ }
1911
+ }
1912
+ const nextActions = [...(response.next_actions ?? [])];
1913
+ if (!nextActions.some((a) => a.type === "join_invite")) {
1914
+ // design D9: "CLI-synthesized, the recipient's exact command" —
1915
+ // rendered by DOOR, same as every other remote-facing command this
1916
+ // module renders (`gitvaultRemoteScheme()` is the one place that
1917
+ // decides `run402` vs `kygit`, per `RUN402_REMOTE_SCHEME`).
1918
+ const door = gitvaultRemoteScheme();
1919
+ nextActions.push({
1920
+ type: "join_invite",
1921
+ command: door === "kygit" ? `kygit join ${key}` : `run402 repos join ${key}`,
1922
+ why: "Run this on the other agent's machine to claim the invite.",
1923
+ safe_to_auto_execute: false,
1924
+ });
1925
+ }
1926
+ if (!nextActions.some((a) => a.type === "wait_room")) {
1927
+ nextActions.push({ type: "wait_room", command: "run402 messages wait", why: "Block until the joining agent speaks; silence returns who is still here." });
1928
+ }
1929
+ return {
1930
+ invite_key: key,
1931
+ invite_id: response.invite_id,
1932
+ kind: response.kind,
1933
+ minted_role: response.role,
1934
+ expires_at: response.expires_at,
1935
+ vault: handoffVaultFromWire(response, options.address && gitvaultRemoteAddressForm(options.address) === "slug" ? `${options.address.org_id}/${options.address.project_id}` : null),
1936
+ room: { organization_id: response.room?.org_id ?? repoFile.org_id, room_key: response.room?.room_key ?? roomKey },
1937
+ checkpoint: response.checkpoint,
1938
+ capture: {
1939
+ modified_captured: snapshot.modified_captured.length,
1940
+ untracked_captured: snapshot.untracked_captured.length,
1941
+ sensitive_excluded: snapshot.sensitive_excluded,
1942
+ ignored_not_transferred_count: snapshot.ignored_not_transferred_count,
1943
+ },
1944
+ snapshot,
1945
+ inviter_presence: inviterPresenceReport,
1946
+ room_fact: roomFact,
1947
+ warnings: response.warnings ?? [],
1948
+ next_actions: nextActions,
1949
+ };
1950
+ }
1951
+ /** List a vault's invites (ids, kind, state, role, room, expiry, claimed_by — never the hash or envelope). */
1952
+ async listInvites(options) {
1953
+ const repoId = await this.#resolveRepoId(options);
1954
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/invites`, { context: "listing invites" });
1955
+ }
1956
+ /** Revoke an invite (idempotent — a second revoke of an already-revoked/claimed/expired row still answers `200`). */
1957
+ async revokeInvite(inviteId, options) {
1958
+ const repoId = await this.#resolveRepoId(options);
1959
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE", context: "revoking an invite" });
1960
+ }
1679
1961
  /**
1680
1962
  * Create this machine's wallet (the allowance file) when the credentials
1681
1963
  * provider supports one and none exists yet. A keypair on disk — no
@@ -1728,7 +2010,7 @@ export class Gitvault {
1728
2010
  async resume(options) {
1729
2011
  const [ho, { GitvaultKeystore }, restore] = await Promise.all([this.#handoff(), this.#keystore(), this.#restore()]);
1730
2012
  const { parseHandoffKey, deriveHandoffSecrets, deriveWriterAdmissionSeed, buildWriterAcceptance, openHandoffEnvelopeV2 } = ho;
1731
- const { cloneGitvaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage } = restore;
2013
+ const { cloneGitvaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage, excludeMessagingCacheFromGit } = restore;
1732
2014
  // parse
1733
2015
  const parsed = parseHandoffKey(options.key);
1734
2016
  // ensure wallet — A fresh machine has no wallet, and the claim route
@@ -1867,6 +2149,15 @@ export class Gitvault {
1867
2149
  const { pinGitvaultRepo } = await this.#address();
1868
2150
  const addressParts = vault.address ? vault.address.split("/") : null;
1869
2151
  await pinGitvaultRepo(targetDir, vault.vault_id, addressParts && addressParts.length === 2 ? { org_slug: addressParts[0], repo_name: addressParts[1] } : undefined, { project_id: vault.project_id, org_id: vault.organization_id });
2152
+ // kygit-invite design D5: restore is kind-agnostic, so the same
2153
+ // `.git/info/exclude` write `join` performs happens on `resume` too —
2154
+ // best-effort, never a `resume()` throw.
2155
+ try {
2156
+ await excludeMessagingCacheFromGit(targetDir);
2157
+ }
2158
+ catch {
2159
+ // Best-effort: an unwritable checkout still completes the resume.
2160
+ }
1870
2161
  // verify chain — `open()` here mainly constructs the `GitvaultVault`
1871
2162
  // instance: its own `ensureRepoState()` (the cold-open restore path)
1872
2163
  // no-ops the instant it sees a repo file already on disk — and the
@@ -1948,6 +2239,325 @@ export class Gitvault {
1948
2239
  next_actions: nextActions,
1949
2240
  };
1950
2241
  }
2242
+ /**
2243
+ * Join an Invite Key: parse (refusing a `kgh1_` handoff key by name,
2244
+ * pointing at `resume` — design D9) → ensure this machine has a wallet
2245
+ * (design D5, mirroring {@link Gitvault.resume}'s own bare-wallet
2246
+ * backstop; the CALLER folds the fuller cold-start chain — allowance,
2247
+ * faucet, one x402 prototype payment — before invoking this, never
2248
+ * blocking the claim itself) → claim → open the sealed envelope → write
2249
+ * the repo file to the keystore BEFORE touching disk → clone at the base
2250
+ * HEAD → `git stash apply --index` → local git-config pins (including
2251
+ * `r402.room` set to the invite's OWN room, never just the project id) →
2252
+ * append `.run402/` to `.git/info/exclude` → the session-start reconcile
2253
+ * → register THIS session's presence in the room and post ONE arrival
2254
+ * fact naming it and the checkpoint (idempotent on the invite id;
2255
+ * best-effort — a presence or fact failure never blocks arrival) → read
2256
+ * the most recent messages for the arrival view.
2257
+ */
2258
+ async join(options) {
2259
+ const [ho, { GitvaultKeystore }, { createGitvaultHttpTransport }] = await Promise.all([
2260
+ nodeOnly(() => import("../node/gitvault-handoff.js"), "join"),
2261
+ this.#keystore(),
2262
+ this.#publication(),
2263
+ ]);
2264
+ const restore = await nodeOnly(() => import("../node/gitvault-restore.js"), "join");
2265
+ const { parseInviteKey, deriveInviteSecrets, deriveInviteWriterAdmissionSeed, buildWriterAcceptance, openInviteEnvelope } = ho;
2266
+ const { cloneGitvaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage, excludeMessagingCacheFromGit } = restore;
2267
+ const parsed = parseInviteKey(options.key);
2268
+ const secrets = deriveInviteSecrets(parsed.invite_id_bytes, parsed.master_secret);
2269
+ // Bare-wallet backstop, exactly like `resume` (design D5) — the claim
2270
+ // route accepts ONLY a SIWX wallet signature; the fuller cold-start
2271
+ // chain (faucet + one x402 prototype payment) is the CALLER's to fold
2272
+ // before this call, and never blocks the claim either way.
2273
+ await this.#ensureLocalWallet(options.onLine);
2274
+ // ensure identity BEFORE the claim (design D5): the `writer_acceptance`
2275
+ // below needs THIS checkout's own signing key, and the claim REQUEST
2276
+ // body carries it. Nothing about the joiner's key is copied from the
2277
+ // inviter — the joiner generates it and pushes under it.
2278
+ const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
2279
+ const identity = keystore.ensureIdentity();
2280
+ const signingKeypair = keystore.signingKeypair(identity);
2281
+ if (!signingKeypair) {
2282
+ throw new LocalError("this keystore has no local signing seed — joining an invite requires signing writer_acceptance with a full Ed25519 seed, which a read-only recovery identity does not hold", "joining an invite", { code: "VAULT_UNRECOVERABLE" });
2283
+ }
2284
+ const claimantEncryptionPubkeyRaw = fromBase64url(identity.encryption_pubkey, "identity.encryption_pubkey");
2285
+ // Both signatures are constructible BEFORE the stored grant is ever
2286
+ // seen — the invite id and `auth_hash` are already independently known
2287
+ // (design D4). The protocol field is spelled `handoff_id` and the
2288
+ // domain `handoff-writer-accept/v1` by design D11: the r402s/v0 bytes
2289
+ // are frozen, so an invite admission rides them with the INVITE id.
2290
+ const admissionSeed = deriveInviteWriterAdmissionSeed(parsed.invite_id_bytes, parsed.master_secret);
2291
+ const acceptance = buildWriterAcceptance({
2292
+ handoff_id: parsed.invite_id,
2293
+ auth_hash: secrets.auth_hash_hex,
2294
+ admission_seed: admissionSeed,
2295
+ claimant_signing_seed: signingKeypair.seed,
2296
+ claimant_encryption_pubkey_raw: claimantEncryptionPubkeyRaw,
2297
+ });
2298
+ const claim = await this.#client.request(`/gitvault/v1/invites/${encodeURIComponent(parsed.invite_id)}/claim`, {
2299
+ method: "POST",
2300
+ // Base64url, per the documented wire contract — the gateway decodes
2301
+ // base64/base64url and substitutes 32 zero bytes for anything else,
2302
+ // so a hex-encoded secret never matches any stored hash and every
2303
+ // claim answers INVITE_KEY_INVALID. Each side's own tests can pass
2304
+ // independently; only a cross-side vector catches this class.
2305
+ body: { auth_secret: toBase64url(secrets.auth_secret), writer_acceptance: toBase64url(jcs(acceptance)) },
2306
+ context: "claiming an invite key",
2307
+ });
2308
+ // The claim names the vault by id only (no slug-form address rides the
2309
+ // wire), so the default target directory falls back to the vault id —
2310
+ // `--to <dir>` names it explicitly.
2311
+ const vault = handoffVaultFromWire(claim);
2312
+ // verify grant — a light structural/binding check, NOT the full
2313
+ // cryptographic verification (which needs the vault's writer set to
2314
+ // resolve the grantor's pubkey, and therefore waits until the chain is
2315
+ // walked below — protocol §4.17's own admission ordering).
2316
+ let grantBytes;
2317
+ try {
2318
+ grantBytes = fromBase64url(claim.writer_admission_grant, "writer_admission_grant");
2319
+ }
2320
+ catch {
2321
+ throw new LocalError("the claim response's writer_admission_grant is not valid base64url", "joining an invite", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
2322
+ }
2323
+ let grant;
2324
+ try {
2325
+ grant = JSON.parse(new TextDecoder().decode(grantBytes));
2326
+ }
2327
+ catch {
2328
+ throw new LocalError("the claim response's writer_admission_grant does not decode to valid JSON", "joining an invite", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
2329
+ }
2330
+ // `handoff_id` is the frozen protocol spelling of the claim id (D11) —
2331
+ // for an invite it carries the INVITE id.
2332
+ if (grant.handoff_id !== parsed.invite_id || grant.auth_hash !== secrets.auth_hash_hex || grant.repo_id !== vault.vault_id) {
2333
+ throw new LocalError("the claim response's writer_admission_grant does not bind this invite — refusing", "joining an invite", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
2334
+ }
2335
+ const grantSha256 = sha256Hex(grantBytes);
2336
+ const payload = openInviteEnvelope(parsed.invite_id_bytes, secrets.wrap_key, claim.sealed_envelope, claim.envelope_kind);
2337
+ if (payload.repo_id !== vault.vault_id) {
2338
+ throw new LocalError("the opened envelope's repo_id does not match the claim response's vault — refusing", "joining an invite", { code: "INVITE_ENVELOPE_INVALID" });
2339
+ }
2340
+ // design D5's own integrity binding: the SEALED envelope
2341
+ // (wrap_key-authenticated, and the gateway never holds wrap_key) names
2342
+ // the grant it was minted alongside. A gateway-substituted grant fails
2343
+ // this comparison LOCALLY, before anything is written to disk or the
2344
+ // keystore.
2345
+ if (payload.writer_admission_grant_sha256 !== grantSha256) {
2346
+ throw new LocalError(`the claim response's writer_admission_grant (sha256 ${grantSha256}) does not match the hash sealed into the envelope at mint time (${payload.writer_admission_grant_sha256}) — refusing to activate under a substituted grant`, "joining an invite", { code: "INVITE_CLAIM_WRITER_KEY_MISMATCH", details: { expected: payload.writer_admission_grant_sha256, received: grantSha256 } });
2347
+ }
2348
+ // Genesis must be pinned before ANY materialize call can succeed — the
2349
+ // one read that happens BEFORE a keystore repo file exists, via the
2350
+ // transport directly (mirrors `resume`'s own signature check).
2351
+ const transport = createGitvaultHttpTransport(this.#client);
2352
+ const genesisBytes = await transport.getGenesis({ repo_id: vault.vault_id });
2353
+ if (!genesisBytes) {
2354
+ throw new LocalError("the vault has no admitted genesis", "joining an invite", { code: "CHAIN_BROKEN", details: { repo_id: vault.vault_id } });
2355
+ }
2356
+ const genesis = parseGitvaultStrict(new TextDecoder().decode(genesisBytes));
2357
+ if (!verifyGitvaultObject(genesis, genesis.creator_signing_pubkey)) {
2358
+ throw new LocalError("vault_genesis signature does not verify", "joining an invite", { code: "GITVAULT_SIGNATURE_INVALID", details: { repo_id: vault.vault_id } });
2359
+ }
2360
+ const genesisSha = sha256Hex(genesisBytes);
2361
+ // Write the repo file to the keystore BEFORE touching disk (design D5),
2362
+ // carrying EVERY epoch key the envelope holds so a joiner arriving after
2363
+ // a rotation can still open the pre-rotation generations, and the
2364
+ // already-verified grant as `pending_writer_admission` so a crash from
2365
+ // here on never needs to re-claim (the acceptance is trivially
2366
+ // re-derivable from data already in hand; only the grant is not).
2367
+ const myWriterKeyId = identity.signing_fingerprint;
2368
+ keystore.saveRepo({
2369
+ repo_id: vault.vault_id,
2370
+ org_id: vault.organization_id,
2371
+ project_id: vault.project_id ?? "",
2372
+ k_repo_hex: payload.k_e_hex,
2373
+ epoch: payload.epoch,
2374
+ epoch_keys: { ...(payload.epoch_keys ?? {}), [payload.epoch]: payload.k_e_hex },
2375
+ genesis_sha256: genesisSha,
2376
+ head_pin: null,
2377
+ last_ref_transaction: null,
2378
+ provenance: "restored_from_invite",
2379
+ pending_writer_admission: { handoff_id: parsed.invite_id, writer_admission_grant: grant, claimed_writer_key_id: myWriterKeyId },
2380
+ });
2381
+ const targetDir = await resolveResumeTargetDir(options.to, vault.address, vault.vault_id);
2382
+ options.onLine?.(`joining into ${targetDir}`);
2383
+ const remoteUrl = gitvaultRemoteUrl(vault.organization_id, vault.project_id);
2384
+ await cloneGitvaultRemote(remoteUrl, targetDir);
2385
+ // The ROW's room key — a named room, or the project id when the mint
2386
+ // omitted one — is what makes `messages wait` in the joined checkout
2387
+ // address the right room with zero flags (design D5).
2388
+ const roomKey = claim.room?.room_key ?? vault.project_id ?? "";
2389
+ // Local-only pins (design D5) — never a worktree file, never the global
2390
+ // active project. Reuses the SAME pin-writer every other gitvault
2391
+ // resolution path uses, this time with the invite's OWN room key.
2392
+ const { pinGitvaultRepo } = await this.#address();
2393
+ const addressParts = vault.address ? vault.address.split("/") : null;
2394
+ await pinGitvaultRepo(targetDir, vault.vault_id, addressParts && addressParts.length === 2 ? { org_slug: addressParts[0], repo_name: addressParts[1] } : undefined, { project_id: vault.project_id, org_id: vault.organization_id, room_key: roomKey });
2395
+ // design D5/D9 risk list: `.git/info/exclude` rather than `.gitignore`
2396
+ // — the ignore file is part of the captured tree and touching it would
2397
+ // break exact-state on the first `git status`. Best-effort.
2398
+ try {
2399
+ await excludeMessagingCacheFromGit(targetDir);
2400
+ }
2401
+ catch {
2402
+ // Best-effort: an unwritable checkout still completes the join.
2403
+ }
2404
+ // verify chain — `open()` here mainly constructs the `GitvaultVault`
2405
+ // instance (its cold-open restore path no-ops the instant it sees the
2406
+ // repo file `saveRepo` just wrote). The chain walk this method needs
2407
+ // happens the FIRST time `submitWriterActivationHead` calls
2408
+ // `materialize()` below, which unconditionally runs `verifyToNewest()`
2409
+ // and freshly pins `writer_set_pin`. `reconcile: "forbidden"` defers
2410
+ // the envelope reconcile to its own explicit step AFTER activation
2411
+ // (design D5's ordering), never implicitly and possibly twice.
2412
+ const handle = await this.open({ repo_id: vault.vault_id, repo_dir: targetDir, keystore_root: options.keystore_root, reconcile: "forbidden" });
2413
+ // design D5: activate as a writer BEFORE `join()` returns, through the
2414
+ // SAME `add_writer_key` door `resume` drives — so the joiner's first
2415
+ // `git push` is an ordinary push under its OWN key, not a
2416
+ // `GITVAULT_WRITER_NOT_ADMITTED` refusal. `added_writer.principal_id`
2417
+ // names the claimant's own control-plane principal, which the claim
2418
+ // response never carries (its `membership` block names the ORG), so
2419
+ // resolve it fresh here.
2420
+ //
2421
+ // D9's not-stranded rule: an activation that does not land (a
2422
+ // concurrent rotation, a lost network) leaves this key a PENDING writer
2423
+ // of the vault, which any live writer's next push or `repos access
2424
+ // sync` admits. Report it and carry `request_writer_sync` rather than
2425
+ // throwing away a claim already spent and a tree about to be restored.
2426
+ let writerActivation;
2427
+ try {
2428
+ const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's id for the writer activation head" });
2429
+ const activation = await handle.vault.submitWriterActivationHead({
2430
+ addedWriterKeyId: myWriterKeyId,
2431
+ addedSigningPubkeyB64u: identity.signing_pubkey,
2432
+ addedPrincipalId: who.principal.id,
2433
+ handoffId: parsed.invite_id,
2434
+ grant,
2435
+ acceptance: acceptance,
2436
+ });
2437
+ writerActivation = {
2438
+ outcome: "active",
2439
+ writer_key_id: myWriterKeyId,
2440
+ generation: activation.outcome === "activated" ? activation.result.generation : activation.generation,
2441
+ };
2442
+ // Clearing pending_writer_admission now that the activation head is
2443
+ // (or already was — the idempotent-skip case) admitted mirrors
2444
+ // writer_status flipping to "active" at the same moment.
2445
+ keystore.updateRepo(vault.vault_id, { pending_writer_admission: null });
2446
+ }
2447
+ catch (e) {
2448
+ writerActivation = { outcome: "pending", writer_key_id: myWriterKeyId, reason: e instanceof Error ? e.message : String(e) };
2449
+ }
2450
+ // The bearer envelope is superseded within minutes of use — run the
2451
+ // same reconcile `push()` runs, best-effort (never a `join()` throw),
2452
+ // NOW that this checkout is an admitted writer and the reconcile's own
2453
+ // wrap step is meaningful.
2454
+ const reconcile = await this.#tryReconcileEnvelopeRecipients(handle.vault);
2455
+ // apply the checkpoint — LAST (design D5): a failure anywhere above
2456
+ // this line leaves the working tree untouched (freshly cloned, nothing
2457
+ // stashed), the cleanest possible state to retry `join()` from.
2458
+ const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
2459
+ // design D5: register THIS session's presence, then post the ONE
2460
+ // arrival fact — both best-effort, neither ever throws `join()`.
2461
+ const rooms = new Rooms(this.#client);
2462
+ const inviteShort = claim.invite_id.slice(0, 8);
2463
+ let myPresence = null;
2464
+ let presenceFailure = null;
2465
+ try {
2466
+ const registration = await rooms.registerPresence(claim.org_id, roomKey, {
2467
+ task: options.task ?? `joined via invite ${inviteShort}`,
2468
+ ...(options.program !== undefined ? { program: options.program } : {}),
2469
+ ...(options.model !== undefined ? { model: options.model } : {}),
2470
+ ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
2471
+ });
2472
+ myPresence = { presence_id: registration.presence_id, name: registration.name };
2473
+ }
2474
+ catch (e) {
2475
+ // best-effort — arrival still completes without a presence, and says why
2476
+ presenceFailure = e instanceof Error ? e.message : String(e);
2477
+ }
2478
+ if (myPresence) {
2479
+ const receiptShort = payload.checkpoint.commit_oid.slice(0, 12);
2480
+ try {
2481
+ await rooms.sendMessage(claim.org_id, roomKey, {
2482
+ body: `Joined as ${myPresence.name} from checkpoint ${receiptShort}.`,
2483
+ presenceId: myPresence.presence_id,
2484
+ ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
2485
+ idempotencyKey: `invite:${claim.invite_id}:joined`,
2486
+ });
2487
+ }
2488
+ catch {
2489
+ // best-effort — never blocks arrival
2490
+ }
2491
+ }
2492
+ let livePresences = claim.live_presences ?? [];
2493
+ let recentMessages = [];
2494
+ let cursor = claim.cursor ?? null;
2495
+ try {
2496
+ const page = await rooms.listMessages(claim.org_id, roomKey, {
2497
+ order: "desc",
2498
+ limit: options.recentMessagesLimit ?? 10,
2499
+ ...(myPresence ? { presenceId: myPresence.presence_id } : {}),
2500
+ });
2501
+ recentMessages = page.messages ?? [];
2502
+ if (typeof page.cursor === "string")
2503
+ cursor = page.cursor;
2504
+ }
2505
+ catch {
2506
+ // best-effort — arrival still reports the claim's own cursor
2507
+ }
2508
+ const nextActions = [...(claim.next_actions ?? [])];
2509
+ if (!nextActions.some((a) => a.type === "push_repo")) {
2510
+ nextActions.push({ type: "push_repo", command: "git push origin main", why: "Publish continued work back to the vault." });
2511
+ }
2512
+ if (!nextActions.some((a) => a.type === "wait_room")) {
2513
+ nextActions.push({ type: "wait_room", command: "run402 messages wait", why: "Block until the inviter (or anyone else) speaks; silence returns who is still here." });
2514
+ }
2515
+ if (claim.inviter && !nextActions.some((a) => a.type === "send_room_message")) {
2516
+ nextActions.push({ type: "send_room_message", command: `run402 messages send "…" --to ${claim.inviter.name}`, why: `${claim.inviter.name} invited you and may still be live.` });
2517
+ }
2518
+ // design D9: a joiner whose activation did not land is not stranded —
2519
+ // its key is a PENDING writer, and any live writer's reconcile admits
2520
+ // it. The vocabulary is the shipped member-key one, never an
2521
+ // invite-specific error.
2522
+ if (writerActivation.outcome === "pending" && !nextActions.some((a) => a.type === "request_writer_sync")) {
2523
+ nextActions.push({
2524
+ type: "request_writer_sync",
2525
+ command: "run402 repos access sync",
2526
+ why: `This key is a pending writer of the vault (${writerActivation.reason}); ask any live writer to run this — or push once — and the first push from here will land.`,
2527
+ });
2528
+ }
2529
+ let note = null;
2530
+ let noteRaw = null;
2531
+ try {
2532
+ noteRaw = (await readGitCommitMessage(targetDir, payload.checkpoint.commit_oid)) ?? null;
2533
+ if (noteRaw)
2534
+ note = JSON.parse(noteRaw);
2535
+ }
2536
+ catch {
2537
+ note = null;
2538
+ }
2539
+ return {
2540
+ invite_id: claim.invite_id,
2541
+ kind: claim.kind,
2542
+ deduplicated: claim.deduplicated,
2543
+ note,
2544
+ note_raw: noteRaw,
2545
+ restored: { dir: targetDir, branch: restored.branch, base_head_oid: restored.base_head_oid, stash_oid: restored.stash_oid },
2546
+ membership: handoffMembershipFromWire(claim.membership),
2547
+ members: claim.members ?? [],
2548
+ room: { organization_id: claim.room?.org_id ?? claim.org_id, room_key: roomKey },
2549
+ inviter: claim.inviter ?? null,
2550
+ presence: myPresence,
2551
+ presence_failure: presenceFailure,
2552
+ live_presences: livePresences,
2553
+ cursor,
2554
+ recent_messages: recentMessages,
2555
+ expires_at: claim.expires_at,
2556
+ writer_activation: writerActivation,
2557
+ reconcile_recipients: reconcile,
2558
+ next_actions: nextActions,
2559
+ };
2560
+ }
1951
2561
  /** Best-effort dual-push: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `push()` throw. */
1952
2562
  /**
1953
2563
  * `EPOCH_ROTATION_REQUIRED` (D193) is left THROWN — never swallowed into a