@openparachute/vault 0.6.1 → 0.6.2
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 +6 -6
- package/package.json +2 -2
- package/src/cli.ts +90 -25
- package/src/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +313 -0
- package/src/mirror-routes.ts +92 -6
- package/src/vault.test.ts +56 -0
package/src/mirror-routes.ts
CHANGED
|
@@ -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", {
|