@indigoai-us/hq-cloud 6.14.36 → 6.14.39
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 +46 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +1 -9
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/share.d.ts +178 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +555 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +780 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +27 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +113 -13
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +188 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/readlink-safe.d.ts +11 -0
- package/dist/lib/readlink-safe.d.ts.map +1 -0
- package/dist/lib/readlink-safe.js +27 -0
- package/dist/lib/readlink-safe.js.map +1 -0
- package/dist/lib/readlink-safe.test.d.ts +2 -0
- package/dist/lib/readlink-safe.test.d.ts.map +1 -0
- package/dist/lib/readlink-safe.test.js +34 -0
- package/dist/lib/readlink-safe.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +19 -3
- package/src/bin/sync-runner.test.ts +54 -0
- package/src/bin/sync-runner.ts +4 -0
- package/src/cli/reindex.ts +1 -9
- package/src/cli/share.test.ts +974 -2
- package/src/cli/share.ts +675 -32
- package/src/cli/sync.test.ts +209 -0
- package/src/cli/sync.ts +151 -13
- package/src/lib/readlink-safe.test.ts +43 -0
- package/src/lib/readlink-safe.ts +29 -0
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/src/cli/sync.test.ts
CHANGED
|
@@ -10,6 +10,13 @@ import { clearContextCache } from "../context.js";
|
|
|
10
10
|
import type { VaultServiceConfig } from "../types.js";
|
|
11
11
|
import { lockPathFor } from "../operation-lock.js";
|
|
12
12
|
|
|
13
|
+
// Re-export node:fs as a mutable module so unreadable-link regressions can
|
|
14
|
+
// inject a win32 EINVAL without needing a real Windows reparse point.
|
|
15
|
+
vi.mock("fs", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import("fs")>();
|
|
17
|
+
return { ...actual };
|
|
18
|
+
});
|
|
19
|
+
|
|
13
20
|
// Mock s3 module at the top level
|
|
14
21
|
vi.mock("../s3.js", async (importOriginal) => {
|
|
15
22
|
const actual = await importOriginal<typeof import("../s3.js")>();
|
|
@@ -67,6 +74,12 @@ const mockConfig: VaultServiceConfig = {
|
|
|
67
74
|
region: "us-east-1",
|
|
68
75
|
};
|
|
69
76
|
|
|
77
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
78
|
+
const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
|
|
79
|
+
err.code = code;
|
|
80
|
+
return err;
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
const mockEntity = {
|
|
71
84
|
uid: "cmp_01ABCDEF",
|
|
72
85
|
slug: "acme",
|
|
@@ -383,6 +396,52 @@ describe("sync", () => {
|
|
|
383
396
|
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
384
397
|
});
|
|
385
398
|
|
|
399
|
+
it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
|
|
400
|
+
const linkKey = "policies/downloaded-unreadable-link";
|
|
401
|
+
const linkPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
402
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
403
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
404
|
+
]);
|
|
405
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
406
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
407
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
408
|
+
fs.symlinkSync("missing-target.md", localPath);
|
|
409
|
+
return { metadata: {} };
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
413
|
+
const readlinkSpy = vi
|
|
414
|
+
.spyOn(fs, "readlinkSync")
|
|
415
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
416
|
+
if (candidate === linkPath) throw errnoError("EINVAL");
|
|
417
|
+
return realReadlinkSync(candidate);
|
|
418
|
+
}) as typeof fs.readlinkSync);
|
|
419
|
+
const events: SyncProgressEvent[] = [];
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const result = await sync({
|
|
423
|
+
company: "acme",
|
|
424
|
+
vaultConfig: mockConfig,
|
|
425
|
+
hqRoot: tmpDir,
|
|
426
|
+
onEvent: (event) => events.push(event),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
expect(result.filesDownloaded).toBe(0);
|
|
430
|
+
expect(result.filesSkipped).toBe(1);
|
|
431
|
+
expect(events).toContainEqual({
|
|
432
|
+
type: "not-shipped",
|
|
433
|
+
reason: "unreadable-link",
|
|
434
|
+
count: 1,
|
|
435
|
+
samplePaths: [linkKey],
|
|
436
|
+
});
|
|
437
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
438
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
439
|
+
expect(journal.files[linkKey]).toBeUndefined();
|
|
440
|
+
} finally {
|
|
441
|
+
readlinkSpy.mockRestore();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
386
445
|
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
387
446
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
388
447
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -505,6 +564,73 @@ describe("sync", () => {
|
|
|
505
564
|
expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
506
565
|
});
|
|
507
566
|
|
|
567
|
+
it("defers a conflict whose downloaded link target cannot be read", async () => {
|
|
568
|
+
const linkKey = "docs/handoff.md";
|
|
569
|
+
const localPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
570
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
571
|
+
fs.writeFileSync(localPath, "local version");
|
|
572
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
573
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"remote-link"' },
|
|
574
|
+
]);
|
|
575
|
+
let mirrorPath = "";
|
|
576
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
577
|
+
async (_ctx: unknown, _key: string, destination: string) => {
|
|
578
|
+
mirrorPath = destination;
|
|
579
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
580
|
+
fs.symlinkSync("missing-target.md", destination);
|
|
581
|
+
return { metadata: {} };
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
fs.writeFileSync(
|
|
585
|
+
journalPath,
|
|
586
|
+
JSON.stringify({
|
|
587
|
+
version: "1",
|
|
588
|
+
lastSync: new Date().toISOString(),
|
|
589
|
+
files: {
|
|
590
|
+
[linkKey]: {
|
|
591
|
+
hash: "stale-hash",
|
|
592
|
+
size: 20,
|
|
593
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
594
|
+
direction: "down",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
600
|
+
const readlinkSpy = vi
|
|
601
|
+
.spyOn(fs, "readlinkSync")
|
|
602
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
603
|
+
if (candidate === mirrorPath) throw errnoError("EINVAL");
|
|
604
|
+
return realReadlinkSync(candidate);
|
|
605
|
+
}) as typeof fs.readlinkSync);
|
|
606
|
+
const events: SyncProgressEvent[] = [];
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const result = await sync({
|
|
610
|
+
company: "acme",
|
|
611
|
+
onConflict: "keep",
|
|
612
|
+
vaultConfig: mockConfig,
|
|
613
|
+
hqRoot: tmpDir,
|
|
614
|
+
onEvent: (event) => events.push(event),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
expect(result.conflicts).toBe(0);
|
|
618
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
619
|
+
expect(events).toContainEqual({
|
|
620
|
+
type: "not-shipped",
|
|
621
|
+
reason: "unreadable-link",
|
|
622
|
+
count: 1,
|
|
623
|
+
samplePaths: [linkKey],
|
|
624
|
+
});
|
|
625
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
626
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
|
|
627
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
628
|
+
expect(journal.files[linkKey].hash).toBe("stale-hash");
|
|
629
|
+
} finally {
|
|
630
|
+
readlinkSpy.mockRestore();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
508
634
|
it("still writes a `.conflict-*` mirror when remote genuinely diverges from local", async () => {
|
|
509
635
|
// Guard the other side of the convergence branch: when the probe bytes
|
|
510
636
|
// differ from local, it remains a real conflict — counted, kept, and the
|
|
@@ -2782,6 +2908,48 @@ describe("sync", () => {
|
|
|
2782
2908
|
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
2783
2909
|
});
|
|
2784
2910
|
|
|
2911
|
+
it("skips remote keys under companies/<slug>/ in a company vault (doubly-scoped corrupt object — frogbear exit-2 regression)", async () => {
|
|
2912
|
+
// A company vault is already anchored at its company root, so its keys are
|
|
2913
|
+
// bucket-relative. A remote key literally beginning with `companies/...` is
|
|
2914
|
+
// a doubly-scoped corrupt object: the vault-service refuses to presign it
|
|
2915
|
+
// (INVALID_KEY_COMPANIES_SCOPED), so the per-file GET fails and the whole
|
|
2916
|
+
// company wedges at "errored" (runner exit 2) on every run. Verified live
|
|
2917
|
+
// 2026-06-16 against frogbear. The pull planner must refuse it at planning
|
|
2918
|
+
// time — same policy bucket as the malformed-key / ephemeral filters.
|
|
2919
|
+
const corruptKey = "companies/acme/drafts/reports/signals-2026-06-15.html";
|
|
2920
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
2921
|
+
// Doubly-scoped corrupt key — must be filtered, never downloaded.
|
|
2922
|
+
{ key: corruptKey, size: 2152, lastModified: new Date(), etag: '"corrupt"' },
|
|
2923
|
+
// A legitimate bucket-relative key — must still download.
|
|
2924
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"ok"' },
|
|
2925
|
+
]);
|
|
2926
|
+
|
|
2927
|
+
const result = await sync({
|
|
2928
|
+
company: "acme",
|
|
2929
|
+
vaultConfig: mockConfig,
|
|
2930
|
+
hqRoot: tmpDir,
|
|
2931
|
+
});
|
|
2932
|
+
|
|
2933
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2934
|
+
// The corrupt key MUST NOT be materialized at its doubled local path.
|
|
2935
|
+
expect(
|
|
2936
|
+
fs.existsSync(
|
|
2937
|
+
path.join(companyRoot, "companies", "acme", "drafts", "reports", "signals-2026-06-15.html"),
|
|
2938
|
+
),
|
|
2939
|
+
).toBe(false);
|
|
2940
|
+
// The vault-service GET must never be attempted for the corrupt key — the
|
|
2941
|
+
// failing presign is the exact symptom this guard removes.
|
|
2942
|
+
for (const call of vi.mocked(s3Module.downloadFile).mock.calls) {
|
|
2943
|
+
expect(call[1]).not.toBe(corruptKey);
|
|
2944
|
+
}
|
|
2945
|
+
// The legitimate bucket-relative key MUST still download.
|
|
2946
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "handoff.md"))).toBe(true);
|
|
2947
|
+
|
|
2948
|
+
expect(result.filesDownloaded).toBe(1);
|
|
2949
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
2950
|
+
expect(result.aborted).toBe(false);
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2785
2953
|
it("F02: rejects traversal remote keys before they can escape the company root", async () => {
|
|
2786
2954
|
const escapeName = `${path.basename(tmpDir)}-escaped.md`;
|
|
2787
2955
|
const traversalKey = `../../../${escapeName}`;
|
|
@@ -4089,6 +4257,47 @@ describe("sync", () => {
|
|
|
4089
4257
|
expect(result.conflicts).toBe(0);
|
|
4090
4258
|
});
|
|
4091
4259
|
|
|
4260
|
+
it("defers an unreadable local symlink instead of aborting the pull plan", async () => {
|
|
4261
|
+
const linkKey = "policies/unreadable-link";
|
|
4262
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4263
|
+
const linkPath = path.join(companyRoot, linkKey);
|
|
4264
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
4265
|
+
fs.symlinkSync("target.md", linkPath);
|
|
4266
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4267
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
4268
|
+
]);
|
|
4269
|
+
|
|
4270
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
4271
|
+
const readlinkSpy = vi
|
|
4272
|
+
.spyOn(fs, "readlinkSync")
|
|
4273
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
4274
|
+
if (candidate === linkPath) throw errnoError("EINVAL");
|
|
4275
|
+
return realReadlinkSync(candidate);
|
|
4276
|
+
}) as typeof fs.readlinkSync);
|
|
4277
|
+
|
|
4278
|
+
try {
|
|
4279
|
+
const events: SyncProgressEvent[] = [];
|
|
4280
|
+
const result = await sync({
|
|
4281
|
+
company: "acme",
|
|
4282
|
+
vaultConfig: mockConfig,
|
|
4283
|
+
hqRoot: tmpDir,
|
|
4284
|
+
onEvent: (event) => events.push(event),
|
|
4285
|
+
});
|
|
4286
|
+
|
|
4287
|
+
expect(result.filesDownloaded).toBe(0);
|
|
4288
|
+
expect(result.filesSkipped).toBe(1);
|
|
4289
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
4290
|
+
expect(events).toContainEqual({
|
|
4291
|
+
type: "not-shipped",
|
|
4292
|
+
reason: "unreadable-link",
|
|
4293
|
+
count: 1,
|
|
4294
|
+
samplePaths: [linkKey],
|
|
4295
|
+
});
|
|
4296
|
+
} finally {
|
|
4297
|
+
readlinkSpy.mockRestore();
|
|
4298
|
+
}
|
|
4299
|
+
});
|
|
4300
|
+
|
|
4092
4301
|
it("classifies a dangling-symlink + remote-change as a conflict without crashing on statSync", async () => {
|
|
4093
4302
|
// Codex round-11 P2 follow-up: pre-fix, the conflict executor
|
|
4094
4303
|
// built the resolveConflict prompt with `fs.statSync(localPath).
|
package/src/cli/sync.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SyncJournal,
|
|
14
14
|
} from "../types.js";
|
|
15
15
|
import { VaultAuthError, VaultClient, type SyncMode } from "../vault-client.js";
|
|
16
|
+
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
16
17
|
import {
|
|
17
18
|
emitCloudTelemetry,
|
|
18
19
|
type TelemetryClaims,
|
|
@@ -78,7 +79,7 @@ import {
|
|
|
78
79
|
resolveActiveCompany,
|
|
79
80
|
resolveTransferConcurrency,
|
|
80
81
|
} from "../sync-core.js";
|
|
81
|
-
import { isEphemeralPath, isMalformedVaultKey } from "./share.js";
|
|
82
|
+
import { isEphemeralPath, isForbiddenCompanyVaultKey, isMalformedVaultKey } from "./share.js";
|
|
82
83
|
import { resolveConflict } from "./conflict.js";
|
|
83
84
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
84
85
|
import {
|
|
@@ -433,6 +434,34 @@ export type SyncProgressEvent =
|
|
|
433
434
|
path: string;
|
|
434
435
|
/** The already-journaled spelling that owns this logical path. */
|
|
435
436
|
journaledKey: string;
|
|
437
|
+
}
|
|
438
|
+
| {
|
|
439
|
+
/**
|
|
440
|
+
* Emitted when local content is deliberately not transferred and would
|
|
441
|
+
* otherwise vanish from sync output without a trace (feedback_258e4a86 /
|
|
442
|
+
* feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success
|
|
443
|
+
* named nothing that was skipped). Push batches by reason; pull emits an
|
|
444
|
+
* unreadable link by its remote key. `reason` distinguishes the cause:
|
|
445
|
+
*
|
|
446
|
+
* - `"unreachable-path"`: a path the caller EXPLICITLY named exists on
|
|
447
|
+
* disk but the resolver could not place it under the company folder,
|
|
448
|
+
* or could not find it under any base. This is actionable signal — the
|
|
449
|
+
* named target was NOT shipped — so the CLI may treat it as an error.
|
|
450
|
+
* - `"linked-subtree"`: a directory symlink whose target lives OUTSIDE
|
|
451
|
+
* the company folder was recorded as a link but its contents were not
|
|
452
|
+
* descended/uploaded. They sync via their own repo, not the vault;
|
|
453
|
+
* this is informational, not an error.
|
|
454
|
+
* - `"unreadable-link"`: the OS identified a local symbolic link but
|
|
455
|
+
* did not expose a readable target. The link is skipped rather than
|
|
456
|
+
* dereferenced, and the company leg remains complete.
|
|
457
|
+
*
|
|
458
|
+
* `count` is the number of distinct paths for that reason; `samplePaths`
|
|
459
|
+
* carries up to 10 for display. Not emitted when `count === 0`.
|
|
460
|
+
*/
|
|
461
|
+
type: "not-shipped";
|
|
462
|
+
reason: "unreachable-path" | "unreadable-link" | "linked-subtree";
|
|
463
|
+
count: number;
|
|
464
|
+
samplePaths: string[];
|
|
436
465
|
};
|
|
437
466
|
|
|
438
467
|
export interface SyncOptions {
|
|
@@ -1236,6 +1265,19 @@ async function executeConflictExecutor(
|
|
|
1236
1265
|
counters.filesSkipped++;
|
|
1237
1266
|
continue;
|
|
1238
1267
|
}
|
|
1268
|
+
if (item.action === "skip-unreadable-link") {
|
|
1269
|
+
// An unreadable local link must stay non-fatal: the runner treats a
|
|
1270
|
+
// generic error event as a failed company leg. Surface the exact remote
|
|
1271
|
+
// key through the recoverable not-shipped channel instead.
|
|
1272
|
+
counters.filesSkipped++;
|
|
1273
|
+
run.emit({
|
|
1274
|
+
type: "not-shipped",
|
|
1275
|
+
reason: "unreadable-link",
|
|
1276
|
+
count: 1,
|
|
1277
|
+
samplePaths: [item.remoteFile.key],
|
|
1278
|
+
});
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1239
1281
|
if (item.action === "skip-excluded-policy") {
|
|
1240
1282
|
continue;
|
|
1241
1283
|
}
|
|
@@ -1383,10 +1425,23 @@ async function executeConflictItem(
|
|
|
1383
1425
|
try {
|
|
1384
1426
|
const downloaded = await downloadFile(run.ctx, remoteFile.key, conflictAbs);
|
|
1385
1427
|
remoteFetched = true;
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1428
|
+
if (fs.lstatSync(conflictAbs).isSymbolicLink()) {
|
|
1429
|
+
const target = readlinkOrNull(conflictAbs);
|
|
1430
|
+
if (target === null) {
|
|
1431
|
+
counters.filesSkipped++;
|
|
1432
|
+
run.emit({
|
|
1433
|
+
type: "not-shipped",
|
|
1434
|
+
reason: "unreadable-link",
|
|
1435
|
+
count: 1,
|
|
1436
|
+
samplePaths: [remoteFile.key],
|
|
1437
|
+
});
|
|
1438
|
+
return null;
|
|
1439
|
+
} else {
|
|
1440
|
+
converged = hashSymlinkTarget(target) === item.localHash;
|
|
1441
|
+
}
|
|
1442
|
+
} else {
|
|
1443
|
+
converged = (downloaded.contentHash ?? hashFile(conflictAbs)) === item.localHash;
|
|
1444
|
+
}
|
|
1390
1445
|
} catch (probeErr) {
|
|
1391
1446
|
if (probeErr instanceof VaultAuthError) throw probeErr;
|
|
1392
1447
|
run.emit({
|
|
@@ -1610,9 +1665,23 @@ async function downloadOne(
|
|
|
1610
1665
|
|
|
1611
1666
|
const localLstat = fs.lstatSync(localPath);
|
|
1612
1667
|
const isLocalSymlink = localLstat.isSymbolicLink();
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1668
|
+
let hash: string;
|
|
1669
|
+
if (isLocalSymlink) {
|
|
1670
|
+
const target = readlinkOrNull(localPath);
|
|
1671
|
+
if (target === null) {
|
|
1672
|
+
counters.filesSkipped++;
|
|
1673
|
+
run.emit({
|
|
1674
|
+
type: "not-shipped",
|
|
1675
|
+
reason: "unreadable-link",
|
|
1676
|
+
count: 1,
|
|
1677
|
+
samplePaths: [remoteFile.key],
|
|
1678
|
+
});
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
hash = hashSymlinkTarget(target);
|
|
1682
|
+
} else {
|
|
1683
|
+
hash = contentHash ?? hashFile(localPath);
|
|
1684
|
+
}
|
|
1616
1685
|
const size = isLocalSymlink ? 0 : (contentSize ?? fs.statSync(localPath).size);
|
|
1617
1686
|
|
|
1618
1687
|
updateEntry(
|
|
@@ -2141,6 +2210,11 @@ type PullPlanItem =
|
|
|
2141
2210
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
2142
2211
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
2143
2212
|
| { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
|
|
2213
|
+
// A local symlink was classified as a link but Windows (or a filesystem
|
|
2214
|
+
// race) would not expose its target. This stays distinct from generic
|
|
2215
|
+
// skip-local-only so the executor can report the exact path without making
|
|
2216
|
+
// the whole company leg fail.
|
|
2217
|
+
| { action: "skip-unreadable-link"; remoteFile: RemoteFile; localPath: string }
|
|
2144
2218
|
// A stale personal-overlay symlink marker in the vault sitting at a key that
|
|
2145
2219
|
// is ALSO a real, release-shipped core directory (e.g. an old
|
|
2146
2220
|
// `core/knowledge/public/agent-browser` overlay marker after a release
|
|
@@ -2432,6 +2506,19 @@ function computePullPlan(
|
|
|
2432
2506
|
continue;
|
|
2433
2507
|
}
|
|
2434
2508
|
|
|
2509
|
+
// Company-vault double-scoped-key filter. A company vault is already
|
|
2510
|
+
// anchored at its company root, so a remote key under `companies/...` is a
|
|
2511
|
+
// corrupt doubly-scoped object the vault-service refuses to presign
|
|
2512
|
+
// (INVALID_KEY_COMPANIES_SCOPED). Without this, the puller enqueues it, the
|
|
2513
|
+
// per-file GET fails, and the whole company wedges at "errored" (runner
|
|
2514
|
+
// exit 2) on every run (frogbear, 2026-06-16). Refuse at plan time,
|
|
2515
|
+
// symmetric with the malformed/ephemeral filters above. Personal vaults
|
|
2516
|
+
// handle `companies/...` keys in the dedicated branch below.
|
|
2517
|
+
if (isForbiddenCompanyVaultKey(remoteFile.key, personalMode)) {
|
|
2518
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2435
2522
|
// Personal-vault policy must be symmetric across both transfer legs.
|
|
2436
2523
|
// The push walker refuses derived/machine-local/sensitive paths, but old
|
|
2437
2524
|
// objects can remain in the bucket from clients that predate an exclusion.
|
|
@@ -2762,9 +2849,19 @@ function computePullPlan(
|
|
|
2762
2849
|
) {
|
|
2763
2850
|
localHash = journalEntry.hash;
|
|
2764
2851
|
} else {
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2852
|
+
if (isLocalSymlink) {
|
|
2853
|
+
const target = readlinkOrNull(localPath);
|
|
2854
|
+
if (target === null) {
|
|
2855
|
+
// We cannot prove whether the local link diverged, so never
|
|
2856
|
+
// overwrite or tombstone it from this plan. A later pass can retry
|
|
2857
|
+
// once the OS exposes a readable target.
|
|
2858
|
+
items.push({ action: "skip-unreadable-link", remoteFile, localPath });
|
|
2859
|
+
continue;
|
|
2860
|
+
}
|
|
2861
|
+
localHash = hashSymlinkTarget(target);
|
|
2862
|
+
} else {
|
|
2863
|
+
localHash = hashFile(localPath);
|
|
2864
|
+
}
|
|
2768
2865
|
}
|
|
2769
2866
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
2770
2867
|
plannedLocalSnapshot = {
|
|
@@ -3085,6 +3182,13 @@ function computePullPlan(
|
|
|
3085
3182
|
}
|
|
3086
3183
|
const localPath = resolveContainedVaultPath(companyRoot, key);
|
|
3087
3184
|
if (localPath === null) continue;
|
|
3185
|
+
// Scope-invalid journal keys (company mode, `companies/…` prefix): the
|
|
3186
|
+
// download branch skips them (presign rejects), but a journaled entry whose
|
|
3187
|
+
// key is absent from the remote LIST is doubled-tree poison that must
|
|
3188
|
+
// drain — local copy under companies/{slug}/companies/{slug}/… removed and
|
|
3189
|
+
// journal dropped. HEAD verify is skipped in verifyPlannedJournalTombstones
|
|
3190
|
+
// (presign would reject); see the auto-verified branch there.
|
|
3191
|
+
const scopeInvalidJournalKey = isForbiddenCompanyVaultKey(key, personalMode);
|
|
3088
3192
|
// PersonalMode key gating — mirror the download branch. Local (non-cloud)
|
|
3089
3193
|
// company keys are tombstone-eligible (a peer's delete should propagate),
|
|
3090
3194
|
// but team-synced orphans are left alone (the team-bucket pull owns those
|
|
@@ -3101,8 +3205,15 @@ function computePullPlan(
|
|
|
3101
3205
|
if (isEphemeralPath(key)) continue;
|
|
3102
3206
|
// Honor the current ignore filter — if a path was previously synced
|
|
3103
3207
|
// but is now ignored (operator edited .hqignore), do NOT delete
|
|
3104
|
-
// the local copy. They're keeping it deliberately.
|
|
3105
|
-
|
|
3208
|
+
// the local copy. They're keeping it deliberately. Scope-invalid poison
|
|
3209
|
+
// bypasses this: the doubled-tree copy must not survive silently.
|
|
3210
|
+
if (
|
|
3211
|
+
!scopeInvalidJournalKey &&
|
|
3212
|
+
!shouldSync(localPath, false) &&
|
|
3213
|
+
!shouldSync(localPath, true)
|
|
3214
|
+
) {
|
|
3215
|
+
continue;
|
|
3216
|
+
}
|
|
3106
3217
|
// Codex P1 (PR #24 round 3): detect local edits before tombstoning.
|
|
3107
3218
|
// Delete-vs-local-edit race: peer deleted the file remotely while
|
|
3108
3219
|
// this machine edited it locally before the next sync. Without
|
|
@@ -3272,5 +3383,32 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
3272
3383
|
console.warn(
|
|
3273
3384
|
` ! ${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
3385
|
);
|
|
3386
|
+
} else if (event.type === "not-shipped") {
|
|
3387
|
+
if (event.reason === "unreachable-path") {
|
|
3388
|
+
console.warn(
|
|
3389
|
+
` ! ${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"}):`,
|
|
3390
|
+
);
|
|
3391
|
+
} else if (event.reason === "unreadable-link") {
|
|
3392
|
+
console.warn(
|
|
3393
|
+
` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
|
|
3394
|
+
);
|
|
3395
|
+
} else {
|
|
3396
|
+
console.warn(
|
|
3397
|
+
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
3398
|
+
);
|
|
3399
|
+
}
|
|
3400
|
+
for (const p of event.samplePaths) {
|
|
3401
|
+
console.warn(` · ${p}`);
|
|
3402
|
+
}
|
|
3403
|
+
if (event.count > event.samplePaths.length) {
|
|
3404
|
+
console.warn(` ... and ${event.count - event.samplePaths.length} more`);
|
|
3405
|
+
}
|
|
3406
|
+
if (event.reason === "linked-subtree") {
|
|
3407
|
+
console.warn(
|
|
3408
|
+
` To put a file from a linked subtree in the vault, push it by name:\n` +
|
|
3409
|
+
` hq sync push <path> --company <slug>\n` +
|
|
3410
|
+
` That uploads a point-in-time copy; re-run it after each edit (regular sync will not track or update it).`,
|
|
3411
|
+
);
|
|
3412
|
+
}
|
|
3275
3413
|
}
|
|
3276
3414
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readlinkOrNull } from "./readlink-safe.js";
|
|
3
|
+
|
|
4
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
5
|
+
const err = new Error(`${code}: scripted readlink failure`) as NodeJS.ErrnoException;
|
|
6
|
+
err.code = code;
|
|
7
|
+
return err;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("readlinkOrNull", () => {
|
|
11
|
+
it("uses the filesystem reader by default", () => {
|
|
12
|
+
expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns the target string verbatim when readlink succeeds", () => {
|
|
16
|
+
expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe(
|
|
17
|
+
"../target with spaces",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
|
|
22
|
+
expect(readlinkOrNull("/test/link", () => {
|
|
23
|
+
throw errnoError(code);
|
|
24
|
+
})).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rethrows non-filesystem failures", () => {
|
|
28
|
+
const defect = new Error("unexpected test defect");
|
|
29
|
+
|
|
30
|
+
expect(() => readlinkOrNull("/test/link", () => {
|
|
31
|
+
throw defect;
|
|
32
|
+
})).toThrow(defect);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rethrows code-bearing programming errors", () => {
|
|
36
|
+
const defect = new TypeError("path must be a string") as TypeError & { code: string };
|
|
37
|
+
defect.code = "ERR_INVALID_ARG_TYPE";
|
|
38
|
+
|
|
39
|
+
expect(() => readlinkOrNull("/test/link", () => {
|
|
40
|
+
throw defect;
|
|
41
|
+
})).toThrow(defect);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a link target without turning an expected filesystem race or an
|
|
5
|
+
* unsupported link shape into a caller-wide failure.
|
|
6
|
+
*
|
|
7
|
+
* A `null` result is deliberately distinct from an empty target string: it
|
|
8
|
+
* means the caller must preserve the link boundary and choose its conservative
|
|
9
|
+
* skip/defer path. Non-filesystem exceptions still propagate so programming
|
|
10
|
+
* errors cannot be mistaken for an unreadable link.
|
|
11
|
+
*/
|
|
12
|
+
export function readlinkOrNull(
|
|
13
|
+
linkPath: string,
|
|
14
|
+
readlink: (path: string) => string = (path) => fs.readlinkSync(path, "utf8"),
|
|
15
|
+
): string | null {
|
|
16
|
+
try {
|
|
17
|
+
return readlink(linkPath);
|
|
18
|
+
} catch (err: unknown) {
|
|
19
|
+
const code =
|
|
20
|
+
err && typeof err === "object" && "code" in err
|
|
21
|
+
? (err as { code?: unknown }).code
|
|
22
|
+
: undefined;
|
|
23
|
+
// Node system errors use POSIX-style errno names such as EINVAL and
|
|
24
|
+
// ENOENT. Runtime/programming errors use ERR_* codes; those must remain
|
|
25
|
+
// loud rather than being misclassified as an unreadable link.
|
|
26
|
+
if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code)) return null;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|