run402 4.69.7 → 4.70.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/README.md +3 -1
- package/gitvault-surface.json +5 -1
- package/lib/command-manifest.mjs +2 -0
- package/lib/gitvault-capabilities.mjs +6 -0
- package/lib/harness-context.mjs +50 -0
- package/lib/messages.mjs +83 -1
- package/lib/repos.mjs +319 -10
- package/lib/rooms-context.mjs +23 -13
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +1 -1
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +223 -0
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +609 -2
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/rooms.d.ts +20 -1
- package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
- package/sdk/dist/namespaces/rooms.js +93 -0
- package/sdk/dist/namespaces/rooms.js.map +1 -1
- package/sdk/dist/namespaces/rooms.types.d.ts +73 -0
- package/sdk/dist/namespaces/rooms.types.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.d.ts +1 -0
- package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.js +5 -1
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-handoff.d.ts +109 -31
- package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-handoff.js +264 -137
- package/sdk/dist/node/gitvault-handoff.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +2 -2
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-restore.d.ts +19 -0
- package/sdk/dist/node/gitvault-restore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-restore.js +59 -2
- package/sdk/dist/node/gitvault-restore.js.map +1 -1
- package/sdk/dist/node/index.d.ts +4 -4
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +9 -5
- 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 };
|
|
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,322 @@ 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
|
+
try {
|
|
2465
|
+
const registration = await rooms.registerPresence(claim.org_id, roomKey, {
|
|
2466
|
+
task: options.task ?? `joined via invite ${inviteShort}`,
|
|
2467
|
+
...(options.program !== undefined ? { program: options.program } : {}),
|
|
2468
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
2469
|
+
...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
|
|
2470
|
+
});
|
|
2471
|
+
myPresence = { presence_id: registration.presence_id, name: registration.name };
|
|
2472
|
+
}
|
|
2473
|
+
catch {
|
|
2474
|
+
// best-effort — arrival still completes without a presence
|
|
2475
|
+
}
|
|
2476
|
+
if (myPresence) {
|
|
2477
|
+
const receiptShort = payload.checkpoint.commit_oid.slice(0, 12);
|
|
2478
|
+
try {
|
|
2479
|
+
await rooms.sendMessage(claim.org_id, roomKey, {
|
|
2480
|
+
body: `Joined as ${myPresence.name} from checkpoint ${receiptShort}.`,
|
|
2481
|
+
presenceId: myPresence.presence_id,
|
|
2482
|
+
...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
|
|
2483
|
+
idempotencyKey: `invite:${claim.invite_id}:joined`,
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
catch {
|
|
2487
|
+
// best-effort — never blocks arrival
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
let livePresences = claim.live_presences ?? [];
|
|
2491
|
+
let recentMessages = [];
|
|
2492
|
+
let cursor = claim.cursor ?? null;
|
|
2493
|
+
try {
|
|
2494
|
+
const page = await rooms.listMessages(claim.org_id, roomKey, {
|
|
2495
|
+
order: "desc",
|
|
2496
|
+
limit: options.recentMessagesLimit ?? 10,
|
|
2497
|
+
...(myPresence ? { presenceId: myPresence.presence_id } : {}),
|
|
2498
|
+
});
|
|
2499
|
+
recentMessages = page.messages ?? [];
|
|
2500
|
+
if (typeof page.cursor === "string")
|
|
2501
|
+
cursor = page.cursor;
|
|
2502
|
+
}
|
|
2503
|
+
catch {
|
|
2504
|
+
// best-effort — arrival still reports the claim's own cursor
|
|
2505
|
+
}
|
|
2506
|
+
const nextActions = [...(claim.next_actions ?? [])];
|
|
2507
|
+
if (!nextActions.some((a) => a.type === "push_repo")) {
|
|
2508
|
+
nextActions.push({ type: "push_repo", command: "git push origin main", why: "Publish continued work back to the vault." });
|
|
2509
|
+
}
|
|
2510
|
+
if (!nextActions.some((a) => a.type === "wait_room")) {
|
|
2511
|
+
nextActions.push({ type: "wait_room", command: "run402 messages wait", why: "Block until the inviter (or anyone else) speaks; silence returns who is still here." });
|
|
2512
|
+
}
|
|
2513
|
+
if (claim.inviter && !nextActions.some((a) => a.type === "send_room_message")) {
|
|
2514
|
+
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.` });
|
|
2515
|
+
}
|
|
2516
|
+
// design D9: a joiner whose activation did not land is not stranded —
|
|
2517
|
+
// its key is a PENDING writer, and any live writer's reconcile admits
|
|
2518
|
+
// it. The vocabulary is the shipped member-key one, never an
|
|
2519
|
+
// invite-specific error.
|
|
2520
|
+
if (writerActivation.outcome === "pending" && !nextActions.some((a) => a.type === "request_writer_sync")) {
|
|
2521
|
+
nextActions.push({
|
|
2522
|
+
type: "request_writer_sync",
|
|
2523
|
+
command: "run402 repos access sync",
|
|
2524
|
+
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.`,
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
let note = null;
|
|
2528
|
+
let noteRaw = null;
|
|
2529
|
+
try {
|
|
2530
|
+
noteRaw = (await readGitCommitMessage(targetDir, payload.checkpoint.commit_oid)) ?? null;
|
|
2531
|
+
if (noteRaw)
|
|
2532
|
+
note = JSON.parse(noteRaw);
|
|
2533
|
+
}
|
|
2534
|
+
catch {
|
|
2535
|
+
note = null;
|
|
2536
|
+
}
|
|
2537
|
+
return {
|
|
2538
|
+
invite_id: claim.invite_id,
|
|
2539
|
+
kind: claim.kind,
|
|
2540
|
+
deduplicated: claim.deduplicated,
|
|
2541
|
+
note,
|
|
2542
|
+
note_raw: noteRaw,
|
|
2543
|
+
restored: { dir: targetDir, branch: restored.branch, base_head_oid: restored.base_head_oid, stash_oid: restored.stash_oid },
|
|
2544
|
+
membership: handoffMembershipFromWire(claim.membership),
|
|
2545
|
+
members: claim.members ?? [],
|
|
2546
|
+
room: { organization_id: claim.room?.org_id ?? claim.org_id, room_key: roomKey },
|
|
2547
|
+
inviter: claim.inviter ?? null,
|
|
2548
|
+
presence: myPresence,
|
|
2549
|
+
live_presences: livePresences,
|
|
2550
|
+
cursor,
|
|
2551
|
+
recent_messages: recentMessages,
|
|
2552
|
+
expires_at: claim.expires_at,
|
|
2553
|
+
writer_activation: writerActivation,
|
|
2554
|
+
reconcile_recipients: reconcile,
|
|
2555
|
+
next_actions: nextActions,
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
1951
2558
|
/** Best-effort dual-push: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `push()` throw. */
|
|
1952
2559
|
/**
|
|
1953
2560
|
* `EPOCH_ROTATION_REQUIRED` (D193) is left THROWN — never swallowed into a
|