@indigoai-us/hq-cloud 6.14.36 → 6.14.37
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.map +1 -1
- package/dist/bin/sync-runner-company.js +20 -3
- package/dist/bin/sync-runner-company.js.map +1 -1
- 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.map +1 -1
- package/dist/bin/sync-runner.test.js +2 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +179 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +504 -26
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +639 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +24 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +45 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +34 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +19 -3
- package/src/bin/sync-runner.test.ts +2 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/share.test.ts +794 -2
- package/src/cli/share.ts +624 -26
- package/src/cli/sync.test.ts +42 -0
- package/src/cli/sync.ts +78 -3
package/src/cli/sync.test.ts
CHANGED
|
@@ -2782,6 +2782,48 @@ describe("sync", () => {
|
|
|
2782
2782
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
2783
2783
|
});
|
|
2784
2784
|
|
|
2785
|
+
it("skips remote keys under companies/<slug>/ in a company vault (doubly-scoped corrupt object — frogbear exit-2 regression)", async () => {
|
|
2786
|
+
// A company vault is already anchored at its company root, so its keys are
|
|
2787
|
+
// bucket-relative. A remote key literally beginning with `companies/...` is
|
|
2788
|
+
// a doubly-scoped corrupt object: the vault-service refuses to presign it
|
|
2789
|
+
// (INVALID_KEY_COMPANIES_SCOPED), so the per-file GET fails and the whole
|
|
2790
|
+
// company wedges at "errored" (runner exit 2) on every run. Verified live
|
|
2791
|
+
// 2026-06-16 against frogbear. The pull planner must refuse it at planning
|
|
2792
|
+
// time — same policy bucket as the malformed-key / ephemeral filters.
|
|
2793
|
+
const corruptKey = "companies/acme/drafts/reports/signals-2026-06-15.html";
|
|
2794
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
2795
|
+
// Doubly-scoped corrupt key — must be filtered, never downloaded.
|
|
2796
|
+
{ key: corruptKey, size: 2152, lastModified: new Date(), etag: '"corrupt"' },
|
|
2797
|
+
// A legitimate bucket-relative key — must still download.
|
|
2798
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"ok"' },
|
|
2799
|
+
]);
|
|
2800
|
+
|
|
2801
|
+
const result = await sync({
|
|
2802
|
+
company: "acme",
|
|
2803
|
+
vaultConfig: mockConfig,
|
|
2804
|
+
hqRoot: tmpDir,
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2808
|
+
// The corrupt key MUST NOT be materialized at its doubled local path.
|
|
2809
|
+
expect(
|
|
2810
|
+
fs.existsSync(
|
|
2811
|
+
path.join(companyRoot, "companies", "acme", "drafts", "reports", "signals-2026-06-15.html"),
|
|
2812
|
+
),
|
|
2813
|
+
).toBe(false);
|
|
2814
|
+
// The vault-service GET must never be attempted for the corrupt key — the
|
|
2815
|
+
// failing presign is the exact symptom this guard removes.
|
|
2816
|
+
for (const call of vi.mocked(s3Module.downloadFile).mock.calls) {
|
|
2817
|
+
expect(call[1]).not.toBe(corruptKey);
|
|
2818
|
+
}
|
|
2819
|
+
// The legitimate bucket-relative key MUST still download.
|
|
2820
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "handoff.md"))).toBe(true);
|
|
2821
|
+
|
|
2822
|
+
expect(result.filesDownloaded).toBe(1);
|
|
2823
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
2824
|
+
expect(result.aborted).toBe(false);
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2785
2827
|
it("F02: rejects traversal remote keys before they can escape the company root", async () => {
|
|
2786
2828
|
const escapeName = `${path.basename(tmpDir)}-escaped.md`;
|
|
2787
2829
|
const traversalKey = `../../../${escapeName}`;
|
package/src/cli/sync.ts
CHANGED
|
@@ -78,7 +78,7 @@ import {
|
|
|
78
78
|
resolveActiveCompany,
|
|
79
79
|
resolveTransferConcurrency,
|
|
80
80
|
} from "../sync-core.js";
|
|
81
|
-
import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
|
|
81
|
+
import { isEphemeralPath, isForbiddenCompanyVaultKey, isMalformedVaultKey } from "./share.js";
|
|
82
82
|
import { resolveConflict } from "./conflict.js";
|
|
83
83
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
84
84
|
import {
|
|
@@ -433,6 +433,31 @@ export type SyncProgressEvent =
|
|
|
433
433
|
path: string;
|
|
434
434
|
/** The already-journaled spelling that owns this logical path. */
|
|
435
435
|
journaledKey: string;
|
|
436
|
+
}
|
|
437
|
+
| {
|
|
438
|
+
/**
|
|
439
|
+
* Emitted at most ONCE per `reason` per `share()` push leg when local
|
|
440
|
+
* content the walk saw was deliberately NOT uploaded and would otherwise
|
|
441
|
+
* vanish from every push bucket without a trace (feedback_258e4a86 /
|
|
442
|
+
* feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success
|
|
443
|
+
* named nothing that was skipped). `reason` distinguishes the cause:
|
|
444
|
+
*
|
|
445
|
+
* - `"unreachable-path"`: a path the caller EXPLICITLY named exists on
|
|
446
|
+
* disk but the resolver could not place it under the company folder,
|
|
447
|
+
* or could not find it under any base. This is actionable signal — the
|
|
448
|
+
* named target was NOT shipped — so the CLI may treat it as an error.
|
|
449
|
+
* - `"linked-subtree"`: a directory symlink whose target lives OUTSIDE
|
|
450
|
+
* the company folder was recorded as a link but its contents were not
|
|
451
|
+
* descended/uploaded. They sync via their own repo, not the vault;
|
|
452
|
+
* this is informational, not an error.
|
|
453
|
+
*
|
|
454
|
+
* `count` is the number of distinct paths for that reason; `samplePaths`
|
|
455
|
+
* carries up to 10 for display. Not emitted when `count === 0`.
|
|
456
|
+
*/
|
|
457
|
+
type: "not-shipped";
|
|
458
|
+
reason: "unreachable-path" | "linked-subtree";
|
|
459
|
+
count: number;
|
|
460
|
+
samplePaths: string[];
|
|
436
461
|
};
|
|
437
462
|
|
|
438
463
|
export interface SyncOptions {
|
|
@@ -2432,6 +2457,19 @@ function computePullPlan(
|
|
|
2432
2457
|
continue;
|
|
2433
2458
|
}
|
|
2434
2459
|
|
|
2460
|
+
// Company-vault double-scoped-key filter. A company vault is already
|
|
2461
|
+
// anchored at its company root, so a remote key under `companies/...` is a
|
|
2462
|
+
// corrupt doubly-scoped object the vault-service refuses to presign
|
|
2463
|
+
// (INVALID_KEY_COMPANIES_SCOPED). Without this, the puller enqueues it, the
|
|
2464
|
+
// per-file GET fails, and the whole company wedges at "errored" (runner
|
|
2465
|
+
// exit 2) on every run (frogbear, 2026-06-16). Refuse at plan time,
|
|
2466
|
+
// symmetric with the malformed/ephemeral filters above. Personal vaults
|
|
2467
|
+
// handle `companies/...` keys in the dedicated branch below.
|
|
2468
|
+
if (isForbiddenCompanyVaultKey(remoteFile.key, personalMode)) {
|
|
2469
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2435
2473
|
// Personal-vault policy must be symmetric across both transfer legs.
|
|
2436
2474
|
// The push walker refuses derived/machine-local/sensitive paths, but old
|
|
2437
2475
|
// objects can remain in the bucket from clients that predate an exclusion.
|
|
@@ -3085,6 +3123,13 @@ function computePullPlan(
|
|
|
3085
3123
|
}
|
|
3086
3124
|
const localPath = resolveContainedVaultPath(companyRoot, key);
|
|
3087
3125
|
if (localPath === null) continue;
|
|
3126
|
+
// Scope-invalid journal keys (company mode, `companies/…` prefix): the
|
|
3127
|
+
// download branch skips them (presign rejects), but a journaled entry whose
|
|
3128
|
+
// key is absent from the remote LIST is doubled-tree poison that must
|
|
3129
|
+
// drain — local copy under companies/{slug}/companies/{slug}/… removed and
|
|
3130
|
+
// journal dropped. HEAD verify is skipped in verifyPlannedJournalTombstones
|
|
3131
|
+
// (presign would reject); see the auto-verified branch there.
|
|
3132
|
+
const scopeInvalidJournalKey = isForbiddenCompanyVaultKey(key, personalMode);
|
|
3088
3133
|
// PersonalMode key gating — mirror the download branch. Local (non-cloud)
|
|
3089
3134
|
// company keys are tombstone-eligible (a peer's delete should propagate),
|
|
3090
3135
|
// but team-synced orphans are left alone (the team-bucket pull owns those
|
|
@@ -3101,8 +3146,15 @@ function computePullPlan(
|
|
|
3101
3146
|
if (isEphemeralPath(key)) continue;
|
|
3102
3147
|
// Honor the current ignore filter — if a path was previously synced
|
|
3103
3148
|
// but is now ignored (operator edited .hqignore), do NOT delete
|
|
3104
|
-
// the local copy. They're keeping it deliberately.
|
|
3105
|
-
|
|
3149
|
+
// the local copy. They're keeping it deliberately. Scope-invalid poison
|
|
3150
|
+
// bypasses this: the doubled-tree copy must not survive silently.
|
|
3151
|
+
if (
|
|
3152
|
+
!scopeInvalidJournalKey &&
|
|
3153
|
+
!shouldSync(localPath, false) &&
|
|
3154
|
+
!shouldSync(localPath, true)
|
|
3155
|
+
) {
|
|
3156
|
+
continue;
|
|
3157
|
+
}
|
|
3106
3158
|
// Codex P1 (PR #24 round 3): detect local edits before tombstoning.
|
|
3107
3159
|
// Delete-vs-local-edit race: peer deleted the file remotely while
|
|
3108
3160
|
// this machine edited it locally before the next sync. Without
|
|
@@ -3272,5 +3324,28 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
3272
3324
|
console.warn(
|
|
3273
3325
|
` ! ${event.path} skipped — junk key spelling; this logical path is already synced as ${event.journaledKey} (mixed-version peer artifact; the vault doctor collapses the family)`,
|
|
3274
3326
|
);
|
|
3327
|
+
} else if (event.type === "not-shipped") {
|
|
3328
|
+
if (event.reason === "unreachable-path") {
|
|
3329
|
+
console.warn(
|
|
3330
|
+
` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
|
|
3331
|
+
);
|
|
3332
|
+
} else {
|
|
3333
|
+
console.warn(
|
|
3334
|
+
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
3335
|
+
);
|
|
3336
|
+
}
|
|
3337
|
+
for (const p of event.samplePaths) {
|
|
3338
|
+
console.warn(` · ${p}`);
|
|
3339
|
+
}
|
|
3340
|
+
if (event.count > event.samplePaths.length) {
|
|
3341
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
3342
|
+
}
|
|
3343
|
+
if (event.reason === "linked-subtree") {
|
|
3344
|
+
console.warn(
|
|
3345
|
+
` To put a file from a linked subtree in the vault, push it by name:\n` +
|
|
3346
|
+
` hq sync push <path> --company <slug>\n` +
|
|
3347
|
+
` That uploads a point-in-time copy; re-run it after each edit (regular sync will not track or update it).`,
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3275
3350
|
}
|
|
3276
3351
|
}
|