@indigoai-us/hq-cloud 6.14.1 → 6.14.3
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-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +85 -12
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +95 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +29 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +48 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +44 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +49 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +49 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +113 -13
- package/src/cli/reindex.ts +123 -23
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +24 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/personal-vault.test.ts +57 -0
- package/src/personal-vault.ts +48 -2
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +70 -0
- package/src/watcher.ts +68 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -1265,8 +1265,8 @@ describe("share", () => {
|
|
|
1265
1265
|
});
|
|
1266
1266
|
|
|
1267
1267
|
it("a file over the size limit is a benign skip-size-limit event, NEVER an error (HQ-SYNC-4 exit-2 flood)", async () => {
|
|
1268
|
-
// Regression for the
|
|
1269
|
-
// (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ machines). An
|
|
1268
|
+
// Regression for the "auto-sync watcher exited unexpectedly
|
|
1269
|
+
// (code=Some(2))" flood (HQ-SYNC-4, ~93k events across 60+ user machines). An
|
|
1270
1270
|
// oversized file is a permanent, benign skip — it exceeds the 50 MB cap on
|
|
1271
1271
|
// EVERY pass, forever. Emitting it as `type: "error"` pushed it into the
|
|
1272
1272
|
// runner's `errors[]`, so the pass returned exit 2, which the menubar
|
|
@@ -4992,3 +4992,140 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4992
4992
|
});
|
|
4993
4993
|
});
|
|
4994
4994
|
});
|
|
4995
|
+
|
|
4996
|
+
describe("scope-invalid key hardening on push (doubled companies/ tree — incident 2026-07-11)", () => {
|
|
4997
|
+
let tmpDir: string;
|
|
4998
|
+
let stateDir: string;
|
|
4999
|
+
|
|
5000
|
+
beforeEach(() => {
|
|
5001
|
+
clearContextCache();
|
|
5002
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-poison-test-"));
|
|
5003
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-poison-test-"));
|
|
5004
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
5005
|
+
setupFetchMock();
|
|
5006
|
+
// Earlier describes in this file don't all clear call history; reset it
|
|
5007
|
+
// here so uploaded-key assertions below are order-independent.
|
|
5008
|
+
vi.mocked(uploadFile).mockClear();
|
|
5009
|
+
vi.mocked(uploadSymlink).mockClear();
|
|
5010
|
+
vi.mocked(headRemoteFile).mockClear();
|
|
5011
|
+
vi.mocked(deleteRemoteFile).mockClear();
|
|
5012
|
+
vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
|
|
5013
|
+
vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
|
|
5014
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
5015
|
+
});
|
|
5016
|
+
|
|
5017
|
+
afterEach(() => {
|
|
5018
|
+
vi.unstubAllGlobals();
|
|
5019
|
+
vi.clearAllMocks();
|
|
5020
|
+
vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
|
|
5021
|
+
vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
|
|
5022
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
5023
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
5024
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
5025
|
+
delete process.env.HQ_STATE_DIR;
|
|
5026
|
+
});
|
|
5027
|
+
|
|
5028
|
+
it("refuses to upload doubled-tree companies/ keys in company mode — clear per-key warning, never an error", async () => {
|
|
5029
|
+
// The incident writer: a stale doubled local tree
|
|
5030
|
+
// companies/acme/companies/acme/… would push literal `companies/…` keys
|
|
5031
|
+
// via the direct-S3 STS transport, poisoning the bucket with keys the
|
|
5032
|
+
// presign download API rejects (INVALID_KEY_COMPANIES_SCOPED) for every
|
|
5033
|
+
// other machine. The push planner must refuse them up front.
|
|
5034
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5035
|
+
const doubled = path.join(companyRoot, "companies", "acme", "knowledge");
|
|
5036
|
+
fs.mkdirSync(doubled, { recursive: true });
|
|
5037
|
+
fs.writeFileSync(path.join(doubled, "poison.md"), "poison");
|
|
5038
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
5039
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
|
|
5040
|
+
|
|
5041
|
+
const events: Array<{ type?: string; path?: string }> = [];
|
|
5042
|
+
const result = await share({
|
|
5043
|
+
paths: [companyRoot],
|
|
5044
|
+
company: "acme",
|
|
5045
|
+
vaultConfig: mockConfig,
|
|
5046
|
+
hqRoot: tmpDir,
|
|
5047
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
5048
|
+
});
|
|
5049
|
+
|
|
5050
|
+
expect(result.aborted).toBe(false);
|
|
5051
|
+
expect(result.filesUploaded).toBe(1);
|
|
5052
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
5053
|
+
expect(uploadedKeys).toEqual(["docs/good.md"]);
|
|
5054
|
+
|
|
5055
|
+
// Per-key warning on the existing event channel, never type:"error"
|
|
5056
|
+
// (an error event flips the runner pass to exit 2 — the flood class).
|
|
5057
|
+
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
5058
|
+
expect(skipEvents.map((e) => e.path)).toEqual([
|
|
5059
|
+
"companies/acme/knowledge/poison.md",
|
|
5060
|
+
]);
|
|
5061
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
5062
|
+
});
|
|
5063
|
+
|
|
5064
|
+
it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
|
|
5065
|
+
// Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
|
|
5066
|
+
// companies/-prefixed key would be rejected by the presign transport's
|
|
5067
|
+
// server validator, so the delete plan must route it straight to
|
|
5068
|
+
// toTombstone — journal entry drained, no remote call, no re-upload.
|
|
5069
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5070
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5071
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5072
|
+
fs.writeFileSync(
|
|
5073
|
+
journalPath,
|
|
5074
|
+
JSON.stringify({
|
|
5075
|
+
version: "1",
|
|
5076
|
+
lastSync: new Date().toISOString(),
|
|
5077
|
+
files: {
|
|
5078
|
+
"companies/acme/knowledge/poison.md": {
|
|
5079
|
+
hash: "poison-hash",
|
|
5080
|
+
size: 6,
|
|
5081
|
+
syncedAt: new Date().toISOString(),
|
|
5082
|
+
direction: "up",
|
|
5083
|
+
remoteEtag: "poison-etag",
|
|
5084
|
+
},
|
|
5085
|
+
},
|
|
5086
|
+
}),
|
|
5087
|
+
);
|
|
5088
|
+
|
|
5089
|
+
const result = await share({
|
|
5090
|
+
paths: [companyRoot],
|
|
5091
|
+
company: "acme",
|
|
5092
|
+
vaultConfig: mockConfig,
|
|
5093
|
+
hqRoot: tmpDir,
|
|
5094
|
+
skipUnchanged: true,
|
|
5095
|
+
propagateDeletes: true,
|
|
5096
|
+
propagateDeletePolicy: "currency-gated",
|
|
5097
|
+
});
|
|
5098
|
+
|
|
5099
|
+
expect(result.aborted).toBe(false);
|
|
5100
|
+
// Tombstoned: journal entry dropped without any remote HEAD or DELETE.
|
|
5101
|
+
expect(result.filesTombstoned).toBe(1);
|
|
5102
|
+
expect(result.filesDeleted).toBe(0);
|
|
5103
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
5104
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
5105
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
5106
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5107
|
+
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
5108
|
+
});
|
|
5109
|
+
|
|
5110
|
+
it("personal-mode push still uploads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
|
|
5111
|
+
const localCo = path.join(tmpDir, "companies", "localco");
|
|
5112
|
+
fs.mkdirSync(localCo, { recursive: true });
|
|
5113
|
+
fs.writeFileSync(path.join(localCo, "notes.md"), "n");
|
|
5114
|
+
|
|
5115
|
+
const events: Array<{ type?: string }> = [];
|
|
5116
|
+
const result = await share({
|
|
5117
|
+
paths: [localCo],
|
|
5118
|
+
company: "acme",
|
|
5119
|
+
vaultConfig: mockConfig,
|
|
5120
|
+
hqRoot: tmpDir,
|
|
5121
|
+
personalMode: true,
|
|
5122
|
+
journalSlug: "personal-vault",
|
|
5123
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
5124
|
+
});
|
|
5125
|
+
|
|
5126
|
+
expect(result.aborted).toBe(false);
|
|
5127
|
+
const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
5128
|
+
expect(uploadedKeys).toContain("companies/localco/notes.md");
|
|
5129
|
+
expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
|
|
5130
|
+
});
|
|
5131
|
+
});
|
package/src/cli/share.ts
CHANGED
|
@@ -1047,12 +1047,34 @@ function createShareCounters(): ShareCounters {
|
|
|
1047
1047
|
}
|
|
1048
1048
|
|
|
1049
1049
|
async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
1050
|
-
const
|
|
1050
|
+
const collected = collectFiles(
|
|
1051
1051
|
run.paths,
|
|
1052
1052
|
run.hqRoot,
|
|
1053
1053
|
run.syncRoot,
|
|
1054
1054
|
run.shouldSync,
|
|
1055
1055
|
);
|
|
1056
|
+
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
1057
|
+
// root IS the company folder, so a local entry whose vault key starts with
|
|
1058
|
+
// `companies/` can only come from a stale doubled local tree
|
|
1059
|
+
// (companies/{slug}/companies/{slug}/…). Uploading it poisons the bucket
|
|
1060
|
+
// with keys the server validator rejects (INVALID_KEY_COMPANIES_SCOPED) —
|
|
1061
|
+
// the direct-S3 STS transport bypasses server validation. Refuse them here
|
|
1062
|
+
// with a per-key warning; uploadFile/uploadSymlink's validateVaultUploadKey
|
|
1063
|
+
// is the belt-and-suspenders backstop. The pull leg's journal-tombstone
|
|
1064
|
+
// path then CLEANS the doubled tree (local delete + journal drop) instead
|
|
1065
|
+
// of this leg ever re-uploading it. Personal vaults legitimately carry
|
|
1066
|
+
// `companies/{slug}/…` keys, so this only applies in company mode.
|
|
1067
|
+
const filesToShare =
|
|
1068
|
+
run.options.personalMode === true
|
|
1069
|
+
? collected
|
|
1070
|
+
: collected.filter((entry) => {
|
|
1071
|
+
if (!entry.relativePath.startsWith("companies/")) return true;
|
|
1072
|
+
run.emit({
|
|
1073
|
+
type: "skip-invalid-scoped-key",
|
|
1074
|
+
path: entry.relativePath,
|
|
1075
|
+
});
|
|
1076
|
+
return false;
|
|
1077
|
+
});
|
|
1056
1078
|
const pushPlan = computePushPlan(
|
|
1057
1079
|
filesToShare,
|
|
1058
1080
|
run.journal,
|
|
@@ -1069,6 +1091,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1069
1091
|
run.shouldSync,
|
|
1070
1092
|
run.propagateDeletePolicy,
|
|
1071
1093
|
run.ctx,
|
|
1094
|
+
run.options.personalMode !== true,
|
|
1072
1095
|
)
|
|
1073
1096
|
: { toDelete: [], toTombstone: [], refusedStale: [] };
|
|
1074
1097
|
const decommissionPlan =
|
|
@@ -1139,7 +1162,7 @@ async function executeUploads(
|
|
|
1139
1162
|
// Emitting it as `type: "error"` pushed it into the runner's `errors[]`,
|
|
1140
1163
|
// so every watch pass returned exit 2 and the menubar reported an
|
|
1141
1164
|
// "auto-sync watcher exited unexpectedly (code=Some(2))" crash on every
|
|
1142
|
-
// tick (the
|
|
1165
|
+
// tick (the HQ-SYNC-4 flood across user machines). Surface it for visibility, but
|
|
1143
1166
|
// do not let it flip the pass to a non-zero exit.
|
|
1144
1167
|
let bytes = 0;
|
|
1145
1168
|
try {
|
|
@@ -1676,6 +1699,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
1676
1699
|
` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
|
|
1677
1700
|
` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
|
|
1678
1701
|
);
|
|
1702
|
+
} else if (event.type === "skip-invalid-scoped-key") {
|
|
1703
|
+
console.warn(
|
|
1704
|
+
` ! ${event.path} skipped — 'companies/'-prefixed key is invalid in a company-scoped vault (stale doubled local tree? remove the inner companies/<slug> copy)`,
|
|
1705
|
+
);
|
|
1679
1706
|
} else if (event.type === "ignore-excluded") {
|
|
1680
1707
|
// The "not silent" surface: an ignore rule dropped content that doesn't
|
|
1681
1708
|
// look like expected build/VCS/cache noise, so it never reached the vault.
|
|
@@ -2178,6 +2205,11 @@ async function computeDeletePlan(
|
|
|
2178
2205
|
shouldSync: (filePath: string, isDir?: boolean) => boolean,
|
|
2179
2206
|
policy: "currency-gated" | "owned-only" | "all",
|
|
2180
2207
|
ctx: EntityContext,
|
|
2208
|
+
// True for a COMPANY-scoped push (personalMode !== true): the sync root is
|
|
2209
|
+
// the company folder and journal keys are bucket-relative, so a literal
|
|
2210
|
+
// `companies/…` key is scope-invalid (see the poisoned-key branch below).
|
|
2211
|
+
// Personal-vault pushes legitimately journal `companies/{slug}/…` keys.
|
|
2212
|
+
companyScoped: boolean = true,
|
|
2181
2213
|
): Promise<DeletePlan> {
|
|
2182
2214
|
const plan: DeletePlan = { toDelete: [], toTombstone: [], refusedStale: [] };
|
|
2183
2215
|
if (scopeRoots.length === 0) return plan;
|
|
@@ -2249,6 +2281,20 @@ async function computeDeletePlan(
|
|
|
2249
2281
|
continue;
|
|
2250
2282
|
}
|
|
2251
2283
|
|
|
2284
|
+
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
2285
|
+
// context, a journal entry at a literal `companies/…` key records a
|
|
2286
|
+
// doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
|
|
2287
|
+
// presign transport is rejected by the server validator
|
|
2288
|
+
// (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
|
|
2289
|
+
// straight to `toTombstone` (journal drop, no remote call) — the local
|
|
2290
|
+
// journal entry drains; server-side cleanup of any poisoned object is an
|
|
2291
|
+
// operator action. Personal-vault pushes (personalMode) carry legitimate
|
|
2292
|
+
// `companies/{slug}/…` keys and are unaffected (companyScoped=false).
|
|
2293
|
+
if (companyScoped && relativeKey.startsWith("companies/")) {
|
|
2294
|
+
plan.toTombstone.push(relativeKey);
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2252
2298
|
if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
|
|
2253
2299
|
// Ephemeral artifacts (conflict mirrors) never propagate-delete via the
|
|
2254
2300
|
// normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
|
|
@@ -461,4 +461,94 @@ describe("sync — scope-aware download (US-005)", () => {
|
|
|
461
461
|
expect(second.scopeOrphansRemoved).toBe(0);
|
|
462
462
|
expect(second.scopeOrphansBlocked).toBe(0);
|
|
463
463
|
});
|
|
464
|
+
|
|
465
|
+
// ── US-006: sessions prefix push-only pull-scope exclusion ─────────────────
|
|
466
|
+
// The sessions prefix is PUSH-ONLY: pushed into the vault but never
|
|
467
|
+
// auto-pulled onto grantee laptops in ANY mode (including `all`, which
|
|
468
|
+
// otherwise pulls the whole bucket). The engine expresses this as
|
|
469
|
+
// `excludePrefixes`, applied to the DOWNLOAD filter only — never to
|
|
470
|
+
// scope-shrink, so an authored/pinned session is never orphaned.
|
|
471
|
+
describe("sessions push-only exclusion (US-006)", () => {
|
|
472
|
+
const withSession = () => {
|
|
473
|
+
REMOTE.current = [
|
|
474
|
+
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
475
|
+
{ key: "sessions/prs_1/2026-07-11T10-00-00.json", size: 50, lastModified: new Date(), etag: '"sess1"' },
|
|
476
|
+
];
|
|
477
|
+
};
|
|
478
|
+
const sessionPath = "sessions/prs_1/2026-07-11T10-00-00.json";
|
|
479
|
+
|
|
480
|
+
it("AC4: `all` mode subtracts excludePrefixes — a sessions key is skip-out-of-scope; non-session downloads", async () => {
|
|
481
|
+
withSession();
|
|
482
|
+
const result = await sync({
|
|
483
|
+
company: "acme",
|
|
484
|
+
vaultConfig: mockConfig,
|
|
485
|
+
hqRoot: tmpDir,
|
|
486
|
+
syncMode: "all",
|
|
487
|
+
excludePrefixes: ["sessions/"],
|
|
488
|
+
});
|
|
489
|
+
// `all` normally pulls everything; the exclusion carves sessions out.
|
|
490
|
+
expect(result.filesDownloaded).toBe(1);
|
|
491
|
+
expect(result.filesOutOfScope).toBe(1);
|
|
492
|
+
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
493
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("AC4: excludePrefixes is NOT fed to scope-shrink — a previously-pulled session file is never orphaned", async () => {
|
|
497
|
+
withSession();
|
|
498
|
+
// 1. Full pull WITHOUT the exclusion: the session materializes and journals
|
|
499
|
+
// as a pulled (direction:"down") entry under an `all`-mode PullRecord.
|
|
500
|
+
const first = await sync({
|
|
501
|
+
company: "acme",
|
|
502
|
+
vaultConfig: mockConfig,
|
|
503
|
+
hqRoot: tmpDir,
|
|
504
|
+
syncMode: "all",
|
|
505
|
+
});
|
|
506
|
+
expect(first.filesDownloaded).toBe(2);
|
|
507
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
508
|
+
|
|
509
|
+
// 2. Full pull WITH the sessions exclusion. The session is now
|
|
510
|
+
// skip-out-of-scope (not re-pulled) but MUST NOT be pruned: the exclude
|
|
511
|
+
// set never reaches scope-shrink, whose currentPrefixSet stays `[""]`
|
|
512
|
+
// (covers everything). If it WERE fed in, the file would be orphaned.
|
|
513
|
+
const second = await sync({
|
|
514
|
+
company: "acme",
|
|
515
|
+
vaultConfig: mockConfig,
|
|
516
|
+
hqRoot: tmpDir,
|
|
517
|
+
syncMode: "all",
|
|
518
|
+
excludePrefixes: ["sessions/"],
|
|
519
|
+
});
|
|
520
|
+
expect(second.filesOutOfScope).toBe(1);
|
|
521
|
+
expect(second.scopeOrphansRemoved).toBe(0);
|
|
522
|
+
expect(second.scopeOrphansBlocked).toBe(0);
|
|
523
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("AC6: a pinned/in-scope session survives scope-shrink even though excludePrefixes skips its re-pull", async () => {
|
|
527
|
+
withSession();
|
|
528
|
+
// Materialize both under all-mode (knowledge sync + an on-demand session get).
|
|
529
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
530
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
531
|
+
|
|
532
|
+
// A scoped shared pull whose inclusion prefixSet includes the pinned
|
|
533
|
+
// session key (as resolvePullScope would after `hq files get` pins it),
|
|
534
|
+
// plus the push-only sessions exclusion. The session is NOT re-pulled
|
|
535
|
+
// (exclusion), but the pin keeps it in scope-shrink's currentPrefixSet, so
|
|
536
|
+
// it is retained rather than orphaned.
|
|
537
|
+
const scoped = await sync({
|
|
538
|
+
company: "acme",
|
|
539
|
+
vaultConfig: mockConfig,
|
|
540
|
+
hqRoot: tmpDir,
|
|
541
|
+
syncMode: "shared",
|
|
542
|
+
prefixSet: ["knowledge/", sessionPath],
|
|
543
|
+
excludePrefixes: ["sessions/"],
|
|
544
|
+
});
|
|
545
|
+
expect(scoped.scopeOrphansRemoved).toBe(0);
|
|
546
|
+
expect(scoped.scopeOrphansBlocked).toBe(0);
|
|
547
|
+
// Session is not re-downloaded (excluded) but survives on disk...
|
|
548
|
+
expect(scoped.filesOutOfScope).toBe(1);
|
|
549
|
+
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
550
|
+
// ...and the in-scope knowledge file is retained too.
|
|
551
|
+
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
464
554
|
});
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -3589,3 +3589,157 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
|
|
|
3589
3589
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
3590
3590
|
});
|
|
3591
3591
|
});
|
|
3592
|
+
|
|
3593
|
+
describe("scope-invalid key hardening (companies/ prefix in a company vault — incident 2026-07-11)", () => {
|
|
3594
|
+
let tmpDir: string;
|
|
3595
|
+
let stateDir: string;
|
|
3596
|
+
let journalPath: string;
|
|
3597
|
+
|
|
3598
|
+
beforeEach(() => {
|
|
3599
|
+
clearContextCache();
|
|
3600
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-poison-test-"));
|
|
3601
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-poison-test-"));
|
|
3602
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
3603
|
+
journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3604
|
+
setupFetchMock();
|
|
3605
|
+
// Earlier tests in this file replace the downloadFile implementation with
|
|
3606
|
+
// throwing probes; vi.clearAllMocks() keeps implementations, so restore
|
|
3607
|
+
// the default writer here to keep these tests order-independent.
|
|
3608
|
+
vi.mocked(s3Module.downloadFile).mockReset();
|
|
3609
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(
|
|
3610
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
3611
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
3612
|
+
fs.writeFileSync(localPath, "mock file content");
|
|
3613
|
+
return { metadata: {} };
|
|
3614
|
+
},
|
|
3615
|
+
);
|
|
3616
|
+
});
|
|
3617
|
+
|
|
3618
|
+
afterEach(() => {
|
|
3619
|
+
vi.unstubAllGlobals();
|
|
3620
|
+
vi.clearAllMocks();
|
|
3621
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
3622
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
3623
|
+
delete process.env.HQ_STATE_DIR;
|
|
3624
|
+
});
|
|
3625
|
+
|
|
3626
|
+
it("skips a companies/-prefixed remote key instead of erroring the whole company sync", async () => {
|
|
3627
|
+
// The incident shape: 25 poisoned `companies/indigo/…` objects in the
|
|
3628
|
+
// company bucket; the presign download API rejects them
|
|
3629
|
+
// (INVALID_KEY_COMPANIES_SCOPED), so attempting to download errored the
|
|
3630
|
+
// whole company sync and gated heartbeat toolset refreshes fleet-wide.
|
|
3631
|
+
// One bad object must never take down a company sync.
|
|
3632
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3633
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
|
|
3634
|
+
{ key: "companies/acme/knowledge/poison.md", size: 10, lastModified: new Date(), etag: '"p1"' },
|
|
3635
|
+
{ key: "docs/good.md", size: 5, lastModified: new Date(), etag: '"g1"' },
|
|
3636
|
+
]);
|
|
3637
|
+
const events: Array<{ type: string; [k: string]: unknown }> = [];
|
|
3638
|
+
const result = await sync({
|
|
3639
|
+
company: "acme",
|
|
3640
|
+
vaultConfig: mockConfig,
|
|
3641
|
+
hqRoot: tmpDir,
|
|
3642
|
+
onEvent: (e) => events.push(e as never),
|
|
3643
|
+
});
|
|
3644
|
+
|
|
3645
|
+
// The good file syncs; the poisoned key is never downloaded.
|
|
3646
|
+
expect(result.aborted).toBe(false);
|
|
3647
|
+
expect(result.filesDownloaded).toBe(1);
|
|
3648
|
+
expect(
|
|
3649
|
+
fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "good.md")),
|
|
3650
|
+
).toBe(true);
|
|
3651
|
+
expect(
|
|
3652
|
+
fs.existsSync(
|
|
3653
|
+
path.join(tmpDir, "companies", "acme", "companies", "acme", "knowledge", "poison.md"),
|
|
3654
|
+
),
|
|
3655
|
+
).toBe(false);
|
|
3656
|
+
const downloadedKeys = vi
|
|
3657
|
+
.mocked(s3Module.downloadFile)
|
|
3658
|
+
.mock.calls.map((c) => c[1]);
|
|
3659
|
+
expect(downloadedKeys).not.toContain("companies/acme/knowledge/poison.md");
|
|
3660
|
+
|
|
3661
|
+
// Counted as policy-excluded, surfaced as a benign per-key warning event —
|
|
3662
|
+
// deliberately NOT type:"error" (an error event flips the runner to exit 2).
|
|
3663
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
3664
|
+
const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
|
|
3665
|
+
expect(skipEvents).toEqual([
|
|
3666
|
+
{ type: "skip-invalid-scoped-key", path: "companies/acme/knowledge/poison.md" },
|
|
3667
|
+
]);
|
|
3668
|
+
expect(events.filter((e) => e.type === "error")).toEqual([]);
|
|
3669
|
+
});
|
|
3670
|
+
|
|
3671
|
+
it("cleans a doubled-tree journal entry via tombstone (local delete + journal drop) without HEAD-verifying the invalid key", async () => {
|
|
3672
|
+
// A doubled local tree (companies/acme/companies/acme/…) previously
|
|
3673
|
+
// poisoned the bucket; the poisoned objects were deleted server-side.
|
|
3674
|
+
// The journal entry at the invalid key must drain as a tombstone —
|
|
3675
|
+
// local copy removed, journal entry dropped — and must NOT be
|
|
3676
|
+
// HEAD-verified (the presign transport rejects the key, which would
|
|
3677
|
+
// defer the tombstone forever) and must NOT be re-uploaded.
|
|
3678
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3679
|
+
const doubled = path.join(companyRoot, "companies", "acme", "knowledge");
|
|
3680
|
+
fs.mkdirSync(doubled, { recursive: true });
|
|
3681
|
+
fs.writeFileSync(path.join(doubled, "poison.md"), "poison content");
|
|
3682
|
+
const crypto = await import("node:crypto");
|
|
3683
|
+
const baseline = crypto
|
|
3684
|
+
.createHash("sha256")
|
|
3685
|
+
.update("poison content")
|
|
3686
|
+
.digest("hex");
|
|
3687
|
+
fs.writeFileSync(
|
|
3688
|
+
journalPath,
|
|
3689
|
+
JSON.stringify({
|
|
3690
|
+
version: "1",
|
|
3691
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
3692
|
+
files: {
|
|
3693
|
+
"companies/acme/knowledge/poison.md": {
|
|
3694
|
+
hash: baseline,
|
|
3695
|
+
size: 14,
|
|
3696
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
3697
|
+
direction: "up",
|
|
3698
|
+
remoteEtag: "poison-etag",
|
|
3699
|
+
},
|
|
3700
|
+
},
|
|
3701
|
+
}),
|
|
3702
|
+
);
|
|
3703
|
+
// Remote LIST no longer contains the poisoned key (manual cleanup done).
|
|
3704
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3705
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([]);
|
|
3706
|
+
|
|
3707
|
+
const result = await sync({
|
|
3708
|
+
company: "acme",
|
|
3709
|
+
vaultConfig: mockConfig,
|
|
3710
|
+
hqRoot: tmpDir,
|
|
3711
|
+
});
|
|
3712
|
+
|
|
3713
|
+
// Cleaned, not re-uploaded: local doubled-tree copy gone, journal dropped.
|
|
3714
|
+
expect(fs.existsSync(path.join(doubled, "poison.md"))).toBe(false);
|
|
3715
|
+
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
3716
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3717
|
+
expect(journal.files["companies/acme/knowledge/poison.md"]).toBeUndefined();
|
|
3718
|
+
// No HEAD verify against the invalid key — the server validator would
|
|
3719
|
+
// reject it (INVALID_KEY_COMPANIES_SCOPED) and defer the cleanup forever.
|
|
3720
|
+
const headKeys = vi
|
|
3721
|
+
.mocked(s3Module.headRemoteFile)
|
|
3722
|
+
.mock.calls.map((c) => c[1]);
|
|
3723
|
+
expect(headKeys).not.toContain("companies/acme/knowledge/poison.md");
|
|
3724
|
+
// Never re-uploaded.
|
|
3725
|
+
expect(vi.mocked(s3Module.uploadFile)).not.toHaveBeenCalled();
|
|
3726
|
+
});
|
|
3727
|
+
|
|
3728
|
+
it("personal-mode pull still downloads companies/{slug}/ keys (local non-cloud companies are legitimate)", async () => {
|
|
3729
|
+
vi.mocked(s3Module.listRemoteFiles).mockReset();
|
|
3730
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
|
|
3731
|
+
{ key: "companies/localco/notes.md", size: 4, lastModified: new Date(), etag: '"n1"' },
|
|
3732
|
+
]);
|
|
3733
|
+
const events: Array<{ type: string }> = [];
|
|
3734
|
+
const result = await sync({
|
|
3735
|
+
company: "acme",
|
|
3736
|
+
vaultConfig: mockConfig,
|
|
3737
|
+
hqRoot: tmpDir,
|
|
3738
|
+
personalMode: true,
|
|
3739
|
+
onEvent: (e) => events.push(e as never),
|
|
3740
|
+
});
|
|
3741
|
+
expect(result.filesDownloaded).toBe(1);
|
|
3742
|
+
expect(events.filter((e) => e.type === "skip-invalid-scoped-key")).toEqual([]);
|
|
3743
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "localco", "notes.md"))).toBe(true);
|
|
3744
|
+
});
|
|
3745
|
+
});
|