@openparachute/vault 0.6.1 → 0.6.2-rc.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.
@@ -75,6 +75,10 @@ import {
75
75
  type ImportResult,
76
76
  } from "./mirror-import.ts";
77
77
  import { redactToken } from "./export-watch.ts";
78
+ import {
79
+ findConflictingVault,
80
+ remoteConflictMessage,
81
+ } from "./mirror-remote-guard.ts";
78
82
  import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
79
83
  import { getVaultStore } from "./vault-store.ts";
80
84
  import { assetsDir } from "./routes.ts";
@@ -595,7 +599,7 @@ export async function handleAuthPat(
595
599
  timeoutMs: number,
596
600
  ) => Promise<{ ok: boolean; error?: string }>,
597
601
  ): Promise<Response> {
598
- let body: { token?: unknown; remote_url?: unknown; label?: unknown };
602
+ let body: { token?: unknown; remote_url?: unknown; label?: unknown; override?: unknown };
599
603
  try {
600
604
  body = (await req.json()) as Record<string, unknown>;
601
605
  } catch (err) {
@@ -604,6 +608,7 @@ export async function handleAuthPat(
604
608
  { status: 400 },
605
609
  );
606
610
  }
611
+ const override = body.override === true;
607
612
  const token = typeof body.token === "string" ? body.token.trim() : "";
608
613
  const remote_url = typeof body.remote_url === "string" ? body.remote_url.trim() : "";
609
614
  const label =
@@ -699,11 +704,33 @@ export async function handleAuthPat(
699
704
  );
700
705
  }
701
706
 
707
+ // vault#482: refuse if ANOTHER vault on this server already pushes to this
708
+ // repo. Two vaults sharing one remote force-push over each other's backups
709
+ // (silent data loss). Compare against the operator-supplied (un-authed)
710
+ // remote_url — the normalizer strips userinfo so the token doesn't matter.
711
+ // Re-pointing THIS vault at its own remote is fine (the scan excludes it).
712
+ // `override=true` is the explicit escape hatch.
713
+ const vaultName = manager.getVaultName();
714
+ if (!override) {
715
+ const conflict = findConflictingVault(vaultName, remote_url);
716
+ if (conflict) {
717
+ return Response.json(
718
+ {
719
+ error: "Remote already in use by another vault",
720
+ error_type: "remote_conflict",
721
+ conflicting_vault: conflict.conflictingVault,
722
+ remote: conflict.remoteIdentity,
723
+ message: remoteConflictMessage(conflict),
724
+ },
725
+ { status: 409 },
726
+ );
727
+ }
728
+ }
729
+
702
730
  // Save the userinfo'd URL — that's what gets embedded as `origin` so
703
731
  // bare `git push` works without needing GIT_ASKPASS etc. Per-vault
704
732
  // (vault#399): the PAT + remote_url land in this vault's own file, not a
705
733
  // server-wide one — so configuring vault B never reuses vault A's remote.
706
- const vaultName = manager.getVaultName();
707
734
  const next: MirrorCredentials = {
708
735
  ...(readCredentials(vaultName) ?? emptyCredentials()),
709
736
  active_method: "pat",
@@ -1126,7 +1153,7 @@ export async function handleAuthGithubSelectRepo(
1126
1153
  { status: 400 },
1127
1154
  );
1128
1155
  }
1129
- let body: { owner?: unknown; name?: unknown };
1156
+ let body: { owner?: unknown; name?: unknown; override?: unknown };
1130
1157
  try {
1131
1158
  body = (await req.json()) as Record<string, unknown>;
1132
1159
  } catch (err) {
@@ -1135,6 +1162,7 @@ export async function handleAuthGithubSelectRepo(
1135
1162
  { status: 400 },
1136
1163
  );
1137
1164
  }
1165
+ const override = body.override === true;
1138
1166
  const owner = typeof body.owner === "string" ? body.owner.trim() : "";
1139
1167
  const name = typeof body.name === "string" ? body.name.trim() : "";
1140
1168
  if (!owner || !name) {
@@ -1146,6 +1174,32 @@ export async function handleAuthGithubSelectRepo(
1146
1174
  { status: 400 },
1147
1175
  );
1148
1176
  }
1177
+
1178
+ // vault#482: refuse if ANOTHER vault on this server already pushes to this
1179
+ // repo. The clobber path for the OAuth flow: select-repo writes
1180
+ // `github.com/<owner>/<name>` onto this vault's mirror `origin`; if a sibling
1181
+ // vault already has that same origin, both force-push the same branch and the
1182
+ // loser's backup is overwritten. Compare against the public github URL (no
1183
+ // token — the normalizer keys off host/owner/repo). The scan excludes THIS
1184
+ // vault, so re-picking the same repo (token rotation, re-entry) is fine.
1185
+ // `override=true` is the explicit escape hatch.
1186
+ if (!override) {
1187
+ const candidate = `https://github.com/${owner}/${name}.git`;
1188
+ const conflict = findConflictingVault(manager.getVaultName(), candidate);
1189
+ if (conflict) {
1190
+ return Response.json(
1191
+ {
1192
+ error: "Remote already in use by another vault",
1193
+ error_type: "remote_conflict",
1194
+ conflicting_vault: conflict.conflictingVault,
1195
+ remote: conflict.remoteIdentity,
1196
+ message: remoteConflictMessage(conflict),
1197
+ },
1198
+ { status: 409 },
1199
+ );
1200
+ }
1201
+ }
1202
+
1149
1203
  // Reach into mirror-credentials.ts for the authed URL builder.
1150
1204
  const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
1151
1205
  const authedUrl = githubAuthedRemoteUrl(
@@ -1463,6 +1517,7 @@ export async function handleMirrorImport(
1463
1517
  mode?: unknown;
1464
1518
  credentials?: unknown;
1465
1519
  enable_sync?: unknown;
1520
+ override?: unknown;
1466
1521
  };
1467
1522
  try {
1468
1523
  body = (await req.json()) as Record<string, unknown>;
@@ -1574,6 +1629,13 @@ export async function handleMirrorImport(
1574
1629
  enableSync = body.enable_sync;
1575
1630
  }
1576
1631
 
1632
+ // vault#482: cross-vault clobber override for the sync-enable step. Default
1633
+ // off; a literal `true` lets the operator deliberately point this vault's
1634
+ // sync at a repo a SIBLING vault already backs up to (e.g. they moved the
1635
+ // repo between vaults). The import itself never refuses on this — only the
1636
+ // optional sync-enable does.
1637
+ const override = body.override === true;
1638
+
1577
1639
  // Resolve the target vault's store + assets dir. The route is gated on
1578
1640
  // `vault:<name>:admin`, so we trust vaultName is real by the time we
1579
1641
  // reach this code path; defensively re-resolve in case the vault was
@@ -1661,6 +1723,7 @@ export async function handleMirrorImport(
1661
1723
  remoteUrl: remote_url,
1662
1724
  auth,
1663
1725
  manager,
1726
+ override,
1664
1727
  });
1665
1728
  result.sync_enabled = outcome.sync_enabled;
1666
1729
  if (outcome.warning) result.sync_warning = outcome.warning;
@@ -1731,8 +1794,14 @@ export async function enableSyncToImportedRepo(opts: {
1731
1794
  * rather than persisting a half-configured mirror with no live manager.
1732
1795
  */
1733
1796
  manager: MirrorManager | undefined;
1797
+ /**
1798
+ * vault#482 escape hatch — skip the cross-vault clobber guard. Default
1799
+ * false: refuse (warn, don't enable sync) when a SIBLING vault already
1800
+ * backs up to this repo.
1801
+ */
1802
+ override?: boolean;
1734
1803
  }): Promise<{ sync_enabled: boolean; warning?: string }> {
1735
- const { vaultName, remoteUrl, auth, manager } = opts;
1804
+ const { vaultName, remoteUrl, auth, manager, override = false } = opts;
1736
1805
 
1737
1806
  if (!manager) {
1738
1807
  return {
@@ -1742,6 +1811,21 @@ export async function enableSyncToImportedRepo(opts: {
1742
1811
  };
1743
1812
  }
1744
1813
 
1814
+ // vault#482: don't enable sync to a repo a DIFFERENT vault on this server
1815
+ // already backs up to — both would force-push the same branch and clobber
1816
+ // each other. Import success is never lost; we just decline the optional
1817
+ // sync-enable + warn. The existing same-vault "different remote" check below
1818
+ // handles THIS vault's own prior target; this one covers SIBLING vaults.
1819
+ if (!override) {
1820
+ const conflict = findConflictingVault(vaultName, remoteUrl);
1821
+ if (conflict) {
1822
+ return {
1823
+ sync_enabled: false,
1824
+ warning: `Import succeeded, but Sync was not enabled — ${remoteConflictMessage(conflict)}`,
1825
+ };
1826
+ }
1827
+ }
1828
+
1745
1829
  // --- Resolve the push credential we'll persist for this remote. ----------
1746
1830
  // We need a credential that can PUSH to `remoteUrl`. The clone-time auth
1747
1831
  // shapes map onto push credentials as follows:
@@ -1983,14 +2067,16 @@ function effectiveRemoteOf(creds: MirrorCredentials): string | null {
1983
2067
  * (SSH shorthand, local paths).
1984
2068
  */
1985
2069
  function sameRemote(a: string, b: string): boolean {
2070
+ // Lower-cased whole identity: GitHub owner/repo is case-insensitive, so a
2071
+ // clobber guard must treat Aaron/Repo == aaron/repo (matches normalizeRemoteIdentity).
1986
2072
  const norm = (u: string): string => {
1987
2073
  try {
1988
2074
  const url = new URL(u);
1989
2075
  const host = url.host.toLowerCase();
1990
2076
  const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, "");
1991
- return `${url.protocol}//${host}${path}`;
2077
+ return `${url.protocol}//${host}${path}`.toLowerCase();
1992
2078
  } catch {
1993
- return u.trim().replace(/\.git$/, "").replace(/\/+$/, "");
2079
+ return u.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
1994
2080
  }
1995
2081
  };
1996
2082
  return norm(a) === norm(b);
package/src/vault.test.ts CHANGED
@@ -4543,6 +4543,62 @@ describe("HTTP PATCH /notes/:idOrPath if_missing=create (vault#309)", async () =
4543
4543
  expect(body.metadata.v).toBe(2);
4544
4544
  });
4545
4545
 
4546
+ // Cross-repo guard for the parachute-agent #agent/thread upsert seam: a single-threaded
4547
+ // thread note lives at a SLASH path (e.g. "Threads/<channel>/<name>") and the agent
4548
+ // module addresses it as ONE URL segment via encodeURIComponent (so `/`→`%2F`). The
4549
+ // full round-trip it relies on — GET an existing note by the encoded-slash path
4550
+ // (readThreadNote read-back) then PATCH-update it by the same encoded-slash path
4551
+ // (if_missing:create upsert, turn 2) — must resolve to the decoded path, or the agent's
4552
+ // turn_count/usage aggregates silently reset every turn. This proves the route resolves
4553
+ // the encoded slash on GET + PATCH-update of an EXISTING note (4505 above only covers
4554
+ // create). See parachute-agent#110.
4555
+ test("encoded-slash path round-trips: GET read-back + PATCH-update resolve a %2F path (the #agent/thread upsert seam)", async () => {
4556
+ const enc = encodeURIComponent("Threads/eng/eng");
4557
+ expect(enc).toBe("Threads%2Feng%2Feng"); // no literal slash — one URL segment.
4558
+
4559
+ // Turn 1 — PATCH if_missing:create by the encoded-slash path → CREATES.
4560
+ const create = await handleNotes(
4561
+ mkReq("PATCH", `/notes/${enc}`, {
4562
+ content: "## Summary\n\nturn 1",
4563
+ tags: ["#agent/thread"],
4564
+ metadata: { turn_count: "1", status: "ok" },
4565
+ if_missing: "create",
4566
+ force: true,
4567
+ }),
4568
+ store,
4569
+ `/${enc}`,
4570
+ );
4571
+ expect(create.status).toBe(200);
4572
+ expect((await create.json() as any).created).toBe(true);
4573
+
4574
+ // Read-back — GET by the SAME encoded-slash path resolves the created note (NOT 404).
4575
+ const get = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
4576
+ expect(get.status).toBe(200);
4577
+ const got = await get.json() as any;
4578
+ expect(got.path).toBe("Threads/eng/eng");
4579
+ expect(got.metadata.turn_count).toBe("1");
4580
+
4581
+ // Turn 2 — PATCH if_missing:create by the SAME encoded-slash path → UPDATES in place.
4582
+ const update = await handleNotes(
4583
+ mkReq("PATCH", `/notes/${enc}`, {
4584
+ content: "## Summary\n\nturn 2",
4585
+ metadata: { turn_count: "2", status: "ok" },
4586
+ if_missing: "create",
4587
+ force: true,
4588
+ }),
4589
+ store,
4590
+ `/${enc}`,
4591
+ );
4592
+ expect(update.status).toBe(200);
4593
+ expect((await update.json() as any).created).toBe(false); // updated, NOT a second note.
4594
+
4595
+ // Exactly ONE note at the decoded path, updated (the upsert worked end-to-end).
4596
+ const final = await store.getNoteByPath("Threads/eng/eng");
4597
+ expect(final).not.toBeNull();
4598
+ expect(String(final!.metadata.turn_count)).toBe("2");
4599
+ expect(final!.content).toContain("turn 2");
4600
+ });
4601
+
4546
4602
  test("missing note without if_missing returns 404 (back-compat)", async () => {
4547
4603
  const res = await handleNotes(
4548
4604
  mkReq("PATCH", "/notes/m309c-nope", {