@indigoai-us/hq-cloud 6.2.5 → 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 +24 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +117 -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 +9 -0
- package/dist/cli/sync.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 +144 -3
- package/src/cli/sync.ts +32 -0
- package/src/cli/watch-event-push-conflict.test.ts +234 -0
- package/src/prefix-coalesce.ts +35 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -274,6 +274,7 @@ export type RunnerEvent =
|
|
|
274
274
|
| ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
|
|
275
275
|
| ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
|
|
276
276
|
| { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
|
|
277
|
+
| { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
|
|
277
278
|
| ({
|
|
278
279
|
type: "complete";
|
|
279
280
|
company: string;
|
|
@@ -1243,6 +1244,16 @@ export async function runRunner(
|
|
|
1243
1244
|
company: companyLabel,
|
|
1244
1245
|
files: event.files,
|
|
1245
1246
|
});
|
|
1247
|
+
} else if (event.type === "scope-excluded") {
|
|
1248
|
+
// Push-side ACL scope exclusions — surface the named paths tagged to
|
|
1249
|
+
// this company so the menubar/CLI can show "N skipped, outside your
|
|
1250
|
+
// access" instead of the file silently never uploading.
|
|
1251
|
+
emit({
|
|
1252
|
+
type: "scope-excluded",
|
|
1253
|
+
company: companyLabel,
|
|
1254
|
+
count: event.count,
|
|
1255
|
+
samplePaths: event.samplePaths,
|
|
1256
|
+
});
|
|
1246
1257
|
}
|
|
1247
1258
|
};
|
|
1248
1259
|
|
|
@@ -1256,6 +1267,7 @@ export async function runRunner(
|
|
|
1256
1267
|
filesRefusedStale: 0,
|
|
1257
1268
|
filesRefusedStalePaths: [],
|
|
1258
1269
|
filesExcludedByPolicy: 0,
|
|
1270
|
+
filesExcludedByScope: 0,
|
|
1259
1271
|
conflictPaths: [],
|
|
1260
1272
|
aborted: false,
|
|
1261
1273
|
};
|
|
@@ -1307,6 +1319,26 @@ export async function runRunner(
|
|
|
1307
1319
|
.map((p) => p.slug),
|
|
1308
1320
|
);
|
|
1309
1321
|
|
|
1322
|
+
// Resolve the membership's effective ACL scope ONCE so BOTH the push and
|
|
1323
|
+
// pull legs respect the granted prefixes. The vault vends a child
|
|
1324
|
+
// credential scoped to these prefixes; without filtering the PUSH plan to
|
|
1325
|
+
// them, share() would HEAD/PUT keys outside the grant and the server's
|
|
1326
|
+
// correct 403 (SCOPE_EXCEEDS_PARENT) would abort the WHOLE company with a
|
|
1327
|
+
// generic error + exit 2. Personal-vault legs have no membership
|
|
1328
|
+
// sync-config — they stay full-scope ("all"). Degrades to "all" on any
|
|
1329
|
+
// error (a transient failure must never silently filter/prune the tree).
|
|
1330
|
+
// Hoisted above the push block (it used to be resolved only for pull) so
|
|
1331
|
+
// push gets the same scope; the pull leg below reuses this value.
|
|
1332
|
+
const scope: PullScope =
|
|
1333
|
+
target.personalMode === true
|
|
1334
|
+
? { syncMode: "all" }
|
|
1335
|
+
: await resolvePullScope(
|
|
1336
|
+
client,
|
|
1337
|
+
target.uid,
|
|
1338
|
+
target.slug,
|
|
1339
|
+
parsed.hqRoot,
|
|
1340
|
+
);
|
|
1341
|
+
|
|
1310
1342
|
if (doPush) {
|
|
1311
1343
|
activePhase = "push";
|
|
1312
1344
|
// For the personal slot we hand share() both (a) the top-level
|
|
@@ -1365,6 +1397,13 @@ export async function runRunner(
|
|
|
1365
1397
|
...(decommissionPrefixes && decommissionPrefixes.length > 0
|
|
1366
1398
|
? { decommissionPrefixes }
|
|
1367
1399
|
: {}),
|
|
1400
|
+
// US-005 symmetry: scope the PUSH plan to the membership's granted
|
|
1401
|
+
// ACL prefixes so out-of-scope keys are skipped (and surfaced via a
|
|
1402
|
+
// `scope-excluded` event) instead of drawing the server's 403 and
|
|
1403
|
+
// aborting the company. `undefined` for `syncMode: "all"` (owner /
|
|
1404
|
+
// personal) → no scope filter, identical to the pre-fix shape so the
|
|
1405
|
+
// "company-target args" contract test stays green.
|
|
1406
|
+
...(scope.prefixSet !== undefined ? { prefixSet: scope.prefixSet } : {}),
|
|
1368
1407
|
});
|
|
1369
1408
|
}
|
|
1370
1409
|
|
|
@@ -1373,20 +1412,11 @@ export async function runRunner(
|
|
|
1373
1412
|
// whichever side `--on-conflict abort` just protected.
|
|
1374
1413
|
if (doPull && !pushResult.aborted) {
|
|
1375
1414
|
activePhase = "pull";
|
|
1376
|
-
// US-005:
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
|
|
1381
|
-
const pullScope: PullScope =
|
|
1382
|
-
target.personalMode === true
|
|
1383
|
-
? { syncMode: "all" }
|
|
1384
|
-
: await resolvePullScope(
|
|
1385
|
-
client,
|
|
1386
|
-
target.uid,
|
|
1387
|
-
target.slug,
|
|
1388
|
-
parsed.hqRoot,
|
|
1389
|
-
);
|
|
1415
|
+
// US-005: the pull only materializes in-scope keys (and prunes clean
|
|
1416
|
+
// orphans when scope shrank). Reuse the `scope` resolved once above so
|
|
1417
|
+
// push and pull apply the SAME granted prefixes and we avoid a second
|
|
1418
|
+
// `listMyExplicitGrants` round-trip per company.
|
|
1419
|
+
const pullScope: PullScope = scope;
|
|
1390
1420
|
pullResult = await syncFn({
|
|
1391
1421
|
company: target.uid,
|
|
1392
1422
|
vaultConfig,
|
package/src/cli/share.test.ts
CHANGED
|
@@ -586,6 +586,114 @@ describe("share", () => {
|
|
|
586
586
|
expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
|
|
587
587
|
});
|
|
588
588
|
|
|
589
|
+
it("scoped push (plan-exceeds-grant): syncs the in-scope subset, skips out-of-scope paths, never aborts (feedback_ded09d56)", async () => {
|
|
590
|
+
// Real case (look-optic): a FILE_ACL grant covered {knowledge,policies,
|
|
591
|
+
// workers}/* + company.yaml, but the upload plan also contained
|
|
592
|
+
// settings/.gitkeep + projects/.gitkeep. Pre-fix, the push walked the
|
|
593
|
+
// whole company tree, HEAD'd an out-of-scope key, the scoped child
|
|
594
|
+
// credential drew a 403 SCOPE_EXCEEDS_PARENT, and the runner aborted the
|
|
595
|
+
// ENTIRE company (exit 2) naming no path. The fix scopes the push plan to
|
|
596
|
+
// the granted prefixSet (symmetric with the pull-side skip-out-of-scope):
|
|
597
|
+
// in-scope files upload, out-of-scope paths are skipped + surfaced via a
|
|
598
|
+
// `scope-excluded` event, and the company completes.
|
|
599
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
600
|
+
fs.mkdirSync(path.join(companyRoot, "knowledge"), { recursive: true });
|
|
601
|
+
fs.mkdirSync(path.join(companyRoot, "policies"), { recursive: true });
|
|
602
|
+
fs.mkdirSync(path.join(companyRoot, "settings"), { recursive: true });
|
|
603
|
+
fs.mkdirSync(path.join(companyRoot, "projects"), { recursive: true });
|
|
604
|
+
fs.writeFileSync(path.join(companyRoot, "company.yaml"), "name: Acme\n");
|
|
605
|
+
fs.writeFileSync(path.join(companyRoot, "knowledge", "readme.md"), "# kb\n");
|
|
606
|
+
fs.writeFileSync(path.join(companyRoot, "policies", "p.md"), "policy\n");
|
|
607
|
+
fs.writeFileSync(path.join(companyRoot, "settings", ".gitkeep"), "");
|
|
608
|
+
fs.writeFileSync(path.join(companyRoot, "projects", ".gitkeep"), "");
|
|
609
|
+
|
|
610
|
+
// No remote anywhere → every in-scope file is a clean upload.
|
|
611
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
612
|
+
|
|
613
|
+
const events: Array<{ type?: string; path?: string; count?: number; samplePaths?: string[] }> = [];
|
|
614
|
+
const result = await share({
|
|
615
|
+
paths: [companyRoot],
|
|
616
|
+
company: "acme",
|
|
617
|
+
vaultConfig: mockConfig,
|
|
618
|
+
hqRoot: tmpDir,
|
|
619
|
+
// Coalesced company-relative grant prefixes (what resolvePullScope hands
|
|
620
|
+
// the runner for a shared membership).
|
|
621
|
+
prefixSet: ["company.yaml", "knowledge/", "policies/", "workers/"],
|
|
622
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// In-scope subset uploaded; out-of-scope never PUT. (company.yaml is in
|
|
626
|
+
// the grant but is independently dropped by the base ignore filter — it's
|
|
627
|
+
// in DEFAULT_IGNORES — so it never uploads regardless of scope; the scope
|
|
628
|
+
// filter layers on top of the ignore filter, not under it.)
|
|
629
|
+
const uploadedPaths = events
|
|
630
|
+
.filter((e) => e.type === "progress")
|
|
631
|
+
.map((e) => e.path)
|
|
632
|
+
.sort();
|
|
633
|
+
expect(uploadedPaths).toEqual(["knowledge/readme.md", "policies/p.md"]);
|
|
634
|
+
expect(result.filesUploaded).toBe(2);
|
|
635
|
+
// The two out-of-scope directories were excluded and named.
|
|
636
|
+
expect(result.filesExcludedByScope).toBe(2);
|
|
637
|
+
const scopeEv = events.find((e) => e.type === "scope-excluded") as
|
|
638
|
+
| { count: number; samplePaths: string[] }
|
|
639
|
+
| undefined;
|
|
640
|
+
expect(scopeEv).toBeDefined();
|
|
641
|
+
expect(scopeEv!.count).toBe(2);
|
|
642
|
+
expect(scopeEv!.samplePaths.sort()).toEqual(["projects/", "settings/"]);
|
|
643
|
+
// No conflict, no error — the company completed cleanly (no abort).
|
|
644
|
+
expect(result.aborted).toBe(false);
|
|
645
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
646
|
+
// uploadFile was never called for an out-of-scope key.
|
|
647
|
+
const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
648
|
+
expect(putKeys).not.toContain("settings/.gitkeep");
|
|
649
|
+
expect(putKeys).not.toContain("projects/.gitkeep");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("scoped push defense-in-depth: a 403 on HEAD skips that key (named error) instead of aborting the company", async () => {
|
|
653
|
+
// Belt-and-suspenders: even if a key slips past the prefix filter (a grant
|
|
654
|
+
// that changed mid-run, a pin outside the grant, prefix-coalesce
|
|
655
|
+
// imprecision), the server's correct 403 on the HEAD must NOT abort the
|
|
656
|
+
// whole company. Pre-fix the HEAD sat outside the per-file PUT try/catch,
|
|
657
|
+
// so the throw bubbled to workerErrors -> `throw first` -> exit 2.
|
|
658
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
659
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
660
|
+
const okFile = path.join(companyRoot, "in-scope.md");
|
|
661
|
+
const blockedFile = path.join(companyRoot, "out-of-reach.md");
|
|
662
|
+
fs.writeFileSync(okFile, "ok\n");
|
|
663
|
+
fs.writeFileSync(blockedFile, "denied\n");
|
|
664
|
+
|
|
665
|
+
// The scoped credential 403s on the out-of-scope key's HEAD; the other
|
|
666
|
+
// key heads cleanly (no remote).
|
|
667
|
+
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => {
|
|
668
|
+
if (key === "out-of-reach.md") {
|
|
669
|
+
const err = new Error("access denied");
|
|
670
|
+
(err as { name: string }).name = "AccessDenied";
|
|
671
|
+
throw err;
|
|
672
|
+
}
|
|
673
|
+
return null;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const events: Array<{ type?: string; path?: string; message?: string }> = [];
|
|
677
|
+
const result = await share({
|
|
678
|
+
paths: [okFile, blockedFile],
|
|
679
|
+
company: "acme",
|
|
680
|
+
vaultConfig: mockConfig,
|
|
681
|
+
hqRoot: tmpDir,
|
|
682
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Company did NOT abort; the in-scope file still uploaded.
|
|
686
|
+
expect(result.aborted).toBe(false);
|
|
687
|
+
expect(result.filesUploaded).toBe(1);
|
|
688
|
+
const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
689
|
+
expect(putKeys).toEqual(["in-scope.md"]);
|
|
690
|
+
// The blocked key surfaced as a path-named, scope-clear error event.
|
|
691
|
+
const errs = events.filter((e) => e.type === "error") as Array<{ path?: string; message?: string }>;
|
|
692
|
+
expect(errs).toHaveLength(1);
|
|
693
|
+
expect(errs[0].path).toBe("out-of-reach.md");
|
|
694
|
+
expect(errs[0].message).toMatch(/outside granted ACL scope/i);
|
|
695
|
+
});
|
|
696
|
+
|
|
589
697
|
it("uploads (no conflict) when only the local side changed since last sync", async () => {
|
|
590
698
|
// Regression for hq-cloud#<conflict-detection>: a local edit to a file
|
|
591
699
|
// that exists on S3 used to trigger a push conflict because the
|
|
@@ -810,6 +918,7 @@ describe("share", () => {
|
|
|
810
918
|
e.type === "plan" ||
|
|
811
919
|
e.type === "new-files" ||
|
|
812
920
|
e.type === "personal-vault-out-of-policy" ||
|
|
921
|
+
e.type === "scope-excluded" ||
|
|
813
922
|
e.type === "delete-refused-bulk-asymmetry"
|
|
814
923
|
) return;
|
|
815
924
|
events.push({
|
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,
|
|
@@ -591,6 +592,20 @@ export interface ShareOptions {
|
|
|
591
592
|
* `sync-journal.personal.json` file.
|
|
592
593
|
*/
|
|
593
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[];
|
|
594
609
|
}
|
|
595
610
|
|
|
596
611
|
export interface ShareResult {
|
|
@@ -644,6 +659,16 @@ export interface ShareResult {
|
|
|
644
659
|
* emitted exactly once if this is > 0).
|
|
645
660
|
*/
|
|
646
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;
|
|
647
672
|
/**
|
|
648
673
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
649
674
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -653,6 +678,56 @@ export interface ShareResult {
|
|
|
653
678
|
aborted: boolean;
|
|
654
679
|
}
|
|
655
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
|
+
|
|
656
731
|
/**
|
|
657
732
|
* Share local file(s) to the entity vault.
|
|
658
733
|
*/
|
|
@@ -768,9 +843,22 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
768
843
|
excludedSet.add(rel);
|
|
769
844
|
excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
|
|
770
845
|
};
|
|
771
|
-
|
|
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
|
|
772
857
|
? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
|
|
773
858
|
: ignoreFilter;
|
|
859
|
+
const shouldSync = options.prefixSet !== undefined
|
|
860
|
+
? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
|
|
861
|
+
: baseFilter;
|
|
774
862
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
775
863
|
// Seed the canonical personal-vault journal from the legacy `personal` file
|
|
776
864
|
// exactly once — engine-side so every consumer (sync-runner, hq-cli) gets
|
|
@@ -1008,7 +1096,32 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1008
1096
|
// single-part etag) and compare. Match → no conflict, silently skip
|
|
1009
1097
|
// the PUT (the bytes are already there). Mismatch → treat as a
|
|
1010
1098
|
// conflict in the same shared branch below.
|
|
1011
|
-
|
|
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
|
+
}
|
|
1012
1125
|
if (remoteMeta) {
|
|
1013
1126
|
const journalEntry = journal.files[relativePath];
|
|
1014
1127
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
@@ -1214,7 +1327,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1214
1327
|
emit({
|
|
1215
1328
|
type: "error",
|
|
1216
1329
|
path: relativePath,
|
|
1217
|
-
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),
|
|
1218
1337
|
});
|
|
1219
1338
|
}
|
|
1220
1339
|
};
|
|
@@ -1284,6 +1403,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1284
1403
|
// abort (matches the existing convention: abort short-circuits
|
|
1285
1404
|
// before the end-of-run telemetry emits).
|
|
1286
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,
|
|
1287
1409
|
// Use the snapshot of conflictPaths taken at the moment the abort
|
|
1288
1410
|
// fired — additional in-flight items may have appended to the
|
|
1289
1411
|
// shared array after the abort signal, and those should not show
|
|
@@ -1403,6 +1525,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1403
1525
|
});
|
|
1404
1526
|
}
|
|
1405
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
|
+
|
|
1406
1546
|
// Codex P1 (5.36.x): if any worker rejected (unhandled error in
|
|
1407
1547
|
// headRemoteFile / refreshEntityContext / resolveConflict — paths
|
|
1408
1548
|
// outside the per-item PUT try/catch), we deliberately let the pool
|
|
@@ -1429,6 +1569,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1429
1569
|
filesRefusedStale,
|
|
1430
1570
|
filesRefusedStalePaths,
|
|
1431
1571
|
filesExcludedByPolicy: excludedSet.size,
|
|
1572
|
+
filesExcludedByScope: scopeExcludedSet.size,
|
|
1432
1573
|
conflictPaths,
|
|
1433
1574
|
aborted: false,
|
|
1434
1575
|
};
|
package/src/cli/sync.ts
CHANGED
|
@@ -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 {
|
|
@@ -1877,5 +1899,15 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
1877
1899
|
console.log(` ... and ${event.files.length - MAX_SHOWN} more`);
|
|
1878
1900
|
}
|
|
1879
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
|
+
}
|
|
1880
1912
|
}
|
|
1881
1913
|
}
|