@indigoai-us/hq-cloud 6.14.46 → 6.14.48
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/dist/cli/share.d.ts +11 -3
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +214 -22
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +595 -28
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +9 -5
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/ignore.d.ts +38 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +55 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +50 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/journal.d.ts +56 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +49 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +37 -2
- package/dist/journal.test.js.map +1 -1
- package/dist/s3.test.js +6 -0
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +670 -28
- package/src/cli/share.ts +234 -34
- package/src/cli/sync.ts +29 -15
- package/src/ignore.test.ts +55 -0
- package/src/ignore.ts +57 -0
- package/src/journal.test.ts +44 -1
- package/src/journal.ts +69 -4
- package/src/s3.test.ts +6 -0
package/dist/cli/share.test.js
CHANGED
|
@@ -2305,16 +2305,24 @@ describe("share", () => {
|
|
|
2305
2305
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2306
2306
|
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
2307
2307
|
});
|
|
2308
|
-
it("
|
|
2309
|
-
"
|
|
2310
|
-
//
|
|
2311
|
-
// owned-only
|
|
2312
|
-
//
|
|
2313
|
-
//
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2308
|
+
it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
|
|
2309
|
+
"even under owned-only, because litter bypasses the policy gate (the May-27 .drift-* leak class)", async () => {
|
|
2310
|
+
// Provenance: this test was written for a `prs_`-scoped coercion that
|
|
2311
|
+
// rewrote owned-only -> currency-gated. That coercion is gone (6.14.48 —
|
|
2312
|
+
// it silently defeated the rollback knob; see `createPushRunContext`).
|
|
2313
|
+
// The test still passes, and is retained, because what actually drains
|
|
2314
|
+
// this key is the vault-litter path (6.0.2): `isVaultLitterArtifact`
|
|
2315
|
+
// routes `.drift-<ts>-<pid>` keys to `litterToDelete` BEFORE any policy,
|
|
2316
|
+
// intent, ignore-filter or breaker check runs. That is the durable
|
|
2317
|
+
// guarantee worth pinning — the May-27 `personal/.obsidian/*.drift-*`
|
|
2318
|
+
// pair (journal direction "down", local rm, push silently swallowed the
|
|
2319
|
+
// delete) must drain regardless of which delete policy is in force.
|
|
2320
|
+
//
|
|
2321
|
+
// Note the inputs deliberately keep `direction: "down"`, no
|
|
2322
|
+
// `localDeleteIntent`, and an explicit `owned-only` policy: every one of
|
|
2323
|
+
// those would refuse this key on the normal path, so a regression that
|
|
2324
|
+
// removed the litter bypass would fail here rather than silently
|
|
2325
|
+
// re-stranding litter.
|
|
2318
2326
|
const stateDirPersonal = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-prs-"));
|
|
2319
2327
|
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
2320
2328
|
try {
|
|
@@ -2353,10 +2361,8 @@ describe("share", () => {
|
|
|
2353
2361
|
personalMode: true,
|
|
2354
2362
|
skipUnchanged: true,
|
|
2355
2363
|
propagateDeletes: true,
|
|
2356
|
-
//
|
|
2357
|
-
//
|
|
2358
|
-
// only's "don't tombstone peer-uploaded content" rule has no peer
|
|
2359
|
-
// to protect against here, so we coerce to currency-gated.
|
|
2364
|
+
// Pinned owned-only — the strictest policy. Litter must drain
|
|
2365
|
+
// anyway; that bypass is the property under test.
|
|
2360
2366
|
propagateDeletePolicy: "owned-only",
|
|
2361
2367
|
});
|
|
2362
2368
|
expect(result.filesDeleted).toBe(1);
|
|
@@ -2423,14 +2429,13 @@ describe("share", () => {
|
|
|
2423
2429
|
"core/policies/shipped.md",
|
|
2424
2430
|
]);
|
|
2425
2431
|
});
|
|
2426
|
-
it("
|
|
2427
|
-
"
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
//
|
|
2432
|
-
//
|
|
2433
|
-
// refactors share()'s entity-typing.
|
|
2432
|
+
it("owned-only on a cmp_ entity refuses to tombstone a direction:'down' entry " +
|
|
2433
|
+
"(multi-user curation intact)", async () => {
|
|
2434
|
+
// owned-only's multi-user semantics: a behind machine that pulled
|
|
2435
|
+
// Alice's upload and then rm'd it locally must NOT tombstone Alice's
|
|
2436
|
+
// file. Paired with the prs_ test below, this pins owned-only as
|
|
2437
|
+
// uniformly strict across BOTH vault kinds — the property a rollback
|
|
2438
|
+
// knob has to have to be worth reaching for.
|
|
2434
2439
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2435
2440
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2436
2441
|
// No file on disk under companies/acme/peer-uploaded.md → local missing.
|
|
@@ -2463,6 +2468,104 @@ describe("share", () => {
|
|
|
2463
2468
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2464
2469
|
expect(journal.files["peer-uploaded.md"]).toBeDefined();
|
|
2465
2470
|
});
|
|
2471
|
+
it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
|
|
2472
|
+
"matching remote etag is REFUSED, not deleted", async () => {
|
|
2473
|
+
// Regression guard for the coercion removed in 6.14.48. A `prs_`-scoped
|
|
2474
|
+
// rewrite of owned-only -> currency-gated used to sit in
|
|
2475
|
+
// `createPushRunContext`. While both policies refused intent-less
|
|
2476
|
+
// deletes it was invisible. Once currency-gated started authorizing on
|
|
2477
|
+
// ETag currency alone, that rewrite meant an operator who set
|
|
2478
|
+
// `HQ_SYNC_DELETE_POLICY=owned-only` — the documented rollback, reached
|
|
2479
|
+
// for precisely BECAUSE deletes were misbehaving — still got intent-less
|
|
2480
|
+
// etag-only deletes on their personal vault. The rollback rolled back
|
|
2481
|
+
// company vaults only.
|
|
2482
|
+
//
|
|
2483
|
+
// The inputs below are exactly the shape that DOES delete under the
|
|
2484
|
+
// default policy (see "currency-gated: propagates a delete with NO
|
|
2485
|
+
// watcher intent…"): local file absent, no `localDeleteIntent`, journal
|
|
2486
|
+
// etag matching live remote. Under owned-only it must refuse. If the
|
|
2487
|
+
// coercion ever returns, `filesDeleted` becomes 1 and this fails.
|
|
2488
|
+
const stateDirPersonal = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-prs-rollback-"));
|
|
2489
|
+
process.env.HQ_STATE_DIR = stateDirPersonal;
|
|
2490
|
+
try {
|
|
2491
|
+
const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
|
|
2492
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
2493
|
+
version: "1",
|
|
2494
|
+
lastSync: new Date().toISOString(),
|
|
2495
|
+
files: {
|
|
2496
|
+
// direction:"up" — so the refusal cannot be attributed to
|
|
2497
|
+
// owned-only's direction filter. The ONLY thing refusing this
|
|
2498
|
+
// key is the intent requirement the rollback restores.
|
|
2499
|
+
"personal/knowledge/gone.md": {
|
|
2500
|
+
hash: "rollback-hash",
|
|
2501
|
+
size: 42,
|
|
2502
|
+
syncedAt: new Date(Date.now() - 86400000).toISOString(),
|
|
2503
|
+
direction: "up",
|
|
2504
|
+
remoteEtag: "rollback-etag",
|
|
2505
|
+
kind: "file",
|
|
2506
|
+
// NO localDeleteIntent — no watcher was running.
|
|
2507
|
+
},
|
|
2508
|
+
},
|
|
2509
|
+
}));
|
|
2510
|
+
// Live remote still matches the journal — so under currency-gated this
|
|
2511
|
+
// key is a definite `toDelete`. Without this, the beforeEach default
|
|
2512
|
+
// (null = 404) would route a regression to `toTombstone` instead and
|
|
2513
|
+
// the test would catch it only via the weaker journal assertion.
|
|
2514
|
+
//
|
|
2515
|
+
// Deliberately NOT `mockResolvedValueOnce`: on the PASSING path
|
|
2516
|
+
// owned-only refuses before any HEAD is issued, so a queued one-shot
|
|
2517
|
+
// would go unconsumed and leak into the next test (`vi.clearAllMocks`
|
|
2518
|
+
// in afterEach does not drain a `…Once` queue — it clears call
|
|
2519
|
+
// records, not queued implementations). That is the same trap already
|
|
2520
|
+
// documented in the `.drift-*` litter test above. A persistent impl
|
|
2521
|
+
// reset in `finally` cannot leak either way.
|
|
2522
|
+
vi.mocked(headRemoteFile).mockResolvedValue({
|
|
2523
|
+
lastModified: new Date(),
|
|
2524
|
+
etag: '"rollback-etag"',
|
|
2525
|
+
size: 42,
|
|
2526
|
+
});
|
|
2527
|
+
const refusals = [];
|
|
2528
|
+
const result = await share({
|
|
2529
|
+
paths: [tmpDir],
|
|
2530
|
+
entityContext: makeEntityContext({
|
|
2531
|
+
uid: "prs_01HASSAANTESTUSER",
|
|
2532
|
+
slug: "__hq_personal_vault__",
|
|
2533
|
+
bucketName: "hq-vault-prs-01HASSAANTESTUSER",
|
|
2534
|
+
}),
|
|
2535
|
+
hqRoot: tmpDir,
|
|
2536
|
+
personalMode: true,
|
|
2537
|
+
skipUnchanged: true,
|
|
2538
|
+
propagateDeletes: true,
|
|
2539
|
+
propagateDeletePolicy: "owned-only",
|
|
2540
|
+
onEvent: (event) => {
|
|
2541
|
+
if (event.type === "delete-refused-stale-etag") {
|
|
2542
|
+
refusals.push({ path: event.path, reason: event.reason });
|
|
2543
|
+
}
|
|
2544
|
+
},
|
|
2545
|
+
});
|
|
2546
|
+
expect(result.filesDeleted).toBe(0);
|
|
2547
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2548
|
+
// owned-only refuses on the intent gate, which sits BEFORE the HEAD
|
|
2549
|
+
// pass — so no currency check is ever issued. Asserting this also
|
|
2550
|
+
// keeps the mock above honest about never being consumed.
|
|
2551
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
2552
|
+
// Refused for want of an intent — NOT silently dropped, and not
|
|
2553
|
+
// refused for some incidental reason that would mask a regression.
|
|
2554
|
+
expect(refusals).toEqual([
|
|
2555
|
+
{ path: "personal/knowledge/gone.md", reason: "missing-delete-intent" },
|
|
2556
|
+
]);
|
|
2557
|
+
// Journal entry survives, so a later run (or a watcher-minted intent)
|
|
2558
|
+
// can still authorize the delete properly.
|
|
2559
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2560
|
+
expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
|
|
2561
|
+
}
|
|
2562
|
+
finally {
|
|
2563
|
+
vi.mocked(headRemoteFile).mockReset();
|
|
2564
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
2565
|
+
fs.rmSync(stateDirPersonal, { recursive: true, force: true });
|
|
2566
|
+
delete process.env.HQ_STATE_DIR;
|
|
2567
|
+
}
|
|
2568
|
+
});
|
|
2466
2569
|
it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
|
|
2467
2570
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2468
2571
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -3104,7 +3207,11 @@ describe("share", () => {
|
|
|
3104
3207
|
expect(events).toContainEqual(expect.objectContaining({
|
|
3105
3208
|
type: "delete-refused-stale-etag",
|
|
3106
3209
|
path: "racy.md",
|
|
3107
|
-
|
|
3210
|
+
// The file came back on disk between planning and applying. That is
|
|
3211
|
+
// now reported as what it is, rather than as an intent-version change:
|
|
3212
|
+
// the re-check is no longer nested inside the intent branch, so it
|
|
3213
|
+
// also covers etag-only candidates that carry no intent version.
|
|
3214
|
+
reason: "recreated-locally",
|
|
3108
3215
|
}));
|
|
3109
3216
|
});
|
|
3110
3217
|
it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
|
|
@@ -3247,20 +3354,23 @@ describe("share", () => {
|
|
|
3247
3354
|
});
|
|
3248
3355
|
expect(result.filesDeleted).toBe(0);
|
|
3249
3356
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3250
|
-
// HEAD must NOT be called —
|
|
3251
|
-
//
|
|
3357
|
+
// HEAD must NOT be called — an entry with no recorded etag has nothing to
|
|
3358
|
+
// compare against, so currency-gated refuses it before any remote
|
|
3359
|
+
// operation. (Pre-etag-gated-deletes this was refused one gate earlier, for
|
|
3360
|
+
// want of a watcher intent; the outcome — nothing deleted, no remote call —
|
|
3361
|
+
// is deliberately identical.)
|
|
3252
3362
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3253
3363
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
3254
3364
|
expect(refused).toMatchObject({
|
|
3255
|
-
journalEtag: "<
|
|
3256
|
-
reason: "
|
|
3365
|
+
journalEtag: "<legacy-no-etag>",
|
|
3366
|
+
reason: "legacy-no-etag",
|
|
3257
3367
|
});
|
|
3258
3368
|
expect(result.filesRefusedStale).toBe(1);
|
|
3259
3369
|
expect(result.pathResults).toContainEqual({
|
|
3260
3370
|
path: "legacy-no-etag.md",
|
|
3261
3371
|
status: "refused",
|
|
3262
3372
|
operation: "delete",
|
|
3263
|
-
reason: "
|
|
3373
|
+
reason: "legacy-no-etag",
|
|
3264
3374
|
});
|
|
3265
3375
|
});
|
|
3266
3376
|
it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
|
|
@@ -3309,6 +3419,316 @@ describe("share", () => {
|
|
|
3309
3419
|
expect(result.filesDeleted).toBe(1);
|
|
3310
3420
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), ".claude/commands/retired-command.md");
|
|
3311
3421
|
});
|
|
3422
|
+
// ── etag-gated deletes without a watcher ──────────────────────────────────
|
|
3423
|
+
//
|
|
3424
|
+
// A delete intent is minted ONLY by a live watcher at the instant it sees the
|
|
3425
|
+
// unlink. Three of the four ways HQ syncs never construct a watcher (outpost
|
|
3426
|
+
// and agent boxes run a one-shot runner, `hq sync now` is one-shot, and the
|
|
3427
|
+
// desktop drops the whole TreeWatcher when Instant-sync is off), so requiring
|
|
3428
|
+
// an intent made deletes structurally impossible there rather than merely
|
|
3429
|
+
// gated. currency-gated now authorizes on etag currency alone.
|
|
3430
|
+
//
|
|
3431
|
+
// The per-file question is unchanged: "has any peer touched this since we
|
|
3432
|
+
// journaled it". The whole-set question stays with the bulk-asymmetry
|
|
3433
|
+
// breaker, which these candidates still count toward — see the breaker
|
|
3434
|
+
// coverage above and policy `sync-delete-bulk-asymmetry-guard`.
|
|
3435
|
+
it("currency-gated: propagates a delete with NO watcher intent when the remote etag still matches", async () => {
|
|
3436
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3437
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3438
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3439
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3440
|
+
version: "1",
|
|
3441
|
+
lastSync: new Date().toISOString(),
|
|
3442
|
+
files: {
|
|
3443
|
+
"notes/gone.md": {
|
|
3444
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
3445
|
+
direction: "down",
|
|
3446
|
+
remoteEtag: "current-etag",
|
|
3447
|
+
kind: "file",
|
|
3448
|
+
// NO localDeleteIntent — deleted while no watcher was running.
|
|
3449
|
+
},
|
|
3450
|
+
},
|
|
3451
|
+
}));
|
|
3452
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
3453
|
+
lastModified: new Date(),
|
|
3454
|
+
etag: '"current-etag"',
|
|
3455
|
+
size: 12,
|
|
3456
|
+
});
|
|
3457
|
+
const result = await share({
|
|
3458
|
+
paths: [companyRoot],
|
|
3459
|
+
company: "acme",
|
|
3460
|
+
vaultConfig: mockConfig,
|
|
3461
|
+
hqRoot: tmpDir,
|
|
3462
|
+
skipUnchanged: true,
|
|
3463
|
+
propagateDeletes: true,
|
|
3464
|
+
propagateDeletePolicy: "currency-gated",
|
|
3465
|
+
});
|
|
3466
|
+
expect(result.filesDeleted).toBe(1);
|
|
3467
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "notes/gone.md");
|
|
3468
|
+
});
|
|
3469
|
+
it("currency-gated: refuses an intent-less delete when a peer has touched the remote", async () => {
|
|
3470
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3471
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3472
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3473
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3474
|
+
version: "1",
|
|
3475
|
+
lastSync: new Date().toISOString(),
|
|
3476
|
+
files: {
|
|
3477
|
+
"notes/peer-edited.md": {
|
|
3478
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
3479
|
+
direction: "down",
|
|
3480
|
+
remoteEtag: "etag-we-journaled",
|
|
3481
|
+
kind: "file",
|
|
3482
|
+
},
|
|
3483
|
+
},
|
|
3484
|
+
}));
|
|
3485
|
+
// A peer overwrote it since we journaled — their work must survive.
|
|
3486
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
3487
|
+
lastModified: new Date(),
|
|
3488
|
+
etag: '"a-different-etag"',
|
|
3489
|
+
size: 99,
|
|
3490
|
+
});
|
|
3491
|
+
const events = [];
|
|
3492
|
+
const result = await share({
|
|
3493
|
+
paths: [companyRoot],
|
|
3494
|
+
company: "acme",
|
|
3495
|
+
vaultConfig: mockConfig,
|
|
3496
|
+
hqRoot: tmpDir,
|
|
3497
|
+
skipUnchanged: true,
|
|
3498
|
+
propagateDeletes: true,
|
|
3499
|
+
propagateDeletePolicy: "currency-gated",
|
|
3500
|
+
onEvent: (e) => events.push(e),
|
|
3501
|
+
});
|
|
3502
|
+
expect(result.filesDeleted).toBe(0);
|
|
3503
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3504
|
+
expect(events).toContainEqual(expect.objectContaining({ type: "delete-refused-stale-etag", reason: "stale-etag" }));
|
|
3505
|
+
});
|
|
3506
|
+
it("currency-gated: abandons an etag-only delete when the file reappears between plan and apply", async () => {
|
|
3507
|
+
// Guards the apply-time absence re-check in `executeDeletes`. That check
|
|
3508
|
+
// used to live INSIDE the intent-version branch, so it was skipped for any
|
|
3509
|
+
// item carrying `intentVersion: null`. That was harmless while null meant
|
|
3510
|
+
// only litter and decommission — but etag-only candidates now travel with
|
|
3511
|
+
// `intentVersion: null` too, and without the restructure a file recreated
|
|
3512
|
+
// during the sync would be deleted from the remote while present on disk.
|
|
3513
|
+
//
|
|
3514
|
+
// The recreate is staged from inside the delete-plan HEAD, which is the
|
|
3515
|
+
// real TOCTOU window: planning has already decided the file is absent, and
|
|
3516
|
+
// the apply loop has not yet run. The push plan is computed BEFORE the
|
|
3517
|
+
// delete plan, so the new file is not uploaded by this run — it is purely
|
|
3518
|
+
// a late arrival the delete loop must notice.
|
|
3519
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3520
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3521
|
+
const recreatedPath = path.join(companyRoot, "notes", "gone.md");
|
|
3522
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3523
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3524
|
+
version: "1",
|
|
3525
|
+
lastSync: new Date().toISOString(),
|
|
3526
|
+
files: {
|
|
3527
|
+
"notes/gone.md": {
|
|
3528
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
3529
|
+
direction: "down",
|
|
3530
|
+
remoteEtag: "current-etag",
|
|
3531
|
+
kind: "file",
|
|
3532
|
+
// NO localDeleteIntent — this is an etag-only candidate.
|
|
3533
|
+
},
|
|
3534
|
+
},
|
|
3535
|
+
}));
|
|
3536
|
+
vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
|
|
3537
|
+
// A pull leg (or the user) puts the file back, mid-sync.
|
|
3538
|
+
fs.mkdirSync(path.dirname(recreatedPath), { recursive: true });
|
|
3539
|
+
fs.writeFileSync(recreatedPath, "back again");
|
|
3540
|
+
return { lastModified: new Date(), etag: '"current-etag"', size: 12 };
|
|
3541
|
+
});
|
|
3542
|
+
const events = [];
|
|
3543
|
+
const result = await share({
|
|
3544
|
+
paths: [companyRoot],
|
|
3545
|
+
company: "acme",
|
|
3546
|
+
vaultConfig: mockConfig,
|
|
3547
|
+
hqRoot: tmpDir,
|
|
3548
|
+
skipUnchanged: true,
|
|
3549
|
+
propagateDeletes: true,
|
|
3550
|
+
propagateDeletePolicy: "currency-gated",
|
|
3551
|
+
onEvent: (e) => events.push(e),
|
|
3552
|
+
});
|
|
3553
|
+
expect(result.filesDeleted).toBe(0);
|
|
3554
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3555
|
+
expect(events).toContainEqual(expect.objectContaining({
|
|
3556
|
+
type: "delete-refused-stale-etag",
|
|
3557
|
+
reason: "recreated-locally",
|
|
3558
|
+
}));
|
|
3559
|
+
// The file that came back is still on disk — the whole point.
|
|
3560
|
+
expect(fs.existsSync(recreatedPath)).toBe(true);
|
|
3561
|
+
});
|
|
3562
|
+
it("currency-gated: refuses an intent-less delete when the journal has no recorded etag", async () => {
|
|
3563
|
+
// Legacy journal entries (written before etag tracking) carry no
|
|
3564
|
+
// `remoteEtag`. Currency is the ONLY authorization an intent-less entry
|
|
3565
|
+
// has, so with nothing to compare there is nothing to authorize — refuse,
|
|
3566
|
+
// and keep the entry so a later sync that records an etag can re-evaluate.
|
|
3567
|
+
// No HEAD is issued: there is no etag to check it against.
|
|
3568
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3569
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3570
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3571
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3572
|
+
version: "1",
|
|
3573
|
+
lastSync: new Date().toISOString(),
|
|
3574
|
+
files: {
|
|
3575
|
+
"notes/legacy.md": {
|
|
3576
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
3577
|
+
direction: "down",
|
|
3578
|
+
kind: "file",
|
|
3579
|
+
// No remoteEtag, no localDeleteIntent.
|
|
3580
|
+
},
|
|
3581
|
+
},
|
|
3582
|
+
}));
|
|
3583
|
+
const events = [];
|
|
3584
|
+
const result = await share({
|
|
3585
|
+
paths: [companyRoot],
|
|
3586
|
+
company: "acme",
|
|
3587
|
+
vaultConfig: mockConfig,
|
|
3588
|
+
hqRoot: tmpDir,
|
|
3589
|
+
skipUnchanged: true,
|
|
3590
|
+
propagateDeletes: true,
|
|
3591
|
+
propagateDeletePolicy: "currency-gated",
|
|
3592
|
+
onEvent: (e) => events.push(e),
|
|
3593
|
+
});
|
|
3594
|
+
expect(result.filesDeleted).toBe(0);
|
|
3595
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3596
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3597
|
+
expect(events).toContainEqual(expect.objectContaining({
|
|
3598
|
+
type: "delete-refused-stale-etag",
|
|
3599
|
+
reason: "legacy-no-etag",
|
|
3600
|
+
}));
|
|
3601
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3602
|
+
expect(journal.files["notes/legacy.md"]).toBeDefined();
|
|
3603
|
+
});
|
|
3604
|
+
it("owned-only still requires an intent — it is the documented rollback knob", async () => {
|
|
3605
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3606
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3607
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3608
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
3609
|
+
version: "1",
|
|
3610
|
+
lastSync: new Date().toISOString(),
|
|
3611
|
+
files: {
|
|
3612
|
+
"notes/gone.md": {
|
|
3613
|
+
hash: "h", size: 12, syncedAt: new Date().toISOString(),
|
|
3614
|
+
direction: "up",
|
|
3615
|
+
remoteEtag: "current-etag",
|
|
3616
|
+
kind: "file",
|
|
3617
|
+
},
|
|
3618
|
+
},
|
|
3619
|
+
}));
|
|
3620
|
+
const result = await share({
|
|
3621
|
+
paths: [companyRoot],
|
|
3622
|
+
company: "acme",
|
|
3623
|
+
vaultConfig: mockConfig,
|
|
3624
|
+
hqRoot: tmpDir,
|
|
3625
|
+
skipUnchanged: true,
|
|
3626
|
+
propagateDeletes: true,
|
|
3627
|
+
propagateDeletePolicy: "owned-only",
|
|
3628
|
+
});
|
|
3629
|
+
expect(result.filesDeleted).toBe(0);
|
|
3630
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3631
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
3632
|
+
});
|
|
3633
|
+
// THE load-bearing safety test for this change. An etag match cannot tell
|
|
3634
|
+
// "the user deleted this" from "the local mirror vanished" — in the steady
|
|
3635
|
+
// state nobody has overwritten anything, so a moved hqRoot / unmounted volume
|
|
3636
|
+
// / fresh clone presents mass-ENOENT with perfectly matching etags. That is
|
|
3637
|
+
// exactly how the 2026-05-25 indigo vault mass-delete destroyed 559 objects.
|
|
3638
|
+
// Relaxing the per-file gate must NOT weaken the whole-set guard.
|
|
3639
|
+
it("bulk-asymmetry breaker still refuses a mass intent-less disappearance", async () => {
|
|
3640
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3641
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3642
|
+
const files = {};
|
|
3643
|
+
// 40 of 100 entries missing locally = 40%, far past the 10% threshold.
|
|
3644
|
+
for (let i = 0; i < 100; i++) {
|
|
3645
|
+
const present = i >= 40;
|
|
3646
|
+
const rel = `docs/f${i}.md`;
|
|
3647
|
+
if (present) {
|
|
3648
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
3649
|
+
fs.writeFileSync(path.join(companyRoot, rel), "x");
|
|
3650
|
+
}
|
|
3651
|
+
files[rel] = {
|
|
3652
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
3653
|
+
direction: "down",
|
|
3654
|
+
remoteEtag: `etag-${i}`,
|
|
3655
|
+
kind: "file",
|
|
3656
|
+
};
|
|
3657
|
+
}
|
|
3658
|
+
fs.writeFileSync(path.join(stateDir, "sync-journal.acme.json"), JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files }));
|
|
3659
|
+
const result = await share({
|
|
3660
|
+
paths: [companyRoot],
|
|
3661
|
+
company: "acme",
|
|
3662
|
+
vaultConfig: mockConfig,
|
|
3663
|
+
hqRoot: tmpDir,
|
|
3664
|
+
skipUnchanged: true,
|
|
3665
|
+
propagateDeletes: true,
|
|
3666
|
+
propagateDeletePolicy: "currency-gated",
|
|
3667
|
+
});
|
|
3668
|
+
expect(result.filesDeleted).toBe(0);
|
|
3669
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3670
|
+
// All 40 absent entries are refused as one set. (No assertion on
|
|
3671
|
+
// headRemoteFile here: the push leg HEADs the 60 files that ARE present,
|
|
3672
|
+
// so a global call-count says nothing about the delete plan.)
|
|
3673
|
+
expect(result.filesRefusedStale).toBe(40);
|
|
3674
|
+
});
|
|
3675
|
+
// Regression: moving a company vault from syncMode 'all' to 'shared'.
|
|
3676
|
+
// `hq sync narrow --apply` / scope-shrink calls `tombstoneEntry`, which
|
|
3677
|
+
// deliberately KEEPS hash, direction and remoteEtag so a recovery flow can
|
|
3678
|
+
// see what was pruned — and it removes the file locally. Every such entry
|
|
3679
|
+
// therefore looks exactly like an etag-current intent-less delete candidate.
|
|
3680
|
+
// Propagating those would delete OTHER members' files out of the shared
|
|
3681
|
+
// company vault because one member narrowed their own scope.
|
|
3682
|
+
//
|
|
3683
|
+
// Deliberately asserted with NO prefixSet passed: the scope filter used to be
|
|
3684
|
+
// the only thing preventing this, and that made an implicit dependency
|
|
3685
|
+
// load-bearing for a destructive operation.
|
|
3686
|
+
it("all -> shared: scope-shrink tombstones are never propagated as remote deletes", async () => {
|
|
3687
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3688
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3689
|
+
const now = new Date().toISOString();
|
|
3690
|
+
const shrunk = (reason) => ({
|
|
3691
|
+
hash: "h", size: 3, syncedAt: now,
|
|
3692
|
+
direction: "down",
|
|
3693
|
+
remoteEtag: "still-current",
|
|
3694
|
+
kind: "file",
|
|
3695
|
+
removedAt: now,
|
|
3696
|
+
removedReason: reason,
|
|
3697
|
+
});
|
|
3698
|
+
fs.writeFileSync(path.join(stateDir, "sync-journal.acme.json"), JSON.stringify({
|
|
3699
|
+
version: "2",
|
|
3700
|
+
lastSync: now,
|
|
3701
|
+
files: {
|
|
3702
|
+
"teammate/a.md": shrunk("scope_shrink"),
|
|
3703
|
+
"teammate/b.md": shrunk("narrow_apply"),
|
|
3704
|
+
"teammate/c.md": shrunk("manual"),
|
|
3705
|
+
},
|
|
3706
|
+
pulls: [],
|
|
3707
|
+
}));
|
|
3708
|
+
// The remote is ALIVE and still exactly what the journal recorded. Without
|
|
3709
|
+
// this the keys would 404 into `toTombstone` and the test would pass for
|
|
3710
|
+
// the wrong reason — verified by disabling the guard and watching it still
|
|
3711
|
+
// go green.
|
|
3712
|
+
vi.mocked(headRemoteFile).mockResolvedValue({
|
|
3713
|
+
lastModified: new Date(),
|
|
3714
|
+
etag: '"still-current"',
|
|
3715
|
+
size: 3,
|
|
3716
|
+
});
|
|
3717
|
+
const result = await share({
|
|
3718
|
+
paths: [companyRoot],
|
|
3719
|
+
company: "acme",
|
|
3720
|
+
vaultConfig: mockConfig,
|
|
3721
|
+
hqRoot: tmpDir,
|
|
3722
|
+
skipUnchanged: true,
|
|
3723
|
+
propagateDeletes: true,
|
|
3724
|
+
propagateDeletePolicy: "currency-gated",
|
|
3725
|
+
});
|
|
3726
|
+
expect(result.filesDeleted).toBe(0);
|
|
3727
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
3728
|
+
// Not refusals either — they are simply not delete candidates at all, so
|
|
3729
|
+
// they never enter the plan and never count toward the breaker ratio.
|
|
3730
|
+
expect(result.filesRefusedStale).toBe(0);
|
|
3731
|
+
});
|
|
3312
3732
|
// ── Conflict-mirror exclusion (ephemeral pattern) ──────────────────────────
|
|
3313
3733
|
//
|
|
3314
3734
|
// Conflict mirrors (`*.conflict-<ISO>-<machineHash>.<ext>`) are local-only
|
|
@@ -5785,6 +6205,71 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
|
|
|
5785
6205
|
]);
|
|
5786
6206
|
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
5787
6207
|
});
|
|
6208
|
+
it("never plans an upload for macOS Finder Icon\\r files — silent skip, valid files still ship", async () => {
|
|
6209
|
+
// The incident: Finder writes an `Icon\r` file into every directory it
|
|
6210
|
+
// renders a custom icon for, and a folder-level cloud-sync agent spreads
|
|
6211
|
+
// them tree-wide. A CR is a control character, so validateVaultUploadKey
|
|
6212
|
+
// throws INVALID_KEY_CONTROL_CHARS inside executeUploads; that throw lands
|
|
6213
|
+
// in the generic catch and emits type:"error". errors[] non-empty makes
|
|
6214
|
+
// the runner return PARTIAL_SYNC_EXIT, so ONE such file marks EVERY sync
|
|
6215
|
+
// pass failed forever. On the reporting machine there were 100+ per run.
|
|
6216
|
+
//
|
|
6217
|
+
// They are excluded during the walk, like .DS_Store, so they never reach
|
|
6218
|
+
// the planner at all — and, being expected noise, without a per-file
|
|
6219
|
+
// "EXCLUDED from sync" warning.
|
|
6220
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6221
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
6222
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
|
|
6223
|
+
fs.writeFileSync(path.join(companyRoot, "Icon\r"), "finder cruft");
|
|
6224
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "Icon\r"), "finder cruft");
|
|
6225
|
+
const events = [];
|
|
6226
|
+
const result = await share({
|
|
6227
|
+
paths: [companyRoot],
|
|
6228
|
+
company: "acme",
|
|
6229
|
+
vaultConfig: mockConfig,
|
|
6230
|
+
hqRoot: tmpDir,
|
|
6231
|
+
onEvent: (e) => events.push(e),
|
|
6232
|
+
});
|
|
6233
|
+
expect(result.aborted).toBe(false);
|
|
6234
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
6235
|
+
expect(uploadedKeys).toEqual(["docs/good.md"]);
|
|
6236
|
+
expect(result.filesUploaded).toBe(1);
|
|
6237
|
+
// The failure class this guards: an error event is what flips the runner
|
|
6238
|
+
// pass to exit 2.
|
|
6239
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
6240
|
+
// Expected OS noise — excluded without the "review in case this is
|
|
6241
|
+
// unintended" warning that real content would earn.
|
|
6242
|
+
expect(events.some((e) => e.type === "ignore-excluded")).toBe(false);
|
|
6243
|
+
});
|
|
6244
|
+
it("refuses any other control-character key up front — skip event, never an error", async () => {
|
|
6245
|
+
// Belt-and-suspenders for control-char paths that are not the Finder icon
|
|
6246
|
+
// spelling, and for callers that bypass the walker. Symmetric with the
|
|
6247
|
+
// pull leg, which has classified such keys since the same incident.
|
|
6248
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
6249
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
6250
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
|
|
6251
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "bad\u0001name.md"), "bad");
|
|
6252
|
+
const events = [];
|
|
6253
|
+
const result = await share({
|
|
6254
|
+
paths: [companyRoot],
|
|
6255
|
+
company: "acme",
|
|
6256
|
+
vaultConfig: mockConfig,
|
|
6257
|
+
hqRoot: tmpDir,
|
|
6258
|
+
onEvent: (e) => events.push(e),
|
|
6259
|
+
});
|
|
6260
|
+
expect(result.aborted).toBe(false);
|
|
6261
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
6262
|
+
expect(uploadedKeys).toEqual(["docs/good.md"]);
|
|
6263
|
+
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
6264
|
+
expect(skipEvents).toEqual([
|
|
6265
|
+
{
|
|
6266
|
+
type: "skip-invalid-scoped-key",
|
|
6267
|
+
path: "docs/bad\u0001name.md",
|
|
6268
|
+
errorCode: "INVALID_KEY_CONTROL_CHARS",
|
|
6269
|
+
},
|
|
6270
|
+
]);
|
|
6271
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
6272
|
+
});
|
|
5788
6273
|
it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
|
|
5789
6274
|
// Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
|
|
5790
6275
|
// companies/-prefixed key would be rejected by the presign transport's
|
|
@@ -5929,4 +6414,86 @@ describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", ()
|
|
|
5929
6414
|
expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
|
|
5930
6415
|
});
|
|
5931
6416
|
});
|
|
6417
|
+
// ── delete-refusal rendering ───────────────────────────────────────────────
|
|
6418
|
+
//
|
|
6419
|
+
// Every refusal cause shares ONE event type (`delete-refused-stale-etag`),
|
|
6420
|
+
// with the real cause carried in `reason`. Before this cover, the renderer
|
|
6421
|
+
// only branched on two of the five causes and the rest fell through to the
|
|
6422
|
+
// stale-etag wording — printing a real journal etag next to a
|
|
6423
|
+
// `<not-checked>` remote for a comparison that never happened. Operators
|
|
6424
|
+
// (including a customer bug report on 2026-08-04, and the investigation into
|
|
6425
|
+
// it) read that as an etag/currency defect and chased the wrong root cause.
|
|
6426
|
+
// Pin all five so no cause can silently fall through to etag wording again.
|
|
6427
|
+
describe("defaultConsoleLogger — delete refusal wording per reason", () => {
|
|
6428
|
+
const render = (reason) => {
|
|
6429
|
+
const lines = [];
|
|
6430
|
+
const spy = vi.spyOn(console, "error").mockImplementation((msg) => {
|
|
6431
|
+
lines.push(String(msg));
|
|
6432
|
+
});
|
|
6433
|
+
try {
|
|
6434
|
+
shareTesting.defaultConsoleLogger({
|
|
6435
|
+
type: "delete-refused-stale-etag",
|
|
6436
|
+
path: "knowledge/example.md",
|
|
6437
|
+
// A REAL etag, as the engine emits whenever the entry has one — the
|
|
6438
|
+
// `<missing-delete-intent>` placeholder only appears for entries that
|
|
6439
|
+
// never had an etag, which is the rare case.
|
|
6440
|
+
journalEtag: "0156114498af2c8939c50d7ef0d3a0b1",
|
|
6441
|
+
remoteEtag: "<not-checked>",
|
|
6442
|
+
reason,
|
|
6443
|
+
});
|
|
6444
|
+
}
|
|
6445
|
+
finally {
|
|
6446
|
+
spy.mockRestore();
|
|
6447
|
+
}
|
|
6448
|
+
return lines.join("\n");
|
|
6449
|
+
};
|
|
6450
|
+
it("names the missing authorization instead of blaming the etag", () => {
|
|
6451
|
+
const out = render("missing-delete-intent");
|
|
6452
|
+
expect(out).toContain("no-delete-authorization");
|
|
6453
|
+
expect(out).toContain("watcher did not observe this removal");
|
|
6454
|
+
// The specific regression: neither etag is meaningful, so neither may be
|
|
6455
|
+
// shown, and the stale-etag wording must not appear.
|
|
6456
|
+
expect(out).not.toContain("stale-etag");
|
|
6457
|
+
expect(out).not.toContain("<not-checked>");
|
|
6458
|
+
expect(out).not.toContain("0156114498af2c8939c50d7ef0d3a0b1");
|
|
6459
|
+
});
|
|
6460
|
+
it("names local divergence instead of blaming the etag", () => {
|
|
6461
|
+
const out = render("divergent-local");
|
|
6462
|
+
expect(out).toContain("divergent-local");
|
|
6463
|
+
expect(out).not.toContain("stale-etag");
|
|
6464
|
+
expect(out).not.toContain("<not-checked>");
|
|
6465
|
+
});
|
|
6466
|
+
it("still explains legacy entries that predate etag tracking", () => {
|
|
6467
|
+
const out = render("legacy-no-etag");
|
|
6468
|
+
expect(out).toContain("no-etag-on-record");
|
|
6469
|
+
expect(out).not.toContain("stale-etag");
|
|
6470
|
+
});
|
|
6471
|
+
it("still keeps bulk-asymmetry per-key lines compact", () => {
|
|
6472
|
+
const out = render("bulk-asymmetry");
|
|
6473
|
+
expect(out).toContain("bulk-asymmetry refusal");
|
|
6474
|
+
expect(out).not.toContain("stale-etag");
|
|
6475
|
+
});
|
|
6476
|
+
it("still reports a genuine etag mismatch with both etags", () => {
|
|
6477
|
+
const lines = [];
|
|
6478
|
+
const spy = vi.spyOn(console, "error").mockImplementation((msg) => {
|
|
6479
|
+
lines.push(String(msg));
|
|
6480
|
+
});
|
|
6481
|
+
try {
|
|
6482
|
+
shareTesting.defaultConsoleLogger({
|
|
6483
|
+
type: "delete-refused-stale-etag",
|
|
6484
|
+
path: "knowledge/example.md",
|
|
6485
|
+
journalEtag: "aaa111",
|
|
6486
|
+
remoteEtag: "bbb222",
|
|
6487
|
+
reason: "stale-etag",
|
|
6488
|
+
});
|
|
6489
|
+
}
|
|
6490
|
+
finally {
|
|
6491
|
+
spy.mockRestore();
|
|
6492
|
+
}
|
|
6493
|
+
const out = lines.join("\n");
|
|
6494
|
+
expect(out).toContain("stale-etag");
|
|
6495
|
+
expect(out).toContain("aaa111");
|
|
6496
|
+
expect(out).toContain("bbb222");
|
|
6497
|
+
});
|
|
6498
|
+
});
|
|
5932
6499
|
//# sourceMappingURL=share.test.js.map
|