@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/index.ts
CHANGED
|
@@ -152,7 +152,11 @@ export type {
|
|
|
152
152
|
// the SAME effective scope the menubar runner does, instead of defaulting every
|
|
153
153
|
// CLI pull to `syncMode: "all"` (the seed of the all→shared scope-shrink wedge,
|
|
154
154
|
// DEV-1768).
|
|
155
|
-
export {
|
|
155
|
+
export {
|
|
156
|
+
resolvePullScope,
|
|
157
|
+
readPinnedPrefixes,
|
|
158
|
+
SESSIONS_SCOPE_PREFIX,
|
|
159
|
+
} from "./sync/pull-scope.js";
|
|
156
160
|
export type { PullScope, PullScopeClient } from "./sync/pull-scope.js";
|
|
157
161
|
|
|
158
162
|
// Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
|
|
@@ -160,9 +164,11 @@ export {
|
|
|
160
164
|
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
|
161
165
|
PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
|
|
162
166
|
CONTINUITY_POINTER_REL,
|
|
167
|
+
SESSION_LOGS_SYNC_REL,
|
|
163
168
|
computePersonalVaultPaths,
|
|
164
169
|
computePersonalCompanySubdirs,
|
|
165
170
|
computeContinuityPointerPaths,
|
|
171
|
+
computeSessionLogsSyncPaths,
|
|
166
172
|
} from "./personal-vault.js";
|
|
167
173
|
export type { PersonalVaultOptions } from "./personal-vault.js";
|
|
168
174
|
|
|
@@ -200,6 +206,9 @@ export type {
|
|
|
200
206
|
TelemetryOptInResponse,
|
|
201
207
|
UsageBatch,
|
|
202
208
|
UsageIngestResult,
|
|
209
|
+
RawTelemetryEventInput,
|
|
210
|
+
TelemetryEventsBatch,
|
|
211
|
+
TelemetryEventsIngestResult,
|
|
203
212
|
SkillInvocationBatch,
|
|
204
213
|
SkillInvocationIngestResult,
|
|
205
214
|
// US-004 — browse-vs-sync membership sync-mode + ACL surface
|
|
@@ -219,6 +228,20 @@ export type {
|
|
|
219
228
|
TelemetryClientSurface,
|
|
220
229
|
} from "./telemetry.js";
|
|
221
230
|
|
|
231
|
+
// hq-cloud ACTION telemetry (`/v1/telemetry/events`). Unlike token/skill
|
|
232
|
+
// collectors, these sync/CLI events are not gated by `/v1/usage` opt-in.
|
|
233
|
+
export {
|
|
234
|
+
agentUidFromClaims,
|
|
235
|
+
emitCloudTelemetry,
|
|
236
|
+
} from "./telemetry-events.js";
|
|
237
|
+
export type {
|
|
238
|
+
CloudTelemetryClient,
|
|
239
|
+
EmitCloudTelemetryOptions,
|
|
240
|
+
TelemetryClaims,
|
|
241
|
+
TelemetryEvent,
|
|
242
|
+
TelemetryProperties,
|
|
243
|
+
} from "./telemetry-events.js";
|
|
244
|
+
|
|
222
245
|
// Skill-invocation telemetry collector (`/v1/skill-invocations`). Reads the
|
|
223
246
|
// same Claude Code session logs as the token collector but extracts which
|
|
224
247
|
// skill / slash-command was invoked. Independent cursor; same opt-in gate.
|
package/src/journal.ts
CHANGED
|
@@ -404,6 +404,18 @@ export function hashSymlinkTarget(target: string): string {
|
|
|
404
404
|
.digest("hex");
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Record a per-file journal entry after a transfer.
|
|
409
|
+
*
|
|
410
|
+
* `direction` (`"up"` = pushed / locally authored, `"down"` = pulled) is what
|
|
411
|
+
* lets scope-shrink tell your own work apart from a mirror of someone else's.
|
|
412
|
+
* It underpins the US-006 push-only sessions contract: a session transcript
|
|
413
|
+
* this machine authored and pushed is stamped `direction:"up"`, so even though
|
|
414
|
+
* `sessions/` is excluded from every pull scope, `buildScopeShrinkPlan` skips
|
|
415
|
+
* `direction:"up"` entries and never orphans it. (Sessions fetched on demand
|
|
416
|
+
* via `hq files get` are pulled — `direction:"down"` — but ride the pin union
|
|
417
|
+
* in the caller's inclusion prefixSet, so they are likewise never pruned.)
|
|
418
|
+
*/
|
|
407
419
|
export function updateEntry(
|
|
408
420
|
journal: SyncJournal,
|
|
409
421
|
relativePath: string,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner exit code for a partial sync pass: the pass ran to completion but one
|
|
3
|
+
* or more per-file transfers threw (`errors.length > 0` in the all-complete
|
|
4
|
+
* aggregate). Deterministic per-file failures — an oversized object on a
|
|
5
|
+
* pre-skip runner, a presign denial, an expired token late in a long pass, a
|
|
6
|
+
* corrupt doubly-scoped vault key — all land here.
|
|
7
|
+
*
|
|
8
|
+
* A one-shot `hq sync` exits with this code so humans and scripts see the
|
|
9
|
+
* failure. The watch loop must NOT exit on it: auto-sync runs unattended, and
|
|
10
|
+
* a watcher that dies on every partial pass gets hot-respawned by the menubar
|
|
11
|
+
* supervisor, which Sentry-captures each death as "auto-sync watcher exited
|
|
12
|
+
* unexpectedly (code=Some(2))". Because the watcher typically runs healthily
|
|
13
|
+
* for minutes before the failing pass, the supervisor's fast-crash-loop
|
|
14
|
+
* dampening never engages and EVERY death ships an event — the July 2026
|
|
15
|
+
* #hq-sentry-alerts flood (~1,700 events across 60+ machines in 5 days).
|
|
16
|
+
* Instead the loop logs, stays alive, and re-attempts on the next tick; the
|
|
17
|
+
* per-file errors still surface through the ndjson `error` events and the
|
|
18
|
+
* `partial: true` all-complete aggregate.
|
|
19
|
+
*
|
|
20
|
+
* Kept off the other codes in use by the runner: 0 ok, 1 hard error, 17
|
|
21
|
+
* op-locked (`OPERATION_LOCKED_EXIT`), 75 transient network
|
|
22
|
+
* (`TRANSIENT_NETWORK_EXIT`).
|
|
23
|
+
*/
|
|
24
|
+
export const PARTIAL_SYNC_EXIT = 2;
|
package/src/object-io.test.ts
CHANGED
|
@@ -234,13 +234,78 @@ describe("PresignObjectIO.putObject", () => {
|
|
|
234
234
|
// per-path skip (scopeExcludedSet) + the "N paths skipped — outside your
|
|
235
235
|
// granted access" aggregate, instead of a fatal `type:"error"` that exits
|
|
236
236
|
// the runner non-zero and floods Sentry. This is the PUT/GET symmetry that
|
|
237
|
-
// fixes the
|
|
237
|
+
// fixes the presign-put-denied crash-loop across user machines.
|
|
238
238
|
expect((caught as Error).name).toBe("Forbidden");
|
|
239
239
|
expect(isAccessDenied(caught)).toBe(true);
|
|
240
240
|
// Denied at mint time — the byte-moving PUT is never attempted.
|
|
241
241
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
it("US-006 AC5: a transcript-consent denial (FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED) is skip-with-log, not fatal", async () => {
|
|
245
|
+
const { vault, setPresign } = makeVault();
|
|
246
|
+
// The US-005 consent code ends `_REQUIRED`, not `_FORBIDDEN`, so the plain
|
|
247
|
+
// suffix test alone would MISS it and let it fall through to the fatal
|
|
248
|
+
// generic Error — the recurring partial-failure this AC5 fix prevents.
|
|
249
|
+
setPresign([
|
|
250
|
+
{
|
|
251
|
+
key: "sessions/prs_1/2026-07-11T10-00-00.json",
|
|
252
|
+
op: "get",
|
|
253
|
+
error: "transcript consent required",
|
|
254
|
+
code: "FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED",
|
|
255
|
+
},
|
|
256
|
+
]);
|
|
257
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
258
|
+
// Monkeypatch console.warn (vitest's console interception can shadow a spy)
|
|
259
|
+
// to assert the REAL skip log fires.
|
|
260
|
+
const warnings: string[] = [];
|
|
261
|
+
const originalWarn = console.warn;
|
|
262
|
+
console.warn = (...args: unknown[]) => {
|
|
263
|
+
warnings.push(args.map(String).join(" "));
|
|
264
|
+
};
|
|
265
|
+
let caught: unknown;
|
|
266
|
+
try {
|
|
267
|
+
await io
|
|
268
|
+
.getObject("sessions/prs_1/2026-07-11T10-00-00.json")
|
|
269
|
+
.catch((e) => {
|
|
270
|
+
caught = e;
|
|
271
|
+
});
|
|
272
|
+
} finally {
|
|
273
|
+
console.warn = originalWarn;
|
|
274
|
+
}
|
|
275
|
+
// Same shape a denied GET already throws → isAccessDenied → the per-key
|
|
276
|
+
// scope-excluded skip, NOT a fatal `type:"error"` that poisons the run.
|
|
277
|
+
expect((caught as Error).name).toBe("Forbidden");
|
|
278
|
+
expect(isAccessDenied(caught)).toBe(true);
|
|
279
|
+
// A one-line skip log fires so operators can see WHY the key was skipped.
|
|
280
|
+
expect(
|
|
281
|
+
warnings.some((w) =>
|
|
282
|
+
w.includes("FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED"),
|
|
283
|
+
),
|
|
284
|
+
).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("US-006 AC5: a sessions structural-ACL denial (FILES_PRESIGN_SESSIONS_FORBIDDEN) is skip-with-log", async () => {
|
|
288
|
+
const { vault, setPresign } = makeVault();
|
|
289
|
+
// A spoofed/foreign `sessions/{uid}/` prefix the hq-pro structural gate
|
|
290
|
+
// refuses to presign. Ends `_FORBIDDEN`, so the suffix test already covers
|
|
291
|
+
// it — this pins that coverage as a regression contract for US-006.
|
|
292
|
+
setPresign([
|
|
293
|
+
{
|
|
294
|
+
key: "sessions/prs_other/y.json",
|
|
295
|
+
op: "get",
|
|
296
|
+
error: "not your session prefix",
|
|
297
|
+
code: "FILES_PRESIGN_SESSIONS_FORBIDDEN",
|
|
298
|
+
},
|
|
299
|
+
]);
|
|
300
|
+
const io = new PresignObjectIO(vault, COMPANY);
|
|
301
|
+
let caught: unknown;
|
|
302
|
+
await io.getObject("sessions/prs_other/y.json").catch((e) => {
|
|
303
|
+
caught = e;
|
|
304
|
+
});
|
|
305
|
+
expect((caught as Error).name).toBe("Forbidden");
|
|
306
|
+
expect(isAccessDenied(caught)).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
244
309
|
it("a non-authorization presign failure stays a generic (fatal) error — not silently skipped", async () => {
|
|
245
310
|
const { vault, setPresign } = makeVault();
|
|
246
311
|
setPresign([
|
package/src/object-io.ts
CHANGED
|
@@ -344,6 +344,35 @@ function metaFromHeaders(headers: Headers): Record<string, string> {
|
|
|
344
344
|
return meta;
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Per-key presign denial codes that are EXPECTED and must be reclassified as
|
|
349
|
+
* skip-with-log — throwing the `name: "Forbidden"` shape (see
|
|
350
|
+
* {@link accessDeniedError}) so `isAccessDenied` in sync.ts / share.ts routes
|
|
351
|
+
* them into the silent per-key scope-excluded skip, never a FATAL sync error.
|
|
352
|
+
*
|
|
353
|
+
* Two families qualify:
|
|
354
|
+
* 1. Any `*_FORBIDDEN` code — an authorization/ACL denial. Covers
|
|
355
|
+
* FILES_PRESIGN_FORBIDDEN (no write on a shared prefix), FILES_RAW_FORBIDDEN,
|
|
356
|
+
* and — the structural sessions-ACL rejection this US-006 phase relies on —
|
|
357
|
+
* FILES_PRESIGN_SESSIONS_FORBIDDEN (a spoofed/foreign `sessions/{uid}/`
|
|
358
|
+
* prefix the server refuses to presign).
|
|
359
|
+
* 2. FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED — the US-005 transcript-consent
|
|
360
|
+
* denial. It ends `_REQUIRED`, not `_FORBIDDEN`, so the suffix test alone
|
|
361
|
+
* would MISS it and let it fall through to the fatal generic Error below,
|
|
362
|
+
* re-introducing exactly the recurring partial-failure that poisons every
|
|
363
|
+
* subsequent sync run (US-006 AC5).
|
|
364
|
+
*/
|
|
365
|
+
const SKIP_WITH_LOG_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
366
|
+
"FILES_PRESIGN_TRANSCRIPT_CONSENT_REQUIRED",
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
function isSkipWithLogDenialCode(code: unknown): code is string {
|
|
370
|
+
return (
|
|
371
|
+
typeof code === "string" &&
|
|
372
|
+
(code.endsWith("_FORBIDDEN") || SKIP_WITH_LOG_DENIAL_CODES.has(code))
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
347
376
|
function firstRowOrThrow(
|
|
348
377
|
results: PresignResultRow[],
|
|
349
378
|
key: string,
|
|
@@ -354,22 +383,30 @@ function firstRowOrThrow(
|
|
|
354
383
|
throw new Error(`presign ${op} returned no row for ${key}`);
|
|
355
384
|
}
|
|
356
385
|
if (row.error || !row.url) {
|
|
357
|
-
// An
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// a
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
386
|
+
// An expected per-key denial (see {@link isSkipWithLogDenialCode}) must
|
|
387
|
+
// throw the SAME `name: "Forbidden"` shape the GET/HEAD path uses so it
|
|
388
|
+
// routes through the `isAccessDenied` skip in sync.ts / share.ts. Without
|
|
389
|
+
// this, a denied presign fell to the generic Error below — `isAccessDenied`
|
|
390
|
+
// returned false — and the per-file ACL skip the GET path handles gracefully
|
|
391
|
+
// became a FATAL sync error: the crash-loop + Sentry flood behind the
|
|
392
|
+
// widespread "presign put denied" incident (members pushing net-new keys to
|
|
393
|
+
// a company-wide-read prefix under private-by-default), and — for the
|
|
394
|
+
// sessions/consent codes — a recurring partial-failure that poisoned every
|
|
395
|
+
// subsequent sync run (US-006 AC5). The denial is expected; only the fatal
|
|
396
|
+
// handling of it was the bug. Log a one-line skip so the reason is visible
|
|
397
|
+
// without the run failing.
|
|
398
|
+
if (isSkipWithLogDenialCode(row.code)) {
|
|
399
|
+
// Shared presign layer: this fires for sync legs AND for non-sync callers
|
|
400
|
+
// (hq files get, signals/sources internals) where the denial surfaces as a
|
|
401
|
+
// user-facing error, not a continued sync. State only what THIS layer did
|
|
402
|
+
// (denied → skipped this row) and let the caller own any "sync continues"
|
|
403
|
+
// / error framing — don't assert an outcome the caller may not take.
|
|
404
|
+
console.warn(
|
|
405
|
+
`presign ${op} denied for '${key}' (${row.code}) — skipped`,
|
|
406
|
+
);
|
|
369
407
|
throw accessDeniedError(
|
|
370
408
|
key,
|
|
371
|
-
`no
|
|
372
|
-
`ask an admin to grant write on this shared prefix`,
|
|
409
|
+
`no ${op} access to '${key}' (server ${op} denied: ${row.code})`,
|
|
373
410
|
);
|
|
374
411
|
}
|
|
375
412
|
throw new Error(
|
|
@@ -73,6 +73,39 @@ describe("operation-lock", () => {
|
|
|
73
73
|
expect(lockPathFor(rootA + path.sep)).toBe(a);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
it("scopes partition the lock: same root, different scope → different file, no mutual block", () => {
|
|
77
|
+
// feedback_ed98d810: reindex must not be starved by a long-lived sync/watch
|
|
78
|
+
// holder. Distinct scopes hash to distinct lock files and never block each
|
|
79
|
+
// other, while the same scope stays a real cross-process mutex.
|
|
80
|
+
const opPath = lockPathFor(rootA); // default "operation" scope
|
|
81
|
+
const reindexPath = lockPathFor(rootA, "reindex");
|
|
82
|
+
expect(reindexPath).not.toBe(opPath);
|
|
83
|
+
expect(path.basename(reindexPath).startsWith("reindex-")).toBe(true);
|
|
84
|
+
expect(path.basename(opPath).startsWith("operation-")).toBe(true);
|
|
85
|
+
|
|
86
|
+
let opHandle: ReturnType<typeof acquireOperationLock> | undefined;
|
|
87
|
+
let reindexHandle: ReturnType<typeof acquireOperationLock> | undefined;
|
|
88
|
+
try {
|
|
89
|
+
// A live "operation" holder (as a watch-mode runner keeps) does NOT block
|
|
90
|
+
// a "reindex"-scope acquire, even with no wait.
|
|
91
|
+
opHandle = acquireOperationLock(rootA, "sync");
|
|
92
|
+
expect(() => {
|
|
93
|
+
reindexHandle = acquireOperationLock(rootA, "reindex", {
|
|
94
|
+
scope: "reindex",
|
|
95
|
+
wait: false,
|
|
96
|
+
});
|
|
97
|
+
}).not.toThrow();
|
|
98
|
+
expect(reindexHandle && fs.existsSync(reindexHandle.path)).toBe(true);
|
|
99
|
+
// ...but a second acquire on the SAME "reindex" scope is still refused.
|
|
100
|
+
expect(() =>
|
|
101
|
+
acquireOperationLock(rootA, "reindex", { scope: "reindex", wait: false }),
|
|
102
|
+
).toThrowError(OperationLockedError);
|
|
103
|
+
} finally {
|
|
104
|
+
reindexHandle?.release();
|
|
105
|
+
opHandle?.release();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
76
109
|
it("F14: symlink aliases share the same root lock", () => {
|
|
77
110
|
const aliasParent = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rootA-link-"));
|
|
78
111
|
const aliasRoot = path.join(aliasParent, "alias");
|
package/src/operation-lock.ts
CHANGED
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
* (`sync`, `rescue`, `reindex`).
|
|
4
4
|
*
|
|
5
5
|
* Contract:
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* - `sync` and `rescue` share ONE per-root lock (the "operation" scope, keyed
|
|
7
|
+
* only by the root, not the command), so at most one of them runs at a time
|
|
8
|
+
* and e.g. a rescue refuses while a sync holds it. Different HQ roots are
|
|
9
|
+
* fully independent — they hash to different lock files.
|
|
10
|
+
* - `reindex` uses a SEPARATE per-root scope ("reindex"), so it is guarded
|
|
11
|
+
* against other reindexes but is NOT blocked by a held sync/rescue lock.
|
|
12
|
+
* This is deliberate: a watch-mode `hq-sync-runner` holds the "operation"
|
|
13
|
+
* lock across its entire lifetime, and a shared lock would starve a
|
|
14
|
+
* standalone `hq reindex` indefinitely (feedback_ed98d810). reindex only
|
|
15
|
+
* READS the source trees to rebuild derived artifacts (skill wrappers,
|
|
16
|
+
* overlay mirrors, the workers registry) and is idempotent + re-run after
|
|
17
|
+
* every sync pass, so decoupling it from the sync writer lock is safe.
|
|
18
|
+
* See {@link lockPathFor} for how the scope partitions the lock file.
|
|
10
19
|
* - The push watcher / watch+event-push runner is EXEMPT: it never calls in
|
|
11
20
|
* here, so it neither takes the lock nor is blocked by it (its targeted
|
|
12
21
|
* in-process push passes are likewise lock-free).
|
|
@@ -202,6 +211,13 @@ export interface AcquireOptions {
|
|
|
202
211
|
* (or a no-op) to redirect/silence the status line.
|
|
203
212
|
*/
|
|
204
213
|
onWaitStart?: (holder: LockInfo, attempted: string) => void;
|
|
214
|
+
/**
|
|
215
|
+
* Lock scope — partitions the per-root mutex into independent lock files (see
|
|
216
|
+
* {@link lockPathFor}). Defaults to {@link DEFAULT_LOCK_SCOPE} ("operation"),
|
|
217
|
+
* which `sync`/`rescue` share. `reindex` passes "reindex" so it is guarded
|
|
218
|
+
* against other reindexes without being blocked by a held sync/rescue lock.
|
|
219
|
+
*/
|
|
220
|
+
scope?: string;
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
interface ResolvedWaitConfig {
|
|
@@ -280,11 +296,26 @@ function canonicalRoot(hqRoot: string): string {
|
|
|
280
296
|
}
|
|
281
297
|
}
|
|
282
298
|
|
|
283
|
-
/**
|
|
284
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Default lock scope. `sync` and `rescue` share this one so they stay mutually
|
|
301
|
+
* exclusive with each other, keyed only by the root.
|
|
302
|
+
*/
|
|
303
|
+
export const DEFAULT_LOCK_SCOPE = "operation";
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Absolute lock path for a given HQ root and `scope`. Exported for tests.
|
|
307
|
+
*
|
|
308
|
+
* The `scope` prefixes the lock filename so callers can partition the mutex:
|
|
309
|
+
* `sync`/`rescue` use {@link DEFAULT_LOCK_SCOPE} ("operation"); `reindex` uses
|
|
310
|
+
* its own "reindex" scope so a long-lived watch-mode sync-runner (which holds
|
|
311
|
+
* the "operation" lock across its whole lifetime) can never starve a standalone
|
|
312
|
+
* `hq reindex`. Different scopes hash to different lock files and never block
|
|
313
|
+
* one another; the same scope is a real cross-process mutex.
|
|
314
|
+
*/
|
|
315
|
+
export function lockPathFor(hqRoot: string, scope: string = DEFAULT_LOCK_SCOPE): string {
|
|
285
316
|
const canon = canonicalRoot(hqRoot);
|
|
286
317
|
const key = crypto.createHash("sha1").update(canon).digest("hex").slice(0, 16);
|
|
287
|
-
return path.join(stateDir(), "locks",
|
|
318
|
+
return path.join(stateDir(), "locks", `${scope}-${key}.lock`);
|
|
288
319
|
}
|
|
289
320
|
|
|
290
321
|
/**
|
|
@@ -425,8 +456,12 @@ export function rethrowLockCreateError(err: unknown, lockDir: string): never {
|
|
|
425
456
|
}
|
|
426
457
|
|
|
427
458
|
/** Build the lock payload + ensure the locks dir exists. */
|
|
428
|
-
function prepareLock(
|
|
429
|
-
|
|
459
|
+
function prepareLock(
|
|
460
|
+
hqRoot: string,
|
|
461
|
+
command: string,
|
|
462
|
+
scope: string = DEFAULT_LOCK_SCOPE,
|
|
463
|
+
): { p: string; info: LockInfo; payload: string } {
|
|
464
|
+
const p = lockPathFor(hqRoot, scope);
|
|
430
465
|
const dir = path.dirname(p);
|
|
431
466
|
try {
|
|
432
467
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -565,7 +600,7 @@ export function acquireOperationLock(
|
|
|
565
600
|
): LockHandle {
|
|
566
601
|
if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
|
|
567
602
|
|
|
568
|
-
const { p, info, payload } = prepareLock(hqRoot, command);
|
|
603
|
+
const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
|
|
569
604
|
const cfg = resolveWaitConfig(opts);
|
|
570
605
|
const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
|
|
571
606
|
let announced = false;
|
|
@@ -596,7 +631,7 @@ export async function acquireOperationLockAsync(
|
|
|
596
631
|
): Promise<LockHandle> {
|
|
597
632
|
if (process.env.HQ_DISABLE_OP_LOCK === "1") return disabledHandle(hqRoot, command);
|
|
598
633
|
|
|
599
|
-
const { p, info, payload } = prepareLock(hqRoot, command);
|
|
634
|
+
const { p, info, payload } = prepareLock(hqRoot, command, opts.scope);
|
|
600
635
|
const cfg = resolveWaitConfig(opts);
|
|
601
636
|
const deadline = cfg.timeoutMs === null ? null : Date.now() + cfg.timeoutMs;
|
|
602
637
|
let announced = false;
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
CONTINUITY_POINTER_REL,
|
|
26
26
|
computeAgencySyncPaths,
|
|
27
27
|
AGENCY_SYNC_REL,
|
|
28
|
+
computeSessionLogsSyncPaths,
|
|
29
|
+
SESSION_LOGS_SYNC_REL,
|
|
28
30
|
PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
|
|
29
31
|
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
|
30
32
|
} from "./personal-vault.js";
|
|
@@ -628,3 +630,58 @@ describe("personal-vault: agency-workspace carve-out", () => {
|
|
|
628
630
|
);
|
|
629
631
|
});
|
|
630
632
|
});
|
|
633
|
+
|
|
634
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
635
|
+
// Session-logs carve-out (workspace/.session-logs) — reindex-captured Claude
|
|
636
|
+
// Code transcripts pierce the workspace/ exclusion and round-trip to the vault.
|
|
637
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
638
|
+
describe("personal-vault: session-logs carve-out", () => {
|
|
639
|
+
let hqRoot: string;
|
|
640
|
+
beforeEach(() => {
|
|
641
|
+
hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sesslog-test-"));
|
|
642
|
+
});
|
|
643
|
+
afterEach(() => {
|
|
644
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
645
|
+
});
|
|
646
|
+
const relAll = (abs: string[]): string[] =>
|
|
647
|
+
abs.map((p) => path.relative(hqRoot, p)).sort();
|
|
648
|
+
|
|
649
|
+
it("constant: SESSION_LOGS_SYNC_REL is workspace/.session-logs", () => {
|
|
650
|
+
expect(SESSION_LOGS_SYNC_REL).toBe("workspace/.session-logs");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("includes workspace/.session-logs when it exists", () => {
|
|
654
|
+
fs.mkdirSync(path.join(hqRoot, "workspace", ".session-logs", "tool-results"), {
|
|
655
|
+
recursive: true,
|
|
656
|
+
});
|
|
657
|
+
expect(relAll(computeSessionLogsSyncPaths(hqRoot))).toEqual([
|
|
658
|
+
path.join("workspace", ".session-logs"),
|
|
659
|
+
]);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("empty when workspace/.session-logs is absent (fail-soft)", () => {
|
|
663
|
+
fs.mkdirSync(path.join(hqRoot, "workspace"), { recursive: true });
|
|
664
|
+
expect(computeSessionLogsSyncPaths(hqRoot)).toEqual([]);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("composes into computePersonalVaultPaths without broadening workspace/", () => {
|
|
668
|
+
fs.mkdirSync(path.join(hqRoot, "workspace", ".session-logs"), { recursive: true });
|
|
669
|
+
fs.writeFileSync(
|
|
670
|
+
path.join(hqRoot, "workspace", ".session-logs", "session.jsonl"),
|
|
671
|
+
"l\n",
|
|
672
|
+
);
|
|
673
|
+
// A sibling under workspace/ that must STAY local.
|
|
674
|
+
fs.mkdirSync(path.join(hqRoot, "workspace", "locks"), { recursive: true });
|
|
675
|
+
fs.writeFileSync(path.join(hqRoot, "workspace", "locks", "x.lock"), "1");
|
|
676
|
+
fs.mkdirSync(path.join(hqRoot, ".claude"), { recursive: true });
|
|
677
|
+
|
|
678
|
+
const out = relAll(computePersonalVaultPaths(hqRoot));
|
|
679
|
+
expect(out).toContain(path.join("workspace", ".session-logs"));
|
|
680
|
+
expect(out).toContain(".claude");
|
|
681
|
+
// The carve-out is exactly the .session-logs subtree — no other workspace/
|
|
682
|
+
// sibling is pulled in.
|
|
683
|
+
expect(out.some((p) => p.startsWith(path.join("workspace", "locks")))).toBe(
|
|
684
|
+
false,
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
});
|
package/src/personal-vault.ts
CHANGED
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
* the routing source-of-truth.)
|
|
27
27
|
* - `repos/`, `workspace/`: per user directive — heavy local-only
|
|
28
28
|
* content (cloned remotes, session threads) that has no business in
|
|
29
|
-
* the personal vault.
|
|
29
|
+
* the personal vault. Three explicit sub-paths pierce this exclusion and
|
|
30
|
+
* DO round-trip: `workspace/threads/handoff.json` (+ its active thread) via
|
|
31
|
+
* {@link computeContinuityPointerPaths}, `workspace/agency/` via
|
|
32
|
+
* {@link computeAgencySyncPaths}, and `workspace/.session-logs/` (the
|
|
33
|
+
* reindex-captured Claude Code transcripts) via
|
|
34
|
+
* {@link computeSessionLogsSyncPaths}.
|
|
30
35
|
*
|
|
31
36
|
* Note: `core/`, `data/`, and `personal/` were previously excluded but are
|
|
32
37
|
* INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core
|
|
@@ -134,7 +139,15 @@ export function computePersonalVaultPaths(
|
|
|
134
139
|
);
|
|
135
140
|
const continuity = computeContinuityPointerPaths(hqRoot);
|
|
136
141
|
const agency = computeAgencySyncPaths(hqRoot);
|
|
137
|
-
|
|
142
|
+
const sessionLogs = computeSessionLogsSyncPaths(hqRoot);
|
|
143
|
+
return [
|
|
144
|
+
...topLevel,
|
|
145
|
+
...manifest,
|
|
146
|
+
...companySubdirs,
|
|
147
|
+
...continuity,
|
|
148
|
+
...agency,
|
|
149
|
+
...sessionLogs,
|
|
150
|
+
];
|
|
138
151
|
}
|
|
139
152
|
|
|
140
153
|
/**
|
|
@@ -261,6 +274,39 @@ export function computeAgencySyncPaths(hqRoot: string): string[] {
|
|
|
261
274
|
return [];
|
|
262
275
|
}
|
|
263
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Fixed relative path (forward-slash, hq-root-relative) of the session-logs
|
|
279
|
+
* subtree. See {@link computeSessionLogsSyncPaths}.
|
|
280
|
+
*/
|
|
281
|
+
export const SESSION_LOGS_SYNC_REL = "workspace/.session-logs";
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Compute the absolute path of the session-logs carve-out:
|
|
285
|
+
* `workspace/.session-logs/` (the whole subtree).
|
|
286
|
+
*
|
|
287
|
+
* `hq reindex` copies this HQ root's Claude Code session transcripts here (see
|
|
288
|
+
* reindex's copySessionLogs). Like the agency carve-out above, this pierces the
|
|
289
|
+
* `workspace/` top-level exclusion for ONE specific subtree so those raw
|
|
290
|
+
* transcripts round-trip to the personal vault / S3 — the operator's explicit
|
|
291
|
+
* directive is that the session logs travel with the vault. The rest of
|
|
292
|
+
* `workspace/` (session scratch, locks, reports, worktrees) stays machine-local.
|
|
293
|
+
*
|
|
294
|
+
* Returns the directory path when it exists; share()'s per-file walk handles
|
|
295
|
+
* recursion, and the nested personal-vault exclusions still apply to files
|
|
296
|
+
* inside it. Fail-soft: a missing or unreadable `workspace/.session-logs/`
|
|
297
|
+
* returns []. Callers tolerate empty arrays — same contract as the manifest,
|
|
298
|
+
* continuity, and agency special-cases.
|
|
299
|
+
*/
|
|
300
|
+
export function computeSessionLogsSyncPaths(hqRoot: string): string[] {
|
|
301
|
+
const sessionLogsDir = path.join(hqRoot, "workspace", ".session-logs");
|
|
302
|
+
try {
|
|
303
|
+
if (fs.statSync(sessionLogsDir).isDirectory()) return [sessionLogsDir];
|
|
304
|
+
} catch {
|
|
305
|
+
// No session logs captured on this machine yet — nothing to carry.
|
|
306
|
+
}
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
|
|
264
310
|
/**
|
|
265
311
|
* Discover `companies/{slug}/` subdirs that should sync to the personal
|
|
266
312
|
* bucket as a fallback for companies the operator has not designated as
|
package/src/s3.test.ts
CHANGED
|
@@ -93,6 +93,7 @@ import {
|
|
|
93
93
|
decodeSymlinkMetadataValue,
|
|
94
94
|
FILE_MTIME_META_KEY,
|
|
95
95
|
FILE_BTIME_META_KEY,
|
|
96
|
+
validateVaultUploadKey,
|
|
96
97
|
} from "./s3.js";
|
|
97
98
|
import {
|
|
98
99
|
setObjectIOFactory,
|
|
@@ -1384,3 +1385,89 @@ describe("downloadFile", () => {
|
|
|
1384
1385
|
expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
|
|
1385
1386
|
});
|
|
1386
1387
|
});
|
|
1388
|
+
|
|
1389
|
+
describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 2026-07-11)", () => {
|
|
1390
|
+
// The direct-S3 STS transport bypasses hq-pro's server-side validateObjectKey
|
|
1391
|
+
// (files-acl.ts), so uploadFile/uploadSymlink must enforce the SAME rules
|
|
1392
|
+
// before the PUT. Incident: a stale doubled local tree
|
|
1393
|
+
// (companies/indigo/companies/indigo/…) pushed 25 literal `companies/…` keys
|
|
1394
|
+
// into a company vault; the presign download API then rejected every one
|
|
1395
|
+
// (INVALID_KEY_COMPANIES_SCOPED), erroring the whole company sync fleet-wide.
|
|
1396
|
+
let tmpFile: string;
|
|
1397
|
+
|
|
1398
|
+
function makePersonalCtx(): EntityContext {
|
|
1399
|
+
return { ...makeCtx(), uid: "prs_TEST" };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
beforeEach(() => {
|
|
1403
|
+
sentCommands.length = 0;
|
|
1404
|
+
tmpFile = path.join(
|
|
1405
|
+
os.tmpdir(),
|
|
1406
|
+
`s3-keyguard-test-${Date.now()}-${Math.random()}.md`,
|
|
1407
|
+
);
|
|
1408
|
+
fs.writeFileSync(tmpFile, "hello");
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
afterEach(() => {
|
|
1412
|
+
try {
|
|
1413
|
+
fs.unlinkSync(tmpFile);
|
|
1414
|
+
} catch {
|
|
1415
|
+
// already gone
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
it("validateVaultUploadKey rejects companies/-prefixed keys in company scope with the server's error code", () => {
|
|
1420
|
+
expect(() =>
|
|
1421
|
+
validateVaultUploadKey("companies/indigo/knowledge/x.md", "company"),
|
|
1422
|
+
).toThrowError(
|
|
1423
|
+
expect.objectContaining({ code: "INVALID_KEY_COMPANIES_SCOPED" }),
|
|
1424
|
+
);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
it("validateVaultUploadKey allows companies/-prefixed keys in personal scope (local non-cloud companies)", () => {
|
|
1428
|
+
expect(() =>
|
|
1429
|
+
validateVaultUploadKey("companies/indigo/knowledge/x.md", "personal"),
|
|
1430
|
+
).not.toThrow();
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it.each([
|
|
1434
|
+
["", "INVALID_KEY_EMPTY"],
|
|
1435
|
+
["/leading.md", "INVALID_KEY_LEADING_SLASH"],
|
|
1436
|
+
["folder/", "INVALID_KEY_TRAILING_SLASH"],
|
|
1437
|
+
["a//b.md", "INVALID_KEY_DOUBLE_SLASH"],
|
|
1438
|
+
["a/../b.md", "INVALID_KEY_DOT_COMPONENT"],
|
|
1439
|
+
["a/./b.md", "INVALID_KEY_DOT_COMPONENT"],
|
|
1440
|
+
["bad\x00key.md", "INVALID_KEY_CONTROL_CHARS"],
|
|
1441
|
+
])(
|
|
1442
|
+
"validateVaultUploadKey mirrors the server rule set: %j → %s",
|
|
1443
|
+
(key, code) => {
|
|
1444
|
+
expect(() => validateVaultUploadKey(key, "company")).toThrowError(
|
|
1445
|
+
expect.objectContaining({ code }),
|
|
1446
|
+
);
|
|
1447
|
+
expect(() => validateVaultUploadKey(key, "personal")).toThrowError(
|
|
1448
|
+
expect.objectContaining({ code }),
|
|
1449
|
+
);
|
|
1450
|
+
},
|
|
1451
|
+
);
|
|
1452
|
+
|
|
1453
|
+
it("uploadFile refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
|
|
1454
|
+
await expect(
|
|
1455
|
+
uploadFile(makeCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
|
|
1456
|
+
).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
|
|
1457
|
+
expect(sentCommands.length).toBe(0);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it("uploadSymlink refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
|
|
1461
|
+
await expect(
|
|
1462
|
+
uploadSymlink(makeCtx(), "../target.md", "companies/indigo/link.md"),
|
|
1463
|
+
).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
|
|
1464
|
+
expect(sentCommands.length).toBe(0);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it("uploadFile still allows companies/-prefixed keys for a personal (prs_) vault", async () => {
|
|
1468
|
+
await expect(
|
|
1469
|
+
uploadFile(makePersonalCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
|
|
1470
|
+
).resolves.toBeDefined();
|
|
1471
|
+
expect(sentCommands.length).toBeGreaterThan(0);
|
|
1472
|
+
});
|
|
1473
|
+
});
|