@indigoai-us/hq-cloud 6.2.4 → 6.2.6
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 +5 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +37 -8
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +33 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +36 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +131 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +99 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +20 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +32 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cli/watch-event-push-conflict.test.d.ts +25 -0
- package/dist/cli/watch-event-push-conflict.test.d.ts.map +1 -0
- package/dist/cli/watch-event-push-conflict.test.js +210 -0
- package/dist/cli/watch-event-push-conflict.test.js.map +1 -0
- package/dist/prefix-coalesce.d.ts +22 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +35 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +39 -0
- package/src/bin/sync-runner.ts +44 -14
- package/src/cli/share.test.ts +109 -0
- package/src/cli/share.ts +159 -3
- package/src/cli/sync.test.ts +36 -0
- package/src/cli/sync.ts +44 -1
- package/src/cli/watch-event-push-conflict.test.ts +234 -0
- package/src/prefix-coalesce.ts +35 -0
package/src/cli/share.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { resolveConflict } from "./conflict.js";
|
|
42
42
|
import type { ConflictStrategy } from "./conflict.js";
|
|
43
43
|
import type { SyncProgressEvent } from "./sync.js";
|
|
44
|
+
import { isCoveredByAny, isDirInScope } from "../prefix-coalesce.js";
|
|
44
45
|
import {
|
|
45
46
|
buildConflictId,
|
|
46
47
|
buildConflictPath,
|
|
@@ -218,6 +219,21 @@ export function isEphemeralPath(p: string): boolean {
|
|
|
218
219
|
return EPHEMERAL_PATH_PATTERN.test(p);
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A vault key containing a backslash is never legitimate. HQ keys are POSIX
|
|
224
|
+
* (`toPosixKey` normalizes at every walker since 5.47.2 and `uploadFile`
|
|
225
|
+
* hard-normalizes at the S3 boundary), so a `\` in a remote key can only come
|
|
226
|
+
* from a pre-5.47.2 Windows client whose walker built keys with `path.sep` —
|
|
227
|
+
* verified live 2026-06-10: one such client duplicated 5,711 keys
|
|
228
|
+
* (`skills\demo-hq\SKILL.md`, …) into a company vault, and every up-to-date
|
|
229
|
+
* puller then materialized them as junk single-filename-with-backslash files
|
|
230
|
+
* that churned conflicts forever. The pull walker refuses these keys
|
|
231
|
+
* (skip-excluded-policy), symmetric with the ephemeral-mirror filter above.
|
|
232
|
+
*/
|
|
233
|
+
export function isMalformedVaultKey(key: string): boolean {
|
|
234
|
+
return key.includes("\\");
|
|
235
|
+
}
|
|
236
|
+
|
|
221
237
|
/**
|
|
222
238
|
* Test-only export. Kept under a `_testing` namespace so the module's public
|
|
223
239
|
* surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
|
|
@@ -576,6 +592,20 @@ export interface ShareOptions {
|
|
|
576
592
|
* `sync-journal.personal.json` file.
|
|
577
593
|
*/
|
|
578
594
|
journalSlug?: string;
|
|
595
|
+
/**
|
|
596
|
+
* Effective ACL push scope as a list of company-relative prefixes (the same
|
|
597
|
+
* coalesced `prefixSet` the pull leg uses for `syncMode: "shared"`). When
|
|
598
|
+
* provided, the upload + delete plans are filtered to paths covered by these
|
|
599
|
+
* prefixes — any candidate outside them is skipped (and surfaced via a
|
|
600
|
+
* `scope-excluded` event) rather than PUT, because the vended child
|
|
601
|
+
* credential is scoped to exactly these prefixes and an out-of-scope PUT
|
|
602
|
+
* draws the server's correct 403 `SCOPE_EXCEEDS_PARENT`.
|
|
603
|
+
*
|
|
604
|
+
* `undefined` (the owner/`all` case, and `hq share <file>`) applies NO scope
|
|
605
|
+
* filter — full access. An empty array means "no granted prefixes" → every
|
|
606
|
+
* path is out of scope (mirrors the pull side's `isCoveredByAny([])`).
|
|
607
|
+
*/
|
|
608
|
+
prefixSet?: string[];
|
|
579
609
|
}
|
|
580
610
|
|
|
581
611
|
export interface ShareResult {
|
|
@@ -629,6 +659,16 @@ export interface ShareResult {
|
|
|
629
659
|
* emitted exactly once if this is > 0).
|
|
630
660
|
*/
|
|
631
661
|
filesExcludedByPolicy: number;
|
|
662
|
+
/**
|
|
663
|
+
* Number of distinct company-relative paths/prefixes skipped because they
|
|
664
|
+
* fell OUTSIDE the run's ACL `prefixSet` (member/guest scoped push). Always
|
|
665
|
+
* 0 when `prefixSet` is undefined (owner/`all`) or the whole tree is in
|
|
666
|
+
* scope. Mirrors the `count` field of the `scope-excluded` event (emitted
|
|
667
|
+
* once if this is > 0). These paths were never PUT, so the server's correct
|
|
668
|
+
* 403 `SCOPE_EXCEEDS_PARENT` is never triggered and the company still syncs
|
|
669
|
+
* its in-scope subset.
|
|
670
|
+
*/
|
|
671
|
+
filesExcludedByScope: number;
|
|
632
672
|
/**
|
|
633
673
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
634
674
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -638,6 +678,56 @@ export interface ShareResult {
|
|
|
638
678
|
aborted: boolean;
|
|
639
679
|
}
|
|
640
680
|
|
|
681
|
+
/**
|
|
682
|
+
* Is this error the S3/STS "access denied" class? Expected when a scoped
|
|
683
|
+
* member/guest credential touches a key outside its granted ACL prefixes
|
|
684
|
+
* (the server's `SCOPE_EXCEEDS_PARENT` surfaces as a 403 AccessDenied /
|
|
685
|
+
* Forbidden). Mirrors the pull-side `isAccessDenied` in sync.ts so the push
|
|
686
|
+
* leg can treat a stray out-of-scope key as a skip rather than a fatal throw.
|
|
687
|
+
*/
|
|
688
|
+
function isAccessDenied(err: unknown): boolean {
|
|
689
|
+
if (err && typeof err === "object" && "name" in err) {
|
|
690
|
+
const name = (err as { name?: unknown }).name;
|
|
691
|
+
return name === "AccessDenied" || name === "Forbidden";
|
|
692
|
+
}
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Wrap an existing path filter (ignore filter, optionally already wrapped with
|
|
698
|
+
* the personal-vault defaults) so that paths OUTSIDE the run's ACL `prefixSet`
|
|
699
|
+
* are rejected before they enter the upload OR delete plan. Keeps the
|
|
700
|
+
* `(absolutePath, isDir) => boolean` shape the rest of share() already uses
|
|
701
|
+
* (collectFiles / walkDir / computeDeletePlan), so wiring is one line at the
|
|
702
|
+
* filter-construction site — exactly like `wrapFilterWithPersonalVaultDefaults`.
|
|
703
|
+
*
|
|
704
|
+
* Files use `isCoveredByAny` (plain `startsWith`); directories use
|
|
705
|
+
* `isDirInScope` (descend if the dir is inside a grant OR a grant is inside
|
|
706
|
+
* the dir) so the walk still reaches deep/exact-file grants. Rejected entries
|
|
707
|
+
* are tagged via `onScopeExcluded` (directories with a trailing slash) so the
|
|
708
|
+
* runner can emit a single `scope-excluded` summary naming what was skipped.
|
|
709
|
+
*/
|
|
710
|
+
function wrapFilterWithScope(
|
|
711
|
+
underlying: (absPath: string, isDir?: boolean) => boolean,
|
|
712
|
+
syncRoot: string,
|
|
713
|
+
prefixSet: readonly string[],
|
|
714
|
+
onScopeExcluded: (rel: string) => void,
|
|
715
|
+
): (absPath: string, isDir?: boolean) => boolean {
|
|
716
|
+
return (absPath: string, isDir?: boolean) => {
|
|
717
|
+
if (!underlying(absPath, isDir)) return false;
|
|
718
|
+
const rel = path.relative(syncRoot, absPath).split(path.sep).join("/");
|
|
719
|
+
if (rel === "" || rel.startsWith("..")) return true; // root / outside — defer
|
|
720
|
+
if (isDir) {
|
|
721
|
+
if (isDirInScope(rel, prefixSet)) return true;
|
|
722
|
+
onScopeExcluded(rel.endsWith("/") ? rel : rel + "/");
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
if (isCoveredByAny(rel, prefixSet)) return true;
|
|
726
|
+
onScopeExcluded(rel);
|
|
727
|
+
return false;
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
641
731
|
/**
|
|
642
732
|
* Share local file(s) to the entity vault.
|
|
643
733
|
*/
|
|
@@ -753,9 +843,22 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
753
843
|
excludedSet.add(rel);
|
|
754
844
|
excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
|
|
755
845
|
};
|
|
756
|
-
|
|
846
|
+
// ACL scope filter (member/guest scoped push). The vended child credential
|
|
847
|
+
// is scoped to `options.prefixSet`; any candidate outside those prefixes
|
|
848
|
+
// would draw the server's correct 403 SCOPE_EXCEEDS_PARENT on PUT and abort
|
|
849
|
+
// the whole company. Pre-filter the plan to the granted subset instead —
|
|
850
|
+
// the push-side analogue of the pull leg's `skip-out-of-scope` (US-005).
|
|
851
|
+
// `undefined` = owner/`all` → no scope filter (full access).
|
|
852
|
+
const scopeExcludedSet = new Set<string>();
|
|
853
|
+
const onScopeExcluded = (rel: string) => {
|
|
854
|
+
scopeExcludedSet.add(rel);
|
|
855
|
+
};
|
|
856
|
+
const baseFilter = options.personalMode === true
|
|
757
857
|
? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
|
|
758
858
|
: ignoreFilter;
|
|
859
|
+
const shouldSync = options.prefixSet !== undefined
|
|
860
|
+
? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
|
|
861
|
+
: baseFilter;
|
|
759
862
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
760
863
|
// Seed the canonical personal-vault journal from the legacy `personal` file
|
|
761
864
|
// exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
|
|
@@ -993,7 +1096,32 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
993
1096
|
// single-part etag) and compare. Match → no conflict, silently skip
|
|
994
1097
|
// the PUT (the bytes are already there). Mismatch → treat as a
|
|
995
1098
|
// conflict in the same shared branch below.
|
|
996
|
-
|
|
1099
|
+
// Defense-in-depth for the scoped-push 403: the `prefixSet` filter above
|
|
1100
|
+
// should already have dropped any out-of-scope key from the plan, but a
|
|
1101
|
+
// grant that changed mid-run, a pinned prefix outside the grant, or
|
|
1102
|
+
// prefix-coalesce imprecision can still leave an out-of-scope key here.
|
|
1103
|
+
// This HEAD sits OUTSIDE the per-file PUT try/catch below, so a thrown
|
|
1104
|
+
// 403 used to bubble to `workerErrors` and abort the ENTIRE company with
|
|
1105
|
+
// a generic message and exit 2. Catch the access-denied class, surface
|
|
1106
|
+
// the offending PATH clearly, and skip just this key — the rest of the
|
|
1107
|
+
// company still syncs. Non-access-denied errors re-throw unchanged.
|
|
1108
|
+
let remoteMeta: Awaited<ReturnType<typeof headRemoteFile>>;
|
|
1109
|
+
try {
|
|
1110
|
+
remoteMeta = await headRemoteFile(ctx, relativePath);
|
|
1111
|
+
} catch (headErr) {
|
|
1112
|
+
if (isAccessDenied(headErr)) {
|
|
1113
|
+
emit({
|
|
1114
|
+
type: "error",
|
|
1115
|
+
path: relativePath,
|
|
1116
|
+
message:
|
|
1117
|
+
"skipped: outside granted ACL scope (server returned 403 " +
|
|
1118
|
+
"SCOPE_EXCEEDS_PARENT / access denied on HEAD). Grant this path " +
|
|
1119
|
+
"to push it, or it stays local-only.",
|
|
1120
|
+
});
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
throw headErr;
|
|
1124
|
+
}
|
|
997
1125
|
if (remoteMeta) {
|
|
998
1126
|
const journalEntry = journal.files[relativePath];
|
|
999
1127
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
@@ -1199,7 +1327,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1199
1327
|
emit({
|
|
1200
1328
|
type: "error",
|
|
1201
1329
|
path: relativePath,
|
|
1202
|
-
message:
|
|
1330
|
+
message: isAccessDenied(err)
|
|
1331
|
+
? "skipped: outside granted ACL scope (server returned 403 " +
|
|
1332
|
+
"SCOPE_EXCEEDS_PARENT / access denied on PUT). Grant this path " +
|
|
1333
|
+
"to push it, or it stays local-only."
|
|
1334
|
+
: err instanceof Error
|
|
1335
|
+
? err.message
|
|
1336
|
+
: String(err),
|
|
1203
1337
|
});
|
|
1204
1338
|
}
|
|
1205
1339
|
};
|
|
@@ -1269,6 +1403,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1269
1403
|
// abort (matches the existing convention: abort short-circuits
|
|
1270
1404
|
// before the end-of-run telemetry emits).
|
|
1271
1405
|
filesExcludedByPolicy: excludedSet.size,
|
|
1406
|
+
// Scope exclusions are likewise computed during the upload walk, so the
|
|
1407
|
+
// count is meaningful on the abort path too.
|
|
1408
|
+
filesExcludedByScope: scopeExcludedSet.size,
|
|
1272
1409
|
// Use the snapshot of conflictPaths taken at the moment the abort
|
|
1273
1410
|
// fired — additional in-flight items may have appended to the
|
|
1274
1411
|
// shared array after the abort signal, and those should not show
|
|
@@ -1388,6 +1525,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1388
1525
|
});
|
|
1389
1526
|
}
|
|
1390
1527
|
|
|
1528
|
+
// ACL scope-exclusion summary. Emit at most once when one or more candidate
|
|
1529
|
+
// paths fell outside the run's granted `prefixSet` and were skipped from the
|
|
1530
|
+
// upload/delete plan. Informational (NOT an error): the company still syncs
|
|
1531
|
+
// its in-scope subset and the run exits 0. Sample capped at 10 (insertion
|
|
1532
|
+
// order = walk order, deterministic).
|
|
1533
|
+
if (scopeExcludedSet.size > 0) {
|
|
1534
|
+
const samplePaths: string[] = [];
|
|
1535
|
+
for (const p of scopeExcludedSet) {
|
|
1536
|
+
samplePaths.push(p);
|
|
1537
|
+
if (samplePaths.length >= 10) break;
|
|
1538
|
+
}
|
|
1539
|
+
emit({
|
|
1540
|
+
type: "scope-excluded",
|
|
1541
|
+
count: scopeExcludedSet.size,
|
|
1542
|
+
samplePaths,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1391
1546
|
// Codex P1 (5.36.x): if any worker rejected (unhandled error in
|
|
1392
1547
|
// headRemoteFile / refreshEntityContext / resolveConflict — paths
|
|
1393
1548
|
// outside the per-item PUT try/catch), we deliberately let the pool
|
|
@@ -1414,6 +1569,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1414
1569
|
filesRefusedStale,
|
|
1415
1570
|
filesRefusedStalePaths,
|
|
1416
1571
|
filesExcludedByPolicy: excludedSet.size,
|
|
1572
|
+
filesExcludedByScope: scopeExcludedSet.size,
|
|
1417
1573
|
conflictPaths,
|
|
1418
1574
|
aborted: false,
|
|
1419
1575
|
};
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -1100,6 +1100,42 @@ describe("sync", () => {
|
|
|
1100
1100
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1101
1101
|
});
|
|
1102
1102
|
|
|
1103
|
+
it("skips remote keys containing backslashes (malformed Windows-client keys)", async () => {
|
|
1104
|
+
// A pre-5.47.2 Windows client built S3 keys with path.sep, duplicating a
|
|
1105
|
+
// company tree under keys like `skills\\demo-hq\\SKILL.md` (verified live
|
|
1106
|
+
// 2026-06-10: 5,711 such keys in one vault). On POSIX, downloading one
|
|
1107
|
+
// creates a junk FILE whose name contains backslashes, which then churns
|
|
1108
|
+
// conflict mirrors on every sync. The pull planner must refuse them at
|
|
1109
|
+
// planning time — same policy bucket as the ephemeral-mirror filter.
|
|
1110
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1111
|
+
// Malformed Windows key — must be filtered, never downloaded.
|
|
1112
|
+
{
|
|
1113
|
+
key: "skills\\demo\\SKILL.md",
|
|
1114
|
+
size: 44,
|
|
1115
|
+
lastModified: new Date(),
|
|
1116
|
+
etag: '"abc"',
|
|
1117
|
+
},
|
|
1118
|
+
// Its legitimate POSIX twin — must still download.
|
|
1119
|
+
{ key: "skills/demo/SKILL.md", size: 30, lastModified: new Date(), etag: '"def"' },
|
|
1120
|
+
]);
|
|
1121
|
+
|
|
1122
|
+
const result = await sync({
|
|
1123
|
+
company: "acme",
|
|
1124
|
+
vaultConfig: mockConfig,
|
|
1125
|
+
hqRoot: tmpDir,
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1129
|
+
// The malformed key MUST NOT be materialized — neither as a literal
|
|
1130
|
+
// backslash-named file nor as a nested path.
|
|
1131
|
+
expect(fs.existsSync(path.join(companyRoot, "skills\\demo\\SKILL.md"))).toBe(false);
|
|
1132
|
+
// The legitimate twin MUST download.
|
|
1133
|
+
expect(fs.existsSync(path.join(companyRoot, "skills", "demo", "SKILL.md"))).toBe(true);
|
|
1134
|
+
|
|
1135
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1136
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1103
1139
|
it("overwrites local on --on-conflict overwrite", async () => {
|
|
1104
1140
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
1105
1141
|
fs.mkdirSync(companyDocs, { recursive: true });
|
package/src/cli/sync.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
} from "../scope-shrink.js";
|
|
43
43
|
import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
|
|
44
44
|
import { createIgnoreFilter } from "../ignore.js";
|
|
45
|
-
import { isEphemeralPath } from "./share.js";
|
|
45
|
+
import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
|
|
46
46
|
import { resolveConflict } from "./conflict.js";
|
|
47
47
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
48
48
|
import {
|
|
@@ -241,6 +241,28 @@ export type SyncProgressEvent =
|
|
|
241
241
|
count: number;
|
|
242
242
|
samplePaths: string[];
|
|
243
243
|
byId: Record<string, number>;
|
|
244
|
+
}
|
|
245
|
+
| {
|
|
246
|
+
/**
|
|
247
|
+
* Emitted at most ONCE per `share()` call (push leg) when the run is
|
|
248
|
+
* scoped to a member/guest's ACL prefixes (`prefixSet`) and one or more
|
|
249
|
+
* candidate paths fell OUTSIDE the granted prefixes. Those paths are
|
|
250
|
+
* skipped from the upload (and delete) plan instead of being PUT — the
|
|
251
|
+
* vended child credential is scoped to the granted prefixes, so pushing
|
|
252
|
+
* them would draw the server's correct 403 `SCOPE_EXCEEDS_PARENT` and
|
|
253
|
+
* (pre-fix) abort the WHOLE company. This is the push-side analogue of
|
|
254
|
+
* the pull-side `skip-out-of-scope` action.
|
|
255
|
+
*
|
|
256
|
+
* `count` is the number of distinct company-relative paths/prefixes the
|
|
257
|
+
* scope filter excluded; `samplePaths` carries up to 10 for diagnostic
|
|
258
|
+
* display. Informational, NOT an error — the company still syncs its
|
|
259
|
+
* in-scope subset and the run exits 0.
|
|
260
|
+
*
|
|
261
|
+
* Not emitted when `count === 0` — silent on a fully in-scope tree.
|
|
262
|
+
*/
|
|
263
|
+
type: "scope-excluded";
|
|
264
|
+
count: number;
|
|
265
|
+
samplePaths: string[];
|
|
244
266
|
};
|
|
245
267
|
|
|
246
268
|
export interface SyncOptions {
|
|
@@ -1477,6 +1499,17 @@ function computePullPlan(
|
|
|
1477
1499
|
continue;
|
|
1478
1500
|
}
|
|
1479
1501
|
|
|
1502
|
+
// Malformed-key filter — keys with backslash separators pushed by
|
|
1503
|
+
// pre-5.47.2 Windows clients. Downloading one materializes a junk local
|
|
1504
|
+
// file whose NAME contains backslashes (it is not a path on POSIX), which
|
|
1505
|
+
// then churns conflict mirrors forever. Refuse at planning time, same
|
|
1506
|
+
// policy bucket as the ephemeral filter above. The bogus keys themselves
|
|
1507
|
+
// are cleaned server-side; this keeps clean trees clean in the meantime.
|
|
1508
|
+
if (isMalformedVaultKey(remoteFile.key)) {
|
|
1509
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1480
1513
|
if (personalMode && remoteFile.key.startsWith("companies/")) {
|
|
1481
1514
|
// Default: drop every `companies/...` key — the legacy contract
|
|
1482
1515
|
// is that the personal bucket should never contain them.
|
|
@@ -1866,5 +1899,15 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
1866
1899
|
console.log(` ... and ${event.files.length - MAX_SHOWN} more`);
|
|
1867
1900
|
}
|
|
1868
1901
|
}
|
|
1902
|
+
} else if (event.type === "scope-excluded") {
|
|
1903
|
+
console.log(
|
|
1904
|
+
` ~ ${event.count} path${event.count === 1 ? "" : "s"} skipped — outside your granted access (synced the in-scope subset):`,
|
|
1905
|
+
);
|
|
1906
|
+
for (const p of event.samplePaths) {
|
|
1907
|
+
console.log(` · ${p}`);
|
|
1908
|
+
}
|
|
1909
|
+
if (event.count > event.samplePaths.length) {
|
|
1910
|
+
console.log(` ... and ${event.count - event.samplePaths.length} more`);
|
|
1911
|
+
}
|
|
1869
1912
|
}
|
|
1870
1913
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard: the watch + event-push path must NOT mint phantom
|
|
3
|
+
* `.conflict-*` mirrors for an ordinary single-writer local edit.
|
|
4
|
+
*
|
|
5
|
+
* Background (feedback_ef2b7c8c): a user editing files under
|
|
6
|
+
* `companies/{slug}/` saw the menubar's watch/event-push runner mint a
|
|
7
|
+
* `.conflict-<ts>-<machine>` mirror on EVERY ordinary local Edit/Write —
|
|
8
|
+
* thousands of phantom files in a single session. The watch/event-push runner
|
|
9
|
+
* reacts to a local change by running a targeted `--direction push` pass, i.e.
|
|
10
|
+
* `share()` over the changed company subtree, so the conflict-minting logic is
|
|
11
|
+
* share()'s push-side conflict gate. The original gate flagged a conflict on
|
|
12
|
+
* `localChanged` alone (`journalEntry.hash !== localHash`), which fires for
|
|
13
|
+
* every edit of any already-synced file. The gate now requires
|
|
14
|
+
* `(localChanged && remoteChanged) || isFreshCollision`: a single writer never
|
|
15
|
+
* has `remoteChanged` (the live S3 ETag still equals the journal's recorded
|
|
16
|
+
* baseline), so an ordinary edit is uploaded, not mirrored.
|
|
17
|
+
*
|
|
18
|
+
* This test pins that contract end-to-end through `share()` using the same
|
|
19
|
+
* mocked-S3 harness as `share.test.ts`, modelling a single-writer remote whose
|
|
20
|
+
* ETag is always exactly what THIS device last uploaded. A positive control
|
|
21
|
+
* (a genuine out-of-band peer write) proves the guard is not trivially
|
|
22
|
+
* always-zero: real divergence is still detected.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
26
|
+
import * as fs from "fs";
|
|
27
|
+
import * as path from "path";
|
|
28
|
+
import * as os from "os";
|
|
29
|
+
import { clearContextCache } from "../context.js";
|
|
30
|
+
import type { VaultServiceConfig } from "../types.js";
|
|
31
|
+
|
|
32
|
+
vi.mock("../s3.js", () => ({
|
|
33
|
+
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
34
|
+
uploadFile: vi.fn(),
|
|
35
|
+
uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
|
|
36
|
+
downloadFile: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
listRemoteFiles: vi.fn().mockResolvedValue([]),
|
|
38
|
+
deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
headRemoteFile: vi.fn(),
|
|
40
|
+
primeObjectTransport: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
primeUploads: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("readline", () => ({
|
|
45
|
+
createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import { share } from "./share.js";
|
|
49
|
+
import { downloadFile, headRemoteFile, uploadFile } from "../s3.js";
|
|
50
|
+
|
|
51
|
+
const mockConfig: VaultServiceConfig = {
|
|
52
|
+
apiUrl: "https://vault-api.test",
|
|
53
|
+
authToken: "test-jwt-token",
|
|
54
|
+
region: "us-east-1",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const mockEntity = {
|
|
58
|
+
uid: "cmp_01ABCDEF",
|
|
59
|
+
slug: "acme",
|
|
60
|
+
bucketName: "hq-vault-acme-123",
|
|
61
|
+
status: "active",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function setupFetchMock() {
|
|
65
|
+
vi.stubGlobal(
|
|
66
|
+
"fetch",
|
|
67
|
+
vi.fn().mockImplementation(async (url: string) => {
|
|
68
|
+
const u = String(url);
|
|
69
|
+
if (u.includes("/entity/check-slug/me"))
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
status: 200,
|
|
73
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
74
|
+
text: async () => "",
|
|
75
|
+
};
|
|
76
|
+
if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u))
|
|
77
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
78
|
+
if (u.includes("/sts/vend"))
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
status: 200,
|
|
82
|
+
json: async () => ({
|
|
83
|
+
credentials: {
|
|
84
|
+
accessKeyId: "ASIA_TEST",
|
|
85
|
+
secretAccessKey: "s",
|
|
86
|
+
sessionToken: "t",
|
|
87
|
+
expiration: new Date(Date.now() + 9e5).toISOString(),
|
|
88
|
+
},
|
|
89
|
+
expiresAt: new Date(Date.now() + 9e5).toISOString(),
|
|
90
|
+
}),
|
|
91
|
+
text: async () => "",
|
|
92
|
+
};
|
|
93
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every `.conflict-<ts>-*` mirror file anywhere under `root`. */
|
|
99
|
+
function countConflictMirrors(root: string): string[] {
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
const walk = (d: string) => {
|
|
102
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
103
|
+
const p = path.join(d, e.name);
|
|
104
|
+
if (e.isDirectory()) walk(p);
|
|
105
|
+
else if (/\.conflict-/.test(e.name)) out.push(p);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(root);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* In-memory single-writer S3: the object's ETag is always exactly what THIS
|
|
114
|
+
* device last uploaded (no peer ever writes). `lastModified` advances on each
|
|
115
|
+
* PUT to model S3 stamping it server-side. The S3-module signatures are
|
|
116
|
+
* `uploadFile(ctx, localPath, key)`, `headRemoteFile(ctx, key)`,
|
|
117
|
+
* `downloadFile(ctx, key, dest)` — wire the mocks to match exactly.
|
|
118
|
+
*/
|
|
119
|
+
function wireSingleWriterRemote() {
|
|
120
|
+
const remote = new Map<string, { etag: string; lastModified: Date; size: number }>();
|
|
121
|
+
let clock = Date.now();
|
|
122
|
+
let seq = 0;
|
|
123
|
+
vi.mocked(uploadFile).mockImplementation(async (_ctx: unknown, localPath: string, key: string) => {
|
|
124
|
+
const etag = `"etag-${seq++}"`;
|
|
125
|
+
clock += 1000;
|
|
126
|
+
remote.set(key, { etag, lastModified: new Date(clock), size: fs.statSync(localPath).size });
|
|
127
|
+
return { etag };
|
|
128
|
+
});
|
|
129
|
+
vi.mocked(headRemoteFile).mockImplementation(async (_ctx: unknown, key: string) => {
|
|
130
|
+
const r = remote.get(key);
|
|
131
|
+
return r ? { lastModified: r.lastModified, etag: r.etag, size: r.size } : null;
|
|
132
|
+
});
|
|
133
|
+
vi.mocked(downloadFile).mockImplementation(async (_ctx: unknown, _key: string, dest: string) => {
|
|
134
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
135
|
+
fs.writeFileSync(dest, "remote-bytes\n");
|
|
136
|
+
return {};
|
|
137
|
+
});
|
|
138
|
+
return remote;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe("watch + event-push conflict regression (feedback_ef2b7c8c)", () => {
|
|
142
|
+
let tmpDir: string;
|
|
143
|
+
let stateDir: string;
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
clearContextCache();
|
|
147
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-conflict-"));
|
|
148
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-state-"));
|
|
149
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
150
|
+
setupFetchMock();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
vi.unstubAllGlobals();
|
|
155
|
+
vi.clearAllMocks();
|
|
156
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
157
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
158
|
+
delete process.env.HQ_STATE_DIR;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("a single writer's repeated local edits mint ZERO .conflict-* files", async () => {
|
|
162
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
163
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
164
|
+
const files = ["notes.md", "plan.md", "ideas.md"].map((n) => path.join(companyRoot, n));
|
|
165
|
+
for (const f of files) fs.writeFileSync(f, "v0\n");
|
|
166
|
+
|
|
167
|
+
wireSingleWriterRemote();
|
|
168
|
+
|
|
169
|
+
const conflictPathsSeen: string[] = [];
|
|
170
|
+
// The event-push runner pushes the changed company subtree. Mirror that:
|
|
171
|
+
// share() over the company root on every settled change.
|
|
172
|
+
const eventPush = async () => {
|
|
173
|
+
const r = await share({
|
|
174
|
+
paths: [companyRoot],
|
|
175
|
+
company: "acme",
|
|
176
|
+
vaultConfig: mockConfig,
|
|
177
|
+
hqRoot: tmpDir,
|
|
178
|
+
onConflict: "keep",
|
|
179
|
+
onEvent: () => {},
|
|
180
|
+
});
|
|
181
|
+
conflictPathsSeen.push(...r.conflictPaths);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Initial sync establishes the journal baseline (first upload of each file).
|
|
185
|
+
await eventPush();
|
|
186
|
+
|
|
187
|
+
// An editing session: 30 ordinary local edits, round-robin across the
|
|
188
|
+
// files, each followed by its targeted event-push.
|
|
189
|
+
for (let i = 0; i < 30; i++) {
|
|
190
|
+
fs.writeFileSync(files[i % files.length], `v${i + 1}\n`);
|
|
191
|
+
await eventPush();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
expect(countConflictMirrors(tmpDir)).toEqual([]);
|
|
195
|
+
expect(conflictPathsSeen).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("positive control: a genuine peer write is still detected as a conflict", async () => {
|
|
199
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
200
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
201
|
+
const file = path.join(companyRoot, "contested.md");
|
|
202
|
+
fs.writeFileSync(file, "v0\n");
|
|
203
|
+
|
|
204
|
+
const remote = wireSingleWriterRemote();
|
|
205
|
+
|
|
206
|
+
const conflictPathsSeen: string[] = [];
|
|
207
|
+
const eventPush = async () => {
|
|
208
|
+
const r = await share({
|
|
209
|
+
paths: [companyRoot],
|
|
210
|
+
company: "acme",
|
|
211
|
+
vaultConfig: mockConfig,
|
|
212
|
+
hqRoot: tmpDir,
|
|
213
|
+
onConflict: "keep",
|
|
214
|
+
onEvent: () => {},
|
|
215
|
+
});
|
|
216
|
+
conflictPathsSeen.push(...r.conflictPaths);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
await eventPush(); // baseline
|
|
220
|
+
|
|
221
|
+
// A peer advances the remote object out-of-band to DIFFERENT bytes. Journal
|
|
222
|
+
// keys are company-relative, so the key is "contested.md".
|
|
223
|
+
remote.set("contested.md", { etag: '"peer-etag"', lastModified: new Date(Date.now() + 60_000), size: 99 });
|
|
224
|
+
// ...and we also edit locally → both sides moved → a genuine conflict.
|
|
225
|
+
fs.writeFileSync(file, "v1-local\n");
|
|
226
|
+
await eventPush();
|
|
227
|
+
|
|
228
|
+
// Existing-entry push conflicts surface via conflictPaths (the inspection
|
|
229
|
+
// mirror is written by the pull leg). The contract under test: a REAL
|
|
230
|
+
// divergence is still flagged — the single-writer guard above did not
|
|
231
|
+
// over-correct into swallowing true conflicts.
|
|
232
|
+
expect(conflictPathsSeen).toContain("contested.md");
|
|
233
|
+
});
|
|
234
|
+
});
|
package/src/prefix-coalesce.ts
CHANGED
|
@@ -71,6 +71,41 @@ export function isCoveredByAny(
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Directory companion to `isCoveredByAny`: should the push/pull walk DESCEND
|
|
76
|
+
* into directory `relDir` (company-relative, no leading slash) given the
|
|
77
|
+
* granted `prefixSet`?
|
|
78
|
+
*
|
|
79
|
+
* A file uses plain `startsWith` (`isCoveredByAny`), but a directory must be
|
|
80
|
+
* descended whenever it COULD contain an in-scope file — which is true in two
|
|
81
|
+
* directions:
|
|
82
|
+
* - the directory sits INSIDE a granted prefix (`knowledge/sub` under
|
|
83
|
+
* `knowledge/`), or
|
|
84
|
+
* - a granted prefix sits INSIDE the directory (`knowledge/` under the
|
|
85
|
+
* company root `""`, or `knowledge/README.md` under `knowledge/`).
|
|
86
|
+
* Without the second case the walk would refuse to descend into `knowledge/`
|
|
87
|
+
* to reach a `knowledge/README.md` exact-file grant, scoping the whole tree
|
|
88
|
+
* to nothing.
|
|
89
|
+
*
|
|
90
|
+
* The directory is normalized to a trailing-slash form so the `startsWith`
|
|
91
|
+
* comparisons line up with coalesced prefixes (which are themselves either
|
|
92
|
+
* trailing-slash dir prefixes or exact-file keys). The empty string (company
|
|
93
|
+
* root) always descends when any prefix exists.
|
|
94
|
+
*/
|
|
95
|
+
export function isDirInScope(
|
|
96
|
+
relDir: string,
|
|
97
|
+
prefixSet: readonly string[],
|
|
98
|
+
): boolean {
|
|
99
|
+
const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
|
|
100
|
+
for (const p of prefixSet) {
|
|
101
|
+
if (p === "") return true; // full scope
|
|
102
|
+
if (dir === "") return true; // company root — descend to reach grants
|
|
103
|
+
if (dir.startsWith(p)) return true; // dir is inside a granted prefix
|
|
104
|
+
if (p.startsWith(dir)) return true; // a granted prefix is inside dir
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
/**
|
|
75
110
|
* Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
|
|
76
111
|
* for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
|