@indigoai-us/hq-cloud 6.5.0 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts +4 -35
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -104
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +19 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +67 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +19 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +62 -19
- package/dist/cli/sync.js.map +1 -1
- package/dist/cognito-auth.d.ts +27 -0
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +97 -0
- package/dist/cognito-auth.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.d.ts +14 -0
- package/dist/machine-auth.test.d.ts.map +1 -0
- package/dist/machine-auth.test.js +216 -0
- package/dist/machine-auth.test.js.map +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +15 -5
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -2
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +70 -7
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +102 -23
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +63 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +50 -0
- package/dist/sync/pull-scope.d.ts.map +1 -0
- package/dist/sync/pull-scope.js +129 -0
- package/dist/sync/pull-scope.js.map +1 -0
- package/package.json +2 -2
- package/src/bin/sync-runner.test.ts +23 -0
- package/src/bin/sync-runner.ts +19 -116
- package/src/cli/sync-scope.test.ts +84 -0
- package/src/cli/sync.ts +90 -17
- package/src/cognito-auth.ts +159 -0
- package/src/index.ts +21 -1
- package/src/machine-auth.test.ts +279 -0
- package/src/s3.test.ts +91 -1
- package/src/s3.ts +15 -5
- package/src/scope-shrink.test.ts +71 -0
- package/src/scope-shrink.ts +164 -20
- package/src/sync/pull-scope.ts +161 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -76,7 +76,11 @@ import {
|
|
|
76
76
|
type ExplicitGrant,
|
|
77
77
|
} from "../index.js";
|
|
78
78
|
import { pickCanonicalPersonEntity } from "../vault-client.js";
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
resolvePullScope,
|
|
81
|
+
readPinnedPrefixes,
|
|
82
|
+
type PullScope,
|
|
83
|
+
} from "../sync/pull-scope.js";
|
|
80
84
|
import {
|
|
81
85
|
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
|
82
86
|
computePersonalVaultPaths,
|
|
@@ -401,121 +405,13 @@ export interface VaultClientSurface {
|
|
|
401
405
|
listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
|
|
402
406
|
}
|
|
403
407
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
prefixSet?: string[];
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Resolve the effective download scope for a company target.
|
|
416
|
-
*
|
|
417
|
-
* - `all` → no prefix set; full-bucket pull (legacy behavior).
|
|
418
|
-
* - `shared` → coalesced caller explicit grants (company-relative paths,
|
|
419
|
-
* same namespace as `RemoteFile.key`).
|
|
420
|
-
* - `custom` → coalesced `customPaths` from the sync-config row.
|
|
421
|
-
*
|
|
422
|
-
* DEGRADE-TO-`all` CONTRACT: any failure (missing client method, membership
|
|
423
|
-
* not found, network error, grant fetch error) returns `{ syncMode: "all" }`.
|
|
424
|
-
* A transient failure must NEVER silently narrow scope — that would prune the
|
|
425
|
-
* local tree. Mirrors the CLI's `resolvePerCompanyPullPlan` degrade behavior.
|
|
426
|
-
*/
|
|
427
|
-
export async function resolvePullScope(
|
|
428
|
-
client: VaultClientSurface,
|
|
429
|
-
companyUid: string,
|
|
430
|
-
// Company slug — required to normalize grant paths (which may be anchored
|
|
431
|
-
// at `companies/<slug>/` or `<slug>/`) into the company-relative namespace.
|
|
432
|
-
slug: string,
|
|
433
|
-
// Local HQ root — used to read the per-machine pin set (`.hq/pins.json`).
|
|
434
|
-
// When omitted, pins are simply not unioned (no behavior change).
|
|
435
|
-
hqRoot?: string,
|
|
436
|
-
): Promise<PullScope> {
|
|
437
|
-
if (!client.getMembershipSyncConfig) return { syncMode: "all" };
|
|
438
|
-
try {
|
|
439
|
-
const memberships = await client.listMyMemberships();
|
|
440
|
-
const m = memberships.find((x) => x.companyUid === companyUid);
|
|
441
|
-
if (!m) return { syncMode: "all" };
|
|
442
|
-
const cfg = await client.getMembershipSyncConfig(m.membershipKey);
|
|
443
|
-
if (cfg.syncMode === "all") return { syncMode: "all" };
|
|
444
|
-
|
|
445
|
-
// Pins are company-relative prefixes a user explicitly materialized via
|
|
446
|
-
// `hq files get`. They're unioned into the scope so a scoped pull keeps
|
|
447
|
-
// them instead of pruning them as out-of-scope orphans. Pins only WIDEN
|
|
448
|
-
// scope, never narrow — and `all` mode (handled above) ignores them since
|
|
449
|
-
// it pulls everything anyway.
|
|
450
|
-
const pinPrefixes = hqRoot ? readPinnedPrefixes(hqRoot, slug) : [];
|
|
451
|
-
|
|
452
|
-
if (cfg.syncMode === "custom") {
|
|
453
|
-
const customPrefixes = (cfg.customPaths ?? []).map((p) =>
|
|
454
|
-
grantPathToPrefix(p, slug),
|
|
455
|
-
);
|
|
456
|
-
// A bare-everything entry ("" — e.g. a `*` path) collapses under
|
|
457
|
-
// `coalescePrefixes` (which drops empties) to "nothing", which would
|
|
458
|
-
// prune the whole tree. An everything-scope is semantically `all`.
|
|
459
|
-
if (customPrefixes.some((p) => p === "")) return { syncMode: "all" };
|
|
460
|
-
return {
|
|
461
|
-
syncMode: "custom",
|
|
462
|
-
prefixSet: coalescePrefixes([...customPrefixes, ...pinPrefixes]),
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
// shared: scope to the caller's explicit grants. Real grant paths are
|
|
466
|
-
// inconsistent — full (`companies/<slug>/x/*`), slug-anchored
|
|
467
|
-
// (`<slug>/x/*`), company-relative (`x/*`), bare globs (`*`), and exact
|
|
468
|
-
// files all coexist in production — so each is normalized via
|
|
469
|
-
// `grantPathToPrefix` into a company-relative, startsWith-friendly prefix
|
|
470
|
-
// (the namespace the engine's `RemoteFile.key`s live in) before coalescing.
|
|
471
|
-
//
|
|
472
|
-
// SAFETY: if the client can't fetch grants, we must NOT fall through to an
|
|
473
|
-
// empty `shared` scope — that would tell the engine "nothing is in scope"
|
|
474
|
-
// and scope-shrink would prune every clean local file. Degrade to `all`
|
|
475
|
-
// instead. A genuinely-empty grant list (the method exists and returns
|
|
476
|
-
// []) is a real "nothing shared with me" and is allowed to narrow.
|
|
477
|
-
if (!client.listMyExplicitGrants) return { syncMode: "all" };
|
|
478
|
-
const grants = await client.listMyExplicitGrants(companyUid);
|
|
479
|
-
const sharedPrefixes = grants.map((g) => grantPathToPrefix(g.path, slug));
|
|
480
|
-
// A wildcard grant (`*`) normalizes to "" = everything. Since
|
|
481
|
-
// `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
|
|
482
|
-
// treat any such grant as full-access `all` rather than risk pruning.
|
|
483
|
-
if (sharedPrefixes.some((p) => p === "")) return { syncMode: "all" };
|
|
484
|
-
return {
|
|
485
|
-
syncMode: "shared",
|
|
486
|
-
prefixSet: coalescePrefixes([...sharedPrefixes, ...pinPrefixes]),
|
|
487
|
-
};
|
|
488
|
-
} catch {
|
|
489
|
-
// Degrade to `all` — never prune on a resolution failure.
|
|
490
|
-
return { syncMode: "all" };
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Read the per-machine pin set (`<hqRoot>/.hq/pins.json`) and return the
|
|
496
|
-
* company-relative pinned prefixes for `slug`. These are prefixes the user
|
|
497
|
-
* materialized on demand via `hq files get` that must survive a scoped pull.
|
|
498
|
-
*
|
|
499
|
-
* Tolerant by construction: a missing, unreadable, or malformed file yields
|
|
500
|
-
* `[]` (no pins) — pins only ever widen scope, so "no pins" is the safe
|
|
501
|
-
* default. Empty-string entries are dropped (an everything-pin is meaningless
|
|
502
|
-
* here; `all` mode already covers that case).
|
|
503
|
-
*/
|
|
504
|
-
export function readPinnedPrefixes(hqRoot: string, slug: string): string[] {
|
|
505
|
-
try {
|
|
506
|
-
const raw = fs.readFileSync(path.join(hqRoot, ".hq", "pins.json"), "utf-8");
|
|
507
|
-
const parsed = JSON.parse(raw) as { pins?: Record<string, unknown> };
|
|
508
|
-
const list = parsed?.pins?.[slug];
|
|
509
|
-
if (Array.isArray(list)) {
|
|
510
|
-
return list.filter(
|
|
511
|
-
(p): p is string => typeof p === "string" && p.length > 0,
|
|
512
|
-
);
|
|
513
|
-
}
|
|
514
|
-
} catch {
|
|
515
|
-
/* missing / unreadable / malformed → no pins */
|
|
516
|
-
}
|
|
517
|
-
return [];
|
|
518
|
-
}
|
|
408
|
+
// `resolvePullScope`, `readPinnedPrefixes`, and the `PullScope` type now live
|
|
409
|
+
// in `../sync/pull-scope.ts` so the menubar runner and `hq sync pull|now`
|
|
410
|
+
// (hq-cli) share ONE scope resolver — the drift between them was the root
|
|
411
|
+
// cause of the all→shared scope-shrink wedge (DEV-1768). Re-exported here so
|
|
412
|
+
// existing importers (and the runner test suite) keep their import path.
|
|
413
|
+
export { resolvePullScope, readPinnedPrefixes };
|
|
414
|
+
export type { PullScope };
|
|
519
415
|
|
|
520
416
|
/**
|
|
521
417
|
* Backoff schedule (in ms) between attempts 2 and 3 of
|
|
@@ -1446,6 +1342,13 @@ export async function runRunner(
|
|
|
1446
1342
|
hqRoot: parsed.hqRoot,
|
|
1447
1343
|
onConflict: parsed.onConflict,
|
|
1448
1344
|
syncMode: pullScope.syncMode,
|
|
1345
|
+
// The menubar runner can take no interactive flag, so a scope shrink
|
|
1346
|
+
// must NEVER throw here (the old `ScopeShrinkBlockedError` → exit 2
|
|
1347
|
+
// was the permanent wedge in DEV-1768). Self-heal non-destructively:
|
|
1348
|
+
// dirty out-of-scope files stay on disk + un-tracked, clean ones are
|
|
1349
|
+
// quarantined (recoverable). This also clears an already-wedged
|
|
1350
|
+
// journal — seeded by a buggy `all`-mode CLI pull — on the next sync.
|
|
1351
|
+
scopeShrinkPolicy: "auto-recover",
|
|
1449
1352
|
// Scope-shrink authorship guard: pass the caller's own sub (the very
|
|
1450
1353
|
// sub stamped onto uploads as `created-by-sub`) so a scope shrink
|
|
1451
1354
|
// never prunes content this owner authored. Owners hold their whole
|
|
@@ -340,4 +340,88 @@ describe("sync — scope-aware download (US-005)", () => {
|
|
|
340
340
|
expect(forced.scopeOrphansBlocked).toBe(1);
|
|
341
341
|
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
342
342
|
});
|
|
343
|
+
|
|
344
|
+
// DEV-1768 fix #3: a scope shrink must QUARANTINE clean orphans (recoverable),
|
|
345
|
+
// never silently delete them. The file leaves its working-tree path but is
|
|
346
|
+
// recoverable under `.hq/scope-quarantine/<slug>/`.
|
|
347
|
+
it("scope shrink QUARANTINES a clean orphan (recoverable), never silent-deletes", async () => {
|
|
348
|
+
const quarantined = path.join(
|
|
349
|
+
tmpDir,
|
|
350
|
+
".hq",
|
|
351
|
+
"scope-quarantine",
|
|
352
|
+
"acme",
|
|
353
|
+
"docs",
|
|
354
|
+
"handoff.md",
|
|
355
|
+
);
|
|
356
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
357
|
+
const original = companyRel("docs/handoff.md");
|
|
358
|
+
const bodyBefore = fs.readFileSync(original, "utf-8");
|
|
359
|
+
|
|
360
|
+
const shared = await sync({
|
|
361
|
+
company: "acme",
|
|
362
|
+
vaultConfig: mockConfig,
|
|
363
|
+
hqRoot: tmpDir,
|
|
364
|
+
syncMode: "shared",
|
|
365
|
+
prefixSet: ["knowledge/"],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(shared.scopeOrphansRemoved).toBe(1);
|
|
369
|
+
// Gone from the working tree...
|
|
370
|
+
expect(fs.existsSync(original)).toBe(false);
|
|
371
|
+
// ...but recoverable in quarantine, byte-for-byte.
|
|
372
|
+
expect(fs.existsSync(quarantined)).toBe(true);
|
|
373
|
+
expect(fs.readFileSync(quarantined, "utf-8")).toBe(bodyBefore);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// DEV-1768 fix #1 (recovery) + #2: the background runner path
|
|
377
|
+
// (`scopeShrinkPolicy: "auto-recover"`) must NEVER throw on a scope shrink —
|
|
378
|
+
// it self-heals. This reproduces the EXACT wedge: a buggy `all`-mode pull
|
|
379
|
+
// seeds the journal (all-mode PullRecord + both files), one out-of-scope file
|
|
380
|
+
// is edited locally (dirty), then the real `shared` pull arrives. Under the
|
|
381
|
+
// old code this threw ScopeShrinkBlockedError(all→shared) → exit 2 forever.
|
|
382
|
+
it("auto-recover policy clears an all→shared wedge: dirty kept on disk, clean quarantined, no throw", async () => {
|
|
383
|
+
// 1. Buggy all-mode CLI seed: journals everything + stamps an all-mode record.
|
|
384
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
385
|
+
// 2. User edits an out-of-scope file → it becomes a DIRTY orphan.
|
|
386
|
+
fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — keep me");
|
|
387
|
+
const quarantinedClean = path.join(
|
|
388
|
+
tmpDir, ".hq", "scope-quarantine", "acme", "knowledge", "readme.md",
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// 3. The menubar runner's real shared pull. prefixSet covers NEITHER file
|
|
392
|
+
// (docs is dirty-orphan, knowledge becomes a clean orphan) so we exercise
|
|
393
|
+
// both dispositions. Must resolve without throwing.
|
|
394
|
+
const recovered = await sync({
|
|
395
|
+
company: "acme",
|
|
396
|
+
vaultConfig: mockConfig,
|
|
397
|
+
hqRoot: tmpDir,
|
|
398
|
+
syncMode: "shared",
|
|
399
|
+
prefixSet: ["projects/"],
|
|
400
|
+
scopeShrinkPolicy: "auto-recover",
|
|
401
|
+
});
|
|
402
|
+
expect(recovered.aborted).toBe(false);
|
|
403
|
+
// Dirty file: KEPT on disk (un-tracked), counted as blocked-but-kept.
|
|
404
|
+
expect(recovered.scopeOrphansBlocked).toBe(1);
|
|
405
|
+
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
406
|
+
expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
|
|
407
|
+
"LOCAL EDIT — keep me",
|
|
408
|
+
);
|
|
409
|
+
// Clean file: quarantined (recoverable), removed from the working tree.
|
|
410
|
+
expect(recovered.scopeOrphansRemoved).toBe(1);
|
|
411
|
+
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
|
|
412
|
+
expect(fs.existsSync(quarantinedClean)).toBe(true);
|
|
413
|
+
|
|
414
|
+
// 4. Idempotent: the next auto-recover sync re-flags NOTHING (the orphans
|
|
415
|
+
// were tombstoned, so the wedge does not recur).
|
|
416
|
+
const second = await sync({
|
|
417
|
+
company: "acme",
|
|
418
|
+
vaultConfig: mockConfig,
|
|
419
|
+
hqRoot: tmpDir,
|
|
420
|
+
syncMode: "shared",
|
|
421
|
+
prefixSet: ["projects/"],
|
|
422
|
+
scopeShrinkPolicy: "auto-recover",
|
|
423
|
+
});
|
|
424
|
+
expect(second.scopeOrphansRemoved).toBe(0);
|
|
425
|
+
expect(second.scopeOrphansBlocked).toBe(0);
|
|
426
|
+
});
|
|
343
427
|
});
|
package/src/cli/sync.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
applyScopeShrink,
|
|
41
41
|
ScopeShrinkBlockedError,
|
|
42
42
|
ScopeShrinkLargePruneError,
|
|
43
|
+
type ScopeShrinkAdviceContext,
|
|
43
44
|
} from "../scope-shrink.js";
|
|
44
45
|
import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
|
|
45
46
|
import { createIgnoreFilter } from "../ignore.js";
|
|
@@ -355,6 +356,25 @@ export interface SyncOptions {
|
|
|
355
356
|
* tombstoned. Mirrors `hq sync narrow --force`.
|
|
356
357
|
*/
|
|
357
358
|
forceScopeShrink?: boolean;
|
|
359
|
+
/**
|
|
360
|
+
* How `sync()` handles a scope shrink (US-005 / DEV-1768):
|
|
361
|
+
*
|
|
362
|
+
* - `"block"` (default) — a human is present (foreground `hq sync`). Dirty
|
|
363
|
+
* out-of-scope orphans, or a clean prune over the safety cap, raise a
|
|
364
|
+
* structured error whose advice is followable from a terminal. Clean
|
|
365
|
+
* orphans within the cap are QUARANTINED (moved, not deleted).
|
|
366
|
+
* - `"auto-recover"` — the background menubar runner, which can take no
|
|
367
|
+
* interactive flag. NEVER throws on a shrink: dirty orphans are kept on
|
|
368
|
+
* disk + un-tracked, clean orphans are quarantined, and the bulk-prune
|
|
369
|
+
* cap is bypassed (quarantine is non-destructive). This is what clears an
|
|
370
|
+
* already-wedged journal on the next sync, idempotently and without data
|
|
371
|
+
* loss — the recovery seam for the all→shared seed bug.
|
|
372
|
+
*
|
|
373
|
+
* Both policies are non-destructive for CLEAN files (quarantine, never
|
|
374
|
+
* silent delete) — the deliberate `hq sync narrow --apply` ritual is the only
|
|
375
|
+
* path that hard-deletes, and it confirms first.
|
|
376
|
+
*/
|
|
377
|
+
scopeShrinkPolicy?: "block" | "auto-recover";
|
|
358
378
|
/**
|
|
359
379
|
* The caller's own Cognito `sub`, used by the scope-shrink authorship guard
|
|
360
380
|
* so a scope shrink never prunes content the caller authored. Injected by the
|
|
@@ -655,23 +675,38 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
655
675
|
// explicit `hq sync narrow` ritual opts out of the unknown-author shield.
|
|
656
676
|
protectUnknownAuthors: true,
|
|
657
677
|
});
|
|
658
|
-
|
|
678
|
+
// Policy: the background menubar runner ("auto-recover") can take no
|
|
679
|
+
// interactive flag, so it must never throw on a shrink — it self-heals
|
|
680
|
+
// non-destructively (dirty kept on disk + un-tracked, clean quarantined).
|
|
681
|
+
// A foreground `hq sync` ("block", the default) keeps the protective gate
|
|
682
|
+
// but renders FOLLOWABLE advice. `autoRecover` implies force (proceed) and
|
|
683
|
+
// bypasses the bulk-prune cap (quarantine is non-destructive, so a large
|
|
684
|
+
// recovery move is safe). DEV-1768.
|
|
685
|
+
const scopeShrinkPolicy = options.scopeShrinkPolicy ?? "block";
|
|
686
|
+
const autoRecover = scopeShrinkPolicy === "auto-recover";
|
|
687
|
+
const adviceContext: ScopeShrinkAdviceContext = autoRecover ? "runner" : "cli";
|
|
688
|
+
const effectiveForce = options.forceScopeShrink === true || autoRecover;
|
|
689
|
+
|
|
690
|
+
if (shrinkPlan.dirty.length > 0 && !effectiveForce) {
|
|
659
691
|
throw new ScopeShrinkBlockedError(
|
|
660
692
|
ctx.uid,
|
|
661
693
|
lastRecord?.syncMode ?? "unknown",
|
|
662
694
|
syncMode,
|
|
663
695
|
shrinkPlan.dirty,
|
|
664
696
|
shrinkPlan.clean,
|
|
697
|
+
adviceContext,
|
|
665
698
|
);
|
|
666
699
|
}
|
|
667
|
-
// Bulk
|
|
668
|
-
//
|
|
669
|
-
// `hq sync narrow --apply` (its own confirmation)
|
|
670
|
-
//
|
|
671
|
-
//
|
|
700
|
+
// Bulk guard: refuse to auto-move more than the safety cap of CLEAN files in
|
|
701
|
+
// a single foreground sync. A deliberate large narrow goes through
|
|
702
|
+
// `hq sync narrow --apply` (its own confirmation); `--force-scope-shrink` (or
|
|
703
|
+
// raising HQ_SYNC_MAX_AUTO_PRUNE) overrides. Cap of 0 = unlimited. Skipped
|
|
704
|
+
// under auto-recover — quarantine is non-destructive so a big recovery is
|
|
705
|
+
// safe, and the runner has no way to act on a thrown cap. The engine moves
|
|
706
|
+
// nothing when it throws here.
|
|
672
707
|
const autoPruneCap = resolveAutoPruneCap();
|
|
673
708
|
if (
|
|
674
|
-
|
|
709
|
+
!effectiveForce &&
|
|
675
710
|
autoPruneCap > 0 &&
|
|
676
711
|
shrinkPlan.clean.length > autoPruneCap
|
|
677
712
|
) {
|
|
@@ -680,27 +715,65 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
680
715
|
syncMode,
|
|
681
716
|
shrinkPlan.clean.length,
|
|
682
717
|
autoPruneCap,
|
|
718
|
+
adviceContext,
|
|
683
719
|
);
|
|
684
720
|
}
|
|
721
|
+
// Clean orphans are QUARANTINED (moved into `.hq/scope-quarantine/<slug>/`,
|
|
722
|
+
// recoverable), never silently deleted — a background sync purging local
|
|
723
|
+
// files unannounced was DEV-1768 fix #3. The quarantine root lives under the
|
|
724
|
+
// real HQ root's `.hq/` (outside `companyRoot` and never pushed), so moved
|
|
725
|
+
// files don't round-trip back through S3.
|
|
726
|
+
const scopeQuarantineRoot = path.join(
|
|
727
|
+
hqRoot,
|
|
728
|
+
".hq",
|
|
729
|
+
"scope-quarantine",
|
|
730
|
+
journalSlug,
|
|
731
|
+
);
|
|
685
732
|
const shrinkResult = applyScopeShrink({
|
|
686
733
|
journal,
|
|
687
734
|
plan: shrinkPlan,
|
|
688
735
|
hqRoot: companyRoot,
|
|
689
|
-
forceScopeShrink:
|
|
736
|
+
forceScopeShrink: effectiveForce,
|
|
690
737
|
reason: "scope_shrink",
|
|
738
|
+
cleanDisposition: "quarantine",
|
|
739
|
+
quarantineRoot: scopeQuarantineRoot,
|
|
691
740
|
});
|
|
692
|
-
// Surface each
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
741
|
+
// Surface each affected orphan explicitly (named path) so the prune is never
|
|
742
|
+
// silent. Quarantined clean files render as `deleted: true` (removed from the
|
|
743
|
+
// working tree, recoverable in quarantine); dirty files KEPT on disk render
|
|
744
|
+
// as a non-deletion notice so the operator knows they were un-tracked, not
|
|
745
|
+
// removed. The Rust menubar parser already handles `deleted: true`.
|
|
746
|
+
for (const relPath of shrinkResult.quarantinedPaths) {
|
|
747
|
+
emit({
|
|
748
|
+
type: "progress",
|
|
749
|
+
path: relPath,
|
|
750
|
+
bytes: 0,
|
|
751
|
+
deleted: true,
|
|
752
|
+
message: `scope-narrowed: moved out-of-scope copy to ${scopeQuarantineRoot}`,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
for (const relPath of shrinkResult.removedPaths) {
|
|
696
756
|
emit({
|
|
697
757
|
type: "progress",
|
|
698
|
-
path:
|
|
758
|
+
path: relPath,
|
|
699
759
|
bytes: 0,
|
|
700
760
|
deleted: true,
|
|
701
761
|
message: "scope-narrowed (removed local copy outside sync scope)",
|
|
702
762
|
});
|
|
703
763
|
}
|
|
764
|
+
for (const relPath of shrinkResult.dirtyKeptPaths) {
|
|
765
|
+
emit({
|
|
766
|
+
type: "progress",
|
|
767
|
+
path: relPath,
|
|
768
|
+
bytes: 0,
|
|
769
|
+
message:
|
|
770
|
+
"scope-narrowed: locally-modified file KEPT on disk, un-tracked from sync (outside scope)",
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
// "Removed from the working tree" = deleted OR quarantined; both vacate the
|
|
774
|
+
// file's original path. Reported as `scopeOrphansRemoved` for back-compat.
|
|
775
|
+
const scopeOrphansRemoved =
|
|
776
|
+
shrinkResult.cleanRemoved + shrinkResult.cleanQuarantined;
|
|
704
777
|
|
|
705
778
|
// Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
|
|
706
779
|
// inline loop; the only structural change is that classification has
|
|
@@ -949,7 +1022,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
949
1022
|
// a conflict abort. `filesOutOfScope` reflects how far the serial
|
|
950
1023
|
// pass got before the abort; that's acceptable for an abort result.
|
|
951
1024
|
filesOutOfScope,
|
|
952
|
-
scopeOrphansRemoved
|
|
1025
|
+
scopeOrphansRemoved,
|
|
953
1026
|
scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
|
|
954
1027
|
};
|
|
955
1028
|
break;
|
|
@@ -1328,7 +1401,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1328
1401
|
syncMode,
|
|
1329
1402
|
prefixSet: currentPrefixSet,
|
|
1330
1403
|
scopeChangeDetected: shrinkPlan.scopeChangeDetected,
|
|
1331
|
-
orphansRemoved:
|
|
1404
|
+
orphansRemoved: scopeOrphansRemoved,
|
|
1332
1405
|
orphansBlocked: shrinkResult.dirtyTombstoned,
|
|
1333
1406
|
});
|
|
1334
1407
|
|
|
@@ -1347,7 +1420,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1347
1420
|
const changedOnDisk =
|
|
1348
1421
|
filesDownloaded > 0 ||
|
|
1349
1422
|
filesTombstoned > 0 ||
|
|
1350
|
-
|
|
1423
|
+
scopeOrphansRemoved > 0;
|
|
1351
1424
|
if (!options.skipReindex && changedOnDisk) {
|
|
1352
1425
|
try {
|
|
1353
1426
|
// skipLock: the surrounding sync run already holds this root's operation
|
|
@@ -1370,7 +1443,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
1370
1443
|
filesExcludedByPolicy: plan.filesExcludedByPolicy,
|
|
1371
1444
|
filesTombstoned,
|
|
1372
1445
|
filesOutOfScope,
|
|
1373
|
-
scopeOrphansRemoved
|
|
1446
|
+
scopeOrphansRemoved,
|
|
1374
1447
|
scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
|
|
1375
1448
|
};
|
|
1376
1449
|
}
|
package/src/cognito-auth.ts
CHANGED
|
@@ -147,6 +147,157 @@ export function decodeAccessTokenClientId(accessToken: string): string | null {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Machine identity (company agents)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
//
|
|
154
|
+
// HQ company agents run headless on their own boxes with long-lived Cognito
|
|
155
|
+
// MACHINE credentials ({username: "machine-agt_<ulid>", secret}) provisioned
|
|
156
|
+
// by hq-pro's agent bootstrap and stored at ~/.hq-agent/machine-creds.json.
|
|
157
|
+
// There is no browser, no Hosted UI, and no refresh-token dance: the creds
|
|
158
|
+
// never expire, so the CLI simply re-mints a session via USER_PASSWORD_AUTH
|
|
159
|
+
// whenever the cached tokens are missing or expiring.
|
|
160
|
+
//
|
|
161
|
+
// Token semantics matter here. The agent's identity claims
|
|
162
|
+
// (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token only;
|
|
163
|
+
// APIs that verify token_use=access (e.g. hq-deploy) need the real access
|
|
164
|
+
// token. Both are cached with correct field semantics — callers pick the
|
|
165
|
+
// token type each API actually validates.
|
|
166
|
+
|
|
167
|
+
export interface MachineCreds {
|
|
168
|
+
/** Cognito username, always "machine-agt_<ulid>". */
|
|
169
|
+
username: string;
|
|
170
|
+
/** Long-lived machine secret (USER_PASSWORD_AUTH password). */
|
|
171
|
+
secret: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Resolve the machine-creds file path (HQ_MACHINE_CREDS_FILE overrides). */
|
|
175
|
+
export function machineCredsFilePath(): string {
|
|
176
|
+
return (
|
|
177
|
+
process.env.HQ_MACHINE_CREDS_FILE ??
|
|
178
|
+
path.join(os.homedir(), ".hq-agent", "machine-creds.json")
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Load machine credentials, or null when this process is not running as a
|
|
184
|
+
* machine identity (no creds file / unreadable / malformed).
|
|
185
|
+
*/
|
|
186
|
+
export function loadMachineCreds(): MachineCreds | null {
|
|
187
|
+
const file = machineCredsFilePath();
|
|
188
|
+
try {
|
|
189
|
+
if (!fs.existsSync(file)) return null;
|
|
190
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as {
|
|
191
|
+
username?: unknown;
|
|
192
|
+
secret?: unknown;
|
|
193
|
+
};
|
|
194
|
+
if (
|
|
195
|
+
typeof raw.username === "string" &&
|
|
196
|
+
raw.username.startsWith("machine-") &&
|
|
197
|
+
typeof raw.secret === "string" &&
|
|
198
|
+
raw.secret.length > 0
|
|
199
|
+
) {
|
|
200
|
+
return { username: raw.username, secret: raw.secret };
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** True when machine credentials are present — the CLI is a machine identity. */
|
|
209
|
+
export function isMachineIdentity(): boolean {
|
|
210
|
+
return loadMachineCreds() !== null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface InitiateAuthResponse {
|
|
214
|
+
AuthenticationResult?: {
|
|
215
|
+
AccessToken?: string;
|
|
216
|
+
IdToken?: string;
|
|
217
|
+
RefreshToken?: string;
|
|
218
|
+
ExpiresIn?: number;
|
|
219
|
+
};
|
|
220
|
+
ChallengeName?: string;
|
|
221
|
+
__type?: string;
|
|
222
|
+
message?: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mint a fresh session for the machine identity via USER_PASSWORD_AUTH
|
|
227
|
+
* against the Cognito IDP endpoint (plain unsigned HTTP — no AWS SDK
|
|
228
|
+
* dependency). Caches BOTH tokens with correct field semantics and returns
|
|
229
|
+
* them.
|
|
230
|
+
*/
|
|
231
|
+
export async function mintMachineTokens(
|
|
232
|
+
config: CognitoAuthConfig,
|
|
233
|
+
creds?: MachineCreds,
|
|
234
|
+
): Promise<CognitoTokens> {
|
|
235
|
+
const machineCreds = creds ?? loadMachineCreds();
|
|
236
|
+
if (!machineCreds) {
|
|
237
|
+
throw new CognitoAuthError(
|
|
238
|
+
`No machine credentials found at ${machineCredsFilePath()}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const res = await fetch(
|
|
242
|
+
`https://cognito-idp.${config.region}.amazonaws.com/`,
|
|
243
|
+
{
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
247
|
+
"X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
|
|
248
|
+
},
|
|
249
|
+
body: JSON.stringify({
|
|
250
|
+
AuthFlow: "USER_PASSWORD_AUTH",
|
|
251
|
+
ClientId: config.clientId,
|
|
252
|
+
AuthParameters: {
|
|
253
|
+
USERNAME: machineCreds.username,
|
|
254
|
+
PASSWORD: machineCreds.secret,
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
const data = (await res.json().catch(() => ({}))) as InitiateAuthResponse;
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
throw new CognitoAuthError(
|
|
262
|
+
`Machine token mint failed (${res.status}): ${data.__type ?? ""} ${data.message ?? ""}`.trim(),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const result = data.AuthenticationResult;
|
|
266
|
+
if (!result?.AccessToken || !result?.IdToken) {
|
|
267
|
+
throw new CognitoAuthError(
|
|
268
|
+
`Machine token mint returned no tokens${data.ChallengeName ? ` (challenge: ${data.ChallengeName})` : ""}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const tokens: CognitoTokens = {
|
|
272
|
+
accessToken: result.AccessToken,
|
|
273
|
+
idToken: result.IdToken,
|
|
274
|
+
// Machine creds never expire — expiry is handled by re-minting, so the
|
|
275
|
+
// refresh token (when Cognito returns one at all) is never exercised.
|
|
276
|
+
refreshToken: result.RefreshToken ?? "",
|
|
277
|
+
expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
|
|
278
|
+
tokenType: "Bearer",
|
|
279
|
+
};
|
|
280
|
+
saveCachedTokens(tokens);
|
|
281
|
+
return tokens;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Return a valid (non-expiring) machine session, re-minting on demand.
|
|
286
|
+
* Cache-hit path never touches the network.
|
|
287
|
+
*/
|
|
288
|
+
export async function getValidMachineTokens(
|
|
289
|
+
config: CognitoAuthConfig,
|
|
290
|
+
): Promise<CognitoTokens> {
|
|
291
|
+
const cached = loadCachedTokens();
|
|
292
|
+
if (cached && !isExpiring(cached, 120)) {
|
|
293
|
+
const cachedClientId = decodeAccessTokenClientId(cached.accessToken);
|
|
294
|
+
if (cachedClientId === null || cachedClientId === config.clientId) {
|
|
295
|
+
return cached;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return mintMachineTokens(config);
|
|
299
|
+
}
|
|
300
|
+
|
|
150
301
|
// ---------------------------------------------------------------------------
|
|
151
302
|
// PKCE
|
|
152
303
|
// ---------------------------------------------------------------------------
|
|
@@ -402,6 +553,14 @@ export async function getValidAccessToken(
|
|
|
402
553
|
options: { interactive?: boolean } = {},
|
|
403
554
|
): Promise<string> {
|
|
404
555
|
const interactive = options.interactive ?? true;
|
|
556
|
+
|
|
557
|
+
// Machine identities (company agents) never refresh or open a browser —
|
|
558
|
+
// they re-mint via USER_PASSWORD_AUTH on demand.
|
|
559
|
+
if (isMachineIdentity()) {
|
|
560
|
+
const machine = await getValidMachineTokens(config);
|
|
561
|
+
return machine.accessToken;
|
|
562
|
+
}
|
|
563
|
+
|
|
405
564
|
let cached = loadCachedTokens();
|
|
406
565
|
|
|
407
566
|
// Stale-pool detection: if the cached access token was issued by a
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ export {
|
|
|
69
69
|
buildScopeShrinkPlan,
|
|
70
70
|
applyScopeShrink,
|
|
71
71
|
ScopeShrinkBlockedError,
|
|
72
|
+
ScopeShrinkLargePruneError,
|
|
72
73
|
} from "./scope-shrink.js";
|
|
73
74
|
export type {
|
|
74
75
|
OrphanClassification,
|
|
@@ -76,6 +77,8 @@ export type {
|
|
|
76
77
|
BuildScopeShrinkPlanInput,
|
|
77
78
|
ApplyScopeShrinkInput,
|
|
78
79
|
ApplyScopeShrinkResult,
|
|
80
|
+
ScopeShrinkAdviceContext,
|
|
81
|
+
CleanOrphanDisposition,
|
|
79
82
|
} from "./scope-shrink.js";
|
|
80
83
|
|
|
81
84
|
// Engine-layer ACL-aware pull orchestration (US-005)
|
|
@@ -115,8 +118,25 @@ export {
|
|
|
115
118
|
isExpiring,
|
|
116
119
|
getValidAccessToken,
|
|
117
120
|
CognitoAuthError,
|
|
121
|
+
machineCredsFilePath,
|
|
122
|
+
loadMachineCreds,
|
|
123
|
+
isMachineIdentity,
|
|
124
|
+
mintMachineTokens,
|
|
125
|
+
getValidMachineTokens,
|
|
118
126
|
} from "./cognito-auth.js";
|
|
119
|
-
export type {
|
|
127
|
+
export type {
|
|
128
|
+
CognitoAuthConfig,
|
|
129
|
+
CognitoTokens,
|
|
130
|
+
MachineCreds,
|
|
131
|
+
} from "./cognito-auth.js";
|
|
132
|
+
|
|
133
|
+
// Per-company PULL scope resolver (US-005) — shared between hq-sync-runner and
|
|
134
|
+
// `hq sync pull|now` (hq-cli). Exported so hq-cli's foreground pull paths resolve
|
|
135
|
+
// the SAME effective scope the menubar runner does, instead of defaulting every
|
|
136
|
+
// CLI pull to `syncMode: "all"` (the seed of the all→shared scope-shrink wedge,
|
|
137
|
+
// DEV-1768).
|
|
138
|
+
export { resolvePullScope, readPinnedPrefixes } from "./sync/pull-scope.js";
|
|
139
|
+
export type { PullScope, PullScopeClient } from "./sync/pull-scope.js";
|
|
120
140
|
|
|
121
141
|
// Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
|
|
122
142
|
export {
|