@openparachute/vault 0.7.5 → 0.7.6-rc.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/package.json +1 -1
- package/src/attachment-tickets.ts +17 -1
- package/src/auto-transcribe.test.ts +105 -0
- package/src/auto-transcribe.ts +103 -4
- package/src/cli.ts +97 -82
- package/src/mcp-tools.ts +28 -7
- package/src/mirror-remote-guard.test.ts +159 -0
- package/src/mirror-remote-guard.ts +124 -0
- package/src/mirror-routes.test.ts +147 -0
- package/src/mirror-routes.ts +125 -5
- package/src/routes.ts +33 -2
- package/src/transcription/capability.test.ts +48 -1
- package/src/transcription/capability.ts +23 -0
- package/src/transcription/providers/whisper-cpp.test.ts +44 -1
- package/src/transcription/providers/whisper-cpp.ts +34 -3
- package/src/transcription/select.test.ts +83 -20
- package/src/transcription/select.ts +48 -16
- package/src/transcription-routes.test.ts +29 -6
- package/src/transcription-status-cli.test.ts +221 -0
- package/src/vault.test.ts +120 -0
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
sameRemoteIdentity,
|
|
19
19
|
claimedRemoteOf,
|
|
20
20
|
findConflictingVault,
|
|
21
|
+
findUnrelatedRemoteHistory,
|
|
22
|
+
unrelatedHistoryMessage,
|
|
21
23
|
} from "./mirror-remote-guard.ts";
|
|
22
24
|
import { writeMirrorConfigForVault, defaultMirrorConfig } from "./mirror-config.ts";
|
|
23
25
|
import { writeCredentials } from "./mirror-credentials.ts";
|
|
@@ -267,3 +269,160 @@ describe("findConflictingVault", () => {
|
|
|
267
269
|
expect(findConflictingVault("b", " ")).toBeNull();
|
|
268
270
|
});
|
|
269
271
|
});
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// vault#823 — unrelated-history guard, against REAL git repos.
|
|
275
|
+
//
|
|
276
|
+
// The failure being guarded is deterministic and reproduces in plain git:
|
|
277
|
+
// give a remote some history, `git init` a fresh mirror beside it, bind, push
|
|
278
|
+
// → `! [rejected] (non-fast-forward)`, forever. These tests build exactly that
|
|
279
|
+
// on disk rather than mocking the object database, because the whole claim is
|
|
280
|
+
// about what git does with two roots.
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
function git(cwd: string, ...args: string[]): void {
|
|
284
|
+
const proc = Bun.spawnSync(["git", ...args], {
|
|
285
|
+
cwd,
|
|
286
|
+
stdout: "ignore",
|
|
287
|
+
stderr: "ignore",
|
|
288
|
+
env: {
|
|
289
|
+
...process.env,
|
|
290
|
+
GIT_AUTHOR_NAME: "t",
|
|
291
|
+
GIT_AUTHOR_EMAIL: "t@example.com",
|
|
292
|
+
GIT_COMMITTER_NAME: "t",
|
|
293
|
+
GIT_COMMITTER_EMAIL: "t@example.com",
|
|
294
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
295
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
if (!proc.success) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function headShasOf(repo: string): string[] {
|
|
302
|
+
const proc = Bun.spawnSync(["git", "ls-remote", repo], { stdout: "pipe", stderr: "ignore" });
|
|
303
|
+
return new TextDecoder()
|
|
304
|
+
.decode(proc.stdout)
|
|
305
|
+
.split("\n")
|
|
306
|
+
.map((l) => l.split("\t")[0])
|
|
307
|
+
.filter((s) => /^[0-9a-f]{40}$/.test(s));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
describe("findUnrelatedRemoteHistory (vault#823)", () => {
|
|
311
|
+
test("fresh mirror + non-empty remote → flagged, and the real push really is rejected", async () => {
|
|
312
|
+
const root = tmp("pv-unrelated-");
|
|
313
|
+
const remote = path.join(root, "remote.git");
|
|
314
|
+
fs.mkdirSync(remote, { recursive: true });
|
|
315
|
+
git(remote, "init", "-q", "--bare");
|
|
316
|
+
|
|
317
|
+
// Seed the remote with history, the way a prior machine's mirror would.
|
|
318
|
+
const seed = path.join(root, "seed");
|
|
319
|
+
fs.mkdirSync(seed);
|
|
320
|
+
git(seed, "init", "-q", "-b", "main");
|
|
321
|
+
fs.writeFileSync(path.join(seed, "n1.md"), "note one");
|
|
322
|
+
git(seed, "add", "-A");
|
|
323
|
+
git(seed, "commit", "-qm", "vault export 1");
|
|
324
|
+
git(seed, "push", "-q", remote, "HEAD:main");
|
|
325
|
+
|
|
326
|
+
// The imported box: notes came across, the mirror is a fresh `git init`.
|
|
327
|
+
const mirror = path.join(root, "mirror");
|
|
328
|
+
fs.mkdirSync(mirror);
|
|
329
|
+
git(mirror, "init", "-q", "-b", "main");
|
|
330
|
+
fs.writeFileSync(path.join(mirror, "n1.md"), "note one");
|
|
331
|
+
git(mirror, "add", "-A");
|
|
332
|
+
git(mirror, "commit", "-qm", "vault mirror seed");
|
|
333
|
+
|
|
334
|
+
const heads = headShasOf(remote);
|
|
335
|
+
expect(heads.length).toBeGreaterThan(0);
|
|
336
|
+
|
|
337
|
+
const found = await findUnrelatedRemoteHistory({
|
|
338
|
+
mirrorPath: mirror,
|
|
339
|
+
remoteUrl: `https://github.com/a/b.git`,
|
|
340
|
+
remoteHeads: heads,
|
|
341
|
+
});
|
|
342
|
+
expect(found).not.toBeNull();
|
|
343
|
+
expect(found?.mirrorIsFresh).toBe(false); // the dir exists, it's just unrelated
|
|
344
|
+
|
|
345
|
+
// And the thing the guard is predicting actually happens.
|
|
346
|
+
const push = Bun.spawnSync(["git", "push", remote, "HEAD:main"], {
|
|
347
|
+
cwd: mirror,
|
|
348
|
+
stdout: "ignore",
|
|
349
|
+
stderr: "pipe",
|
|
350
|
+
});
|
|
351
|
+
expect(push.success).toBe(false);
|
|
352
|
+
expect(new TextDecoder().decode(push.stderr)).toContain("rejected");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("mirror cloned FROM the remote → not flagged (re-bind after token rotation)", async () => {
|
|
356
|
+
const root = tmp("pv-related-");
|
|
357
|
+
const remote = path.join(root, "remote.git");
|
|
358
|
+
fs.mkdirSync(remote, { recursive: true });
|
|
359
|
+
git(remote, "init", "-q", "--bare");
|
|
360
|
+
const seed = path.join(root, "seed");
|
|
361
|
+
fs.mkdirSync(seed);
|
|
362
|
+
git(seed, "init", "-q", "-b", "main");
|
|
363
|
+
fs.writeFileSync(path.join(seed, "n1.md"), "note one");
|
|
364
|
+
git(seed, "add", "-A");
|
|
365
|
+
git(seed, "commit", "-qm", "vault export 1");
|
|
366
|
+
git(seed, "push", "-q", remote, "HEAD:main");
|
|
367
|
+
|
|
368
|
+
const mirror = path.join(root, "mirror");
|
|
369
|
+
git(root, "clone", "-q", remote, mirror);
|
|
370
|
+
|
|
371
|
+
const found = await findUnrelatedRemoteHistory({
|
|
372
|
+
mirrorPath: mirror,
|
|
373
|
+
remoteUrl: "https://github.com/a/b.git",
|
|
374
|
+
remoteHeads: headShasOf(remote),
|
|
375
|
+
});
|
|
376
|
+
expect(found).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("empty remote → not flagged (the ordinary fresh-repo setup)", async () => {
|
|
380
|
+
const root = tmp("pv-emptyremote-");
|
|
381
|
+
const mirror = path.join(root, "mirror");
|
|
382
|
+
fs.mkdirSync(mirror, { recursive: true });
|
|
383
|
+
git(mirror, "init", "-q", "-b", "main");
|
|
384
|
+
const found = await findUnrelatedRemoteHistory({
|
|
385
|
+
mirrorPath: mirror,
|
|
386
|
+
remoteUrl: "https://github.com/a/b.git",
|
|
387
|
+
remoteHeads: [],
|
|
388
|
+
});
|
|
389
|
+
expect(found).toBeNull();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("no mirror on disk yet + non-empty remote → flagged as fresh", async () => {
|
|
393
|
+
const root = tmp("pv-nomirror-");
|
|
394
|
+
const found = await findUnrelatedRemoteHistory({
|
|
395
|
+
mirrorPath: path.join(root, "does-not-exist"),
|
|
396
|
+
remoteUrl: "https://github.com/a/b.git",
|
|
397
|
+
remoteHeads: ["a".repeat(40)],
|
|
398
|
+
});
|
|
399
|
+
expect(found).not.toBeNull();
|
|
400
|
+
expect(found?.mirrorIsFresh).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("null mirrorPath + non-empty remote → flagged as fresh", async () => {
|
|
404
|
+
const found = await findUnrelatedRemoteHistory({
|
|
405
|
+
mirrorPath: null,
|
|
406
|
+
remoteUrl: "https://github.com/a/b.git",
|
|
407
|
+
remoteHeads: ["b".repeat(40)],
|
|
408
|
+
});
|
|
409
|
+
expect(found?.mirrorIsFresh).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("message names the repo, the cause, and never leaks a token", () => {
|
|
413
|
+
const msg = unrelatedHistoryMessage({
|
|
414
|
+
remoteIdentity: "github.com/aaron/my-vault",
|
|
415
|
+
mirrorIsFresh: true,
|
|
416
|
+
});
|
|
417
|
+
expect(msg).toContain("github.com/aaron/my-vault");
|
|
418
|
+
expect(msg).toContain("non-fast-forward");
|
|
419
|
+
expect(msg).toContain("override=true");
|
|
420
|
+
|
|
421
|
+
// Unparseable URL falls back to a userinfo-stripped form, not the raw one.
|
|
422
|
+
const leaky = unrelatedHistoryMessage({
|
|
423
|
+
remoteIdentity: "not a url",
|
|
424
|
+
mirrorIsFresh: false,
|
|
425
|
+
});
|
|
426
|
+
expect(leaky).not.toContain("@");
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -271,3 +271,127 @@ export function remoteConflictMessage(conflict: RemoteConflict): string {
|
|
|
271
271
|
`If you're sure (e.g. you just moved the repo between vaults), pass override=true to proceed anyway.`
|
|
272
272
|
);
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Unrelated-history guard (vault#823).
|
|
277
|
+
//
|
|
278
|
+
// Field report: a vault imported from a git repo, then armed Sync against that
|
|
279
|
+
// same repo, and every push for five days was refused as non-fast-forward.
|
|
280
|
+
// `mirror-import.ts` clones to a TEMP dir, imports notes into the store, and
|
|
281
|
+
// deletes the temp dir — it never touches the mirror dir. The mirror is then
|
|
282
|
+
// stood up fresh by `bootstrapInternalMirror` (`git init` + seed commit), so
|
|
283
|
+
// its root commit has no relationship to anything on the remote. Two histories,
|
|
284
|
+
// no common ancestor, and no push can ever land. It is deterministic, not a
|
|
285
|
+
// race: ANY import onto a non-empty remote produces a mirror that can never
|
|
286
|
+
// push.
|
|
287
|
+
//
|
|
288
|
+
// The cross-vault guard above answers "is someone else using this repo?". This
|
|
289
|
+
// one answers a different question about the SAME bind: "can my history reach
|
|
290
|
+
// theirs at all?" — and it is the one the import path needs, because on that
|
|
291
|
+
// path the remote is not a stranger's repo, it is the repo we just cloned FROM.
|
|
292
|
+
//
|
|
293
|
+
// Detection, without a fetch: ask the remote for its head shas (`ls-remote`,
|
|
294
|
+
// already run at the PAT bind point), then ask the mirror whether it HOLDS any
|
|
295
|
+
// of those objects (`git cat-file -e`). A mirror that was cloned from — or has
|
|
296
|
+
// ever pushed to — the remote holds them. A freshly-`git init`ed one does not.
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/** A detected unrelated-history bind — the mirror can never push to this remote. */
|
|
300
|
+
export interface UnrelatedHistory {
|
|
301
|
+
/** Normalized repo identity, or the raw URL when it can't be parsed. */
|
|
302
|
+
remoteIdentity: string;
|
|
303
|
+
/** True when the mirror dir doesn't exist / holds no commits yet. */
|
|
304
|
+
mirrorIsFresh: boolean;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Does `mirrorPath` hold at least one of `remoteHeads`?
|
|
309
|
+
*
|
|
310
|
+
* `git cat-file -e <sha>^{commit}` is a local object-database lookup — no
|
|
311
|
+
* network, no fetch, no working-tree touch. Fails closed to `false` (we do not
|
|
312
|
+
* hold it) on any spawn error, which routes into "unrelated" and therefore into
|
|
313
|
+
* a warning rather than a silent arm. That is the safe direction: the cost of a
|
|
314
|
+
* false warning is one confused operator; the cost of a false all-clear is five
|
|
315
|
+
* days with no backup.
|
|
316
|
+
*/
|
|
317
|
+
async function mirrorHoldsAnyRemoteHead(
|
|
318
|
+
mirrorPath: string,
|
|
319
|
+
remoteHeads: string[],
|
|
320
|
+
spawnImpl: typeof Bun.spawn = Bun.spawn,
|
|
321
|
+
): Promise<boolean> {
|
|
322
|
+
for (const sha of remoteHeads) {
|
|
323
|
+
if (!/^[0-9a-f]{7,64}$/.test(sha)) continue;
|
|
324
|
+
try {
|
|
325
|
+
const proc = spawnImpl(["git", "cat-file", "-e", `${sha}^{commit}`], {
|
|
326
|
+
cwd: mirrorPath,
|
|
327
|
+
stdout: "ignore",
|
|
328
|
+
stderr: "ignore",
|
|
329
|
+
});
|
|
330
|
+
if ((await proc.exited) === 0) return true;
|
|
331
|
+
} catch {
|
|
332
|
+
// git missing / path unreadable — treat as "not held" and keep going.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Detect a bind whose pushes can never land: a non-empty remote whose history
|
|
340
|
+
* the local mirror cannot reach.
|
|
341
|
+
*
|
|
342
|
+
* Returns null (bind is fine) when:
|
|
343
|
+
* - the remote is EMPTY — nothing to conflict with; the first push defines
|
|
344
|
+
* the history. This is the ordinary "fresh repo" setup and must not warn.
|
|
345
|
+
* - the mirror already holds one of the remote's heads — it was cloned from
|
|
346
|
+
* there, or has pushed there before. Re-binding after a token rotation
|
|
347
|
+
* lands here and must not warn.
|
|
348
|
+
*
|
|
349
|
+
* Returns a struct (bind will never push) when the remote has refs and the
|
|
350
|
+
* mirror holds none of them — including the case where the mirror does not
|
|
351
|
+
* exist yet, because what gets created will be a fresh `git init`.
|
|
352
|
+
*/
|
|
353
|
+
export async function findUnrelatedRemoteHistory(opts: {
|
|
354
|
+
mirrorPath: string | null;
|
|
355
|
+
remoteUrl: string;
|
|
356
|
+
/** Head shas from `git ls-remote` — empty array means an empty remote. */
|
|
357
|
+
remoteHeads: string[];
|
|
358
|
+
/** Test seam. */
|
|
359
|
+
spawnImpl?: typeof Bun.spawn;
|
|
360
|
+
}): Promise<UnrelatedHistory | null> {
|
|
361
|
+
const { mirrorPath, remoteUrl, remoteHeads, spawnImpl } = opts;
|
|
362
|
+
// An empty remote can't conflict — the first push establishes the history.
|
|
363
|
+
if (remoteHeads.length === 0) return null;
|
|
364
|
+
|
|
365
|
+
const remoteIdentity = normalizeRemoteIdentity(remoteUrl) ?? redactUrlForMessage(remoteUrl);
|
|
366
|
+
|
|
367
|
+
// No mirror on disk yet → whatever gets bootstrapped will be a fresh root.
|
|
368
|
+
if (mirrorPath === null || !existsSync(join(mirrorPath, ".git"))) {
|
|
369
|
+
return { remoteIdentity, mirrorIsFresh: true };
|
|
370
|
+
}
|
|
371
|
+
if (await mirrorHoldsAnyRemoteHead(mirrorPath, remoteHeads, spawnImpl)) return null;
|
|
372
|
+
return { remoteIdentity, mirrorIsFresh: false };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Strip any userinfo from a remote URL so an unparseable one can still be
|
|
377
|
+
* named in an operator-facing message without leaking a token.
|
|
378
|
+
*/
|
|
379
|
+
function redactUrlForMessage(remote: string): string {
|
|
380
|
+
return remote.replace(/\/\/[^@/]*@/, "//");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Operator-facing message for a refused arm. Says what is wrong, why it can
|
|
385
|
+
* never self-correct, and what the three real options are — deliberately
|
|
386
|
+
* concrete, because "non-fast-forward" is exactly the error an operator cannot
|
|
387
|
+
* act on without knowing the histories are unrelated.
|
|
388
|
+
*/
|
|
389
|
+
export function unrelatedHistoryMessage(found: UnrelatedHistory): string {
|
|
390
|
+
return (
|
|
391
|
+
`${found.remoteIdentity} already has commits that this vault's backup history doesn't share, ` +
|
|
392
|
+
`so every push would be rejected (non-fast-forward) and it would never recover on its own. ` +
|
|
393
|
+
`Importing brings your notes across but not the old backup history, which is why they don't line up. ` +
|
|
394
|
+
`Either back up to a new empty repo, or replace the repo's contents with this vault's history on purpose. ` +
|
|
395
|
+
`If you know they should be joined, pass override=true to arm Sync anyway.`
|
|
396
|
+
);
|
|
397
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type MirrorDeps,
|
|
23
23
|
} from "./mirror-manager.ts";
|
|
24
24
|
import {
|
|
25
|
+
enableSyncToImportedRepo,
|
|
25
26
|
_resetDeviceFlowSessionsForTest,
|
|
26
27
|
handleAuthDelete,
|
|
27
28
|
handleAuthGet,
|
|
@@ -2310,6 +2311,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2310
2311
|
spawnCloneSuccess(fixture),
|
|
2311
2312
|
undefined,
|
|
2312
2313
|
manager,
|
|
2314
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2315
|
+
async () => ({ ok: true, heads: [] }),
|
|
2313
2316
|
);
|
|
2314
2317
|
expect(res.status).toBe(200);
|
|
2315
2318
|
const body = (await res.json()) as {
|
|
@@ -2355,6 +2358,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2355
2358
|
spawnCloneSuccess(fixture),
|
|
2356
2359
|
undefined,
|
|
2357
2360
|
manager,
|
|
2361
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2362
|
+
async () => ({ ok: true, heads: [] }),
|
|
2358
2363
|
);
|
|
2359
2364
|
expect(res.status).toBe(200);
|
|
2360
2365
|
const body = (await res.json()) as {
|
|
@@ -2391,6 +2396,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2391
2396
|
spawnCloneSuccess(fixture),
|
|
2392
2397
|
undefined,
|
|
2393
2398
|
manager,
|
|
2399
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2400
|
+
async () => ({ ok: true, heads: [] }),
|
|
2394
2401
|
);
|
|
2395
2402
|
expect(res.status).toBe(200);
|
|
2396
2403
|
const body = (await res.json()) as {
|
|
@@ -2435,6 +2442,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2435
2442
|
spawnCloneSuccess(fixture),
|
|
2436
2443
|
undefined,
|
|
2437
2444
|
manager,
|
|
2445
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2446
|
+
async () => ({ ok: true, heads: [] }),
|
|
2438
2447
|
);
|
|
2439
2448
|
expect(res.status).toBe(200);
|
|
2440
2449
|
const body = (await res.json()) as {
|
|
@@ -2470,6 +2479,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2470
2479
|
spawnCloneSuccess(fixture),
|
|
2471
2480
|
undefined,
|
|
2472
2481
|
manager,
|
|
2482
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2483
|
+
async () => ({ ok: true, heads: [] }),
|
|
2473
2484
|
);
|
|
2474
2485
|
expect(res.status).toBe(200);
|
|
2475
2486
|
const body = (await res.json()) as { sync_enabled: boolean };
|
|
@@ -2516,6 +2527,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2516
2527
|
spawnCloneSuccess(fixture),
|
|
2517
2528
|
undefined,
|
|
2518
2529
|
manager,
|
|
2530
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2531
|
+
async () => ({ ok: true, heads: [] }),
|
|
2519
2532
|
);
|
|
2520
2533
|
expect(res.status).toBe(200);
|
|
2521
2534
|
const body = (await res.json()) as {
|
|
@@ -2569,6 +2582,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2569
2582
|
spawnCloneSuccess(fixture),
|
|
2570
2583
|
undefined,
|
|
2571
2584
|
manager,
|
|
2585
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2586
|
+
async () => ({ ok: true, heads: [] }),
|
|
2572
2587
|
);
|
|
2573
2588
|
expect(res.status).toBe(200);
|
|
2574
2589
|
const body = (await res.json()) as {
|
|
@@ -2626,6 +2641,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2626
2641
|
spawnCloneSuccess(fixture),
|
|
2627
2642
|
undefined,
|
|
2628
2643
|
manager,
|
|
2644
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2645
|
+
async () => ({ ok: true, heads: [] }),
|
|
2629
2646
|
);
|
|
2630
2647
|
expect(res.status).toBe(200);
|
|
2631
2648
|
const body = (await res.json()) as { sync_enabled: boolean };
|
|
@@ -2673,6 +2690,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2673
2690
|
spawnCloneSuccess(fixture),
|
|
2674
2691
|
undefined,
|
|
2675
2692
|
manager,
|
|
2693
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2694
|
+
async () => ({ ok: true, heads: [] }),
|
|
2676
2695
|
);
|
|
2677
2696
|
expect(res.status).toBe(200);
|
|
2678
2697
|
const body = (await res.json()) as {
|
|
@@ -2722,6 +2741,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2722
2741
|
spawnCloneSuccess(fixture),
|
|
2723
2742
|
undefined,
|
|
2724
2743
|
manager,
|
|
2744
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2745
|
+
async () => ({ ok: true, heads: [] }),
|
|
2725
2746
|
);
|
|
2726
2747
|
expect(res.status).toBe(200);
|
|
2727
2748
|
const body = (await res.json()) as {
|
|
@@ -2766,6 +2787,8 @@ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
|
|
|
2766
2787
|
spawnCloneSuccess(fixture),
|
|
2767
2788
|
undefined,
|
|
2768
2789
|
manager,
|
|
2790
|
+
// vault#823: hermetic probe — reachable, empty remote (no guard fire).
|
|
2791
|
+
async () => ({ ok: true, heads: [] }),
|
|
2769
2792
|
);
|
|
2770
2793
|
expect(res.status).toBe(200);
|
|
2771
2794
|
const body = (await res.json()) as {
|
|
@@ -3165,3 +3188,127 @@ describe("cross-vault remote-clobber guard (vault#482)", () => {
|
|
|
3165
3188
|
});
|
|
3166
3189
|
});
|
|
3167
3190
|
|
|
3191
|
+
// ---------------------------------------------------------------------------
|
|
3192
|
+
// vault#823 — enableSyncToImportedRepo must consult the unrelated-history
|
|
3193
|
+
// guard, not just contain a call to it.
|
|
3194
|
+
//
|
|
3195
|
+
// The lesson from hub#820, applied before shipping rather than after: proving
|
|
3196
|
+
// `findUnrelatedRemoteHistory` returns a struct proves nothing about whether
|
|
3197
|
+
// the arm path reads it. Mutating `if (unrelated)` to `if (false && unrelated)`
|
|
3198
|
+
// left the whole vault suite green, which is how this test came to exist.
|
|
3199
|
+
// ---------------------------------------------------------------------------
|
|
3200
|
+
|
|
3201
|
+
describe("enableSyncToImportedRepo — unrelated-history guard (vault#823)", () => {
|
|
3202
|
+
/** Minimal manager stand-in: the guard branch only reads `mirror_path`. */
|
|
3203
|
+
function stubManager(mirrorPath: string | null): MirrorManager {
|
|
3204
|
+
return {
|
|
3205
|
+
getStatus: () => ({ mirror_path: mirrorPath }),
|
|
3206
|
+
} as unknown as MirrorManager;
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
test("non-empty remote + fresh mirror → sync NOT armed, and the reason is stated", async () => {
|
|
3210
|
+
const res = await enableSyncToImportedRepo({
|
|
3211
|
+
vaultName: "solo",
|
|
3212
|
+
remoteUrl: "https://github.com/aaron/my-vault.git",
|
|
3213
|
+
auth: { kind: "none" },
|
|
3214
|
+
manager: stubManager(null), // nothing bootstrapped yet — the import case
|
|
3215
|
+
probeOverride: async () => ({ ok: true, heads: ["a".repeat(40)] }),
|
|
3216
|
+
});
|
|
3217
|
+
expect(res.sync_enabled).toBe(false);
|
|
3218
|
+
expect(res.warning).toContain("github.com/aaron/my-vault");
|
|
3219
|
+
expect(res.warning).toContain("non-fast-forward");
|
|
3220
|
+
// It must not be mistaken for the "no credential" refusal further down —
|
|
3221
|
+
// that one fires for auth.kind === "none" and would also return false.
|
|
3222
|
+
expect(res.warning).not.toContain("write credentials");
|
|
3223
|
+
});
|
|
3224
|
+
|
|
3225
|
+
test("empty remote → guard stays out of the way (falls through to the credential check)", async () => {
|
|
3226
|
+
const res = await enableSyncToImportedRepo({
|
|
3227
|
+
vaultName: "solo",
|
|
3228
|
+
remoteUrl: "https://github.com/aaron/fresh.git",
|
|
3229
|
+
auth: { kind: "none" },
|
|
3230
|
+
manager: stubManager(null),
|
|
3231
|
+
probeOverride: async () => ({ ok: true, heads: [] }),
|
|
3232
|
+
});
|
|
3233
|
+
expect(res.sync_enabled).toBe(false);
|
|
3234
|
+
// Reached the NEXT refusal, which proves the history guard declined to fire.
|
|
3235
|
+
expect(res.warning).toContain("write credentials");
|
|
3236
|
+
});
|
|
3237
|
+
|
|
3238
|
+
test("override=true skips the guard entirely", async () => {
|
|
3239
|
+
const res = await enableSyncToImportedRepo({
|
|
3240
|
+
vaultName: "solo",
|
|
3241
|
+
remoteUrl: "https://github.com/aaron/my-vault.git",
|
|
3242
|
+
auth: { kind: "none" },
|
|
3243
|
+
manager: stubManager(null),
|
|
3244
|
+
override: true,
|
|
3245
|
+
probeOverride: async () => ({ ok: true, heads: ["a".repeat(40)] }),
|
|
3246
|
+
});
|
|
3247
|
+
expect(res.warning).toContain("write credentials");
|
|
3248
|
+
expect(res.warning).not.toContain("non-fast-forward");
|
|
3249
|
+
});
|
|
3250
|
+
|
|
3251
|
+
test("unreachable remote fails OPEN — a network blip must not block setup", async () => {
|
|
3252
|
+
const res = await enableSyncToImportedRepo({
|
|
3253
|
+
vaultName: "solo",
|
|
3254
|
+
remoteUrl: "https://github.com/aaron/my-vault.git",
|
|
3255
|
+
auth: { kind: "none" },
|
|
3256
|
+
manager: stubManager(null),
|
|
3257
|
+
probeOverride: async () => ({ ok: false, error: "could not resolve host" }),
|
|
3258
|
+
});
|
|
3259
|
+
expect(res.warning).toContain("write credentials");
|
|
3260
|
+
expect(res.warning).not.toContain("non-fast-forward");
|
|
3261
|
+
});
|
|
3262
|
+
|
|
3263
|
+
test("a skipped check is SAID, not silent — the advisory rides on a successful arm", async () => {
|
|
3264
|
+
// Uni's catch on #646: this guard runs at BIND time and there may be no
|
|
3265
|
+
// next bind. An operator arms Sync once and walks away, so a probe blip
|
|
3266
|
+
// that silently skips the check reproduces the exact five-day silence the
|
|
3267
|
+
// guard exists to prevent. Failing open is still right; failing open
|
|
3268
|
+
// WITHOUT saying so is not.
|
|
3269
|
+
let armed = false;
|
|
3270
|
+
const manager = {
|
|
3271
|
+
getStatus: () => (armed ? { mirror_path: "/tmp/m", enabled: true } : { mirror_path: null }),
|
|
3272
|
+
getEffectiveConfig: () => ({ enabled: false, auto_push: false, location: "internal" }),
|
|
3273
|
+
reload: async () => {
|
|
3274
|
+
armed = true;
|
|
3275
|
+
},
|
|
3276
|
+
} as unknown as MirrorManager;
|
|
3277
|
+
|
|
3278
|
+
const res = await enableSyncToImportedRepo({
|
|
3279
|
+
vaultName: "solo",
|
|
3280
|
+
remoteUrl: "https://github.com/aaron/my-vault.git",
|
|
3281
|
+
auth: { kind: "pat", token: "ghp_x" },
|
|
3282
|
+
manager,
|
|
3283
|
+
probeOverride: async () => ({ ok: false, error: "could not resolve host" }),
|
|
3284
|
+
});
|
|
3285
|
+
expect(res.sync_enabled).toBe(true);
|
|
3286
|
+
expect(res.warning).toContain("couldn't reach the repo");
|
|
3287
|
+
// Points at a surface that works TODAY — the vault's own Git remote
|
|
3288
|
+
// section, which renders status.last_push_error. Naming the hub account
|
|
3289
|
+
// tile instead would make hub#820 a precondition for text a user reads.
|
|
3290
|
+
expect(res.warning).toContain("Git remote section");
|
|
3291
|
+
expect(res.warning).not.toContain("account page");
|
|
3292
|
+
});
|
|
3293
|
+
|
|
3294
|
+
test("a probe that succeeded adds no advisory", async () => {
|
|
3295
|
+
let armed = false;
|
|
3296
|
+
const manager = {
|
|
3297
|
+
getStatus: () => (armed ? { mirror_path: "/tmp/m", enabled: true } : { mirror_path: null }),
|
|
3298
|
+
getEffectiveConfig: () => ({ enabled: false, auto_push: false, location: "internal" }),
|
|
3299
|
+
reload: async () => {
|
|
3300
|
+
armed = true;
|
|
3301
|
+
},
|
|
3302
|
+
} as unknown as MirrorManager;
|
|
3303
|
+
|
|
3304
|
+
const res = await enableSyncToImportedRepo({
|
|
3305
|
+
vaultName: "solo",
|
|
3306
|
+
remoteUrl: "https://github.com/aaron/fresh.git",
|
|
3307
|
+
auth: { kind: "pat", token: "ghp_x" },
|
|
3308
|
+
manager,
|
|
3309
|
+
probeOverride: async () => ({ ok: true, heads: [] }),
|
|
3310
|
+
});
|
|
3311
|
+
expect(res.sync_enabled).toBe(true);
|
|
3312
|
+
expect(res.warning ?? "").not.toContain("couldn't reach");
|
|
3313
|
+
});
|
|
3314
|
+
});
|