@indigoai-us/hq-cloud 6.13.1 → 6.13.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/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +30 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +48 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +77 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/sync.js +46 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +88 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +10 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +29 -6
- package/dist/ignore.test.js.map +1 -1
- package/dist/operation-lock.d.ts +31 -0
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +75 -2
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +81 -1
- package/dist/operation-lock.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/reindex.test.ts +92 -0
- package/src/cli/reindex.ts +46 -0
- package/src/cli/sync.test.ts +105 -0
- package/src/cli/sync.ts +61 -1
- package/src/ignore.test.ts +32 -6
- package/src/ignore.ts +11 -0
- package/src/operation-lock.test.ts +93 -0
- package/src/operation-lock.ts +78 -2
package/src/cli/reindex.test.ts
CHANGED
|
@@ -203,6 +203,98 @@ describe("reindex", () => {
|
|
|
203
203
|
).toBe("../../../core/skills/demo/SKILL.md");
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// ── Cleanup pass C: orphan personal-overlay mirror symlinks ──────────────
|
|
207
|
+
// The mirror creates core/<type>/<entry> -> ../../personal/<type>/<entry>.
|
|
208
|
+
// When the personal source is later deleted/renamed, the next reindex must
|
|
209
|
+
// prune the now-dangling core symlink — the gap that used to leave a retired
|
|
210
|
+
// personal policy pointer surfacing in core/policies/ until hand-deleted.
|
|
211
|
+
|
|
212
|
+
it("prunes an orphan personal-overlay mirror symlink when the source is deleted", () => {
|
|
213
|
+
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
214
|
+
fs.writeFileSync(path.join(root, "personal/policies/myrule.md"), "rule\n");
|
|
215
|
+
reindex({ repoRoot: root });
|
|
216
|
+
const link = path.join(root, "core/policies/myrule.md");
|
|
217
|
+
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
218
|
+
|
|
219
|
+
// Retire the personal source, then reindex again.
|
|
220
|
+
fs.rmSync(path.join(root, "personal/policies/myrule.md"));
|
|
221
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
222
|
+
|
|
223
|
+
// The dangling mirror symlink is gone — no entry at all (lstat throws).
|
|
224
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
225
|
+
expect(() => fs.lstatSync(link)).toThrow();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("prunes mirror symlinks when the ENTIRE personal/<type>/ directory is removed", () => {
|
|
229
|
+
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
230
|
+
fs.writeFileSync(path.join(root, "personal/policies/a.md"), "a\n");
|
|
231
|
+
fs.writeFileSync(path.join(root, "personal/policies/b.md"), "b\n");
|
|
232
|
+
reindex({ repoRoot: root });
|
|
233
|
+
expect(fs.lstatSync(path.join(root, "core/policies/a.md")).isSymbolicLink()).toBe(true);
|
|
234
|
+
expect(fs.lstatSync(path.join(root, "core/policies/b.md")).isSymbolicLink()).toBe(true);
|
|
235
|
+
|
|
236
|
+
// Nuke the whole personal/policies dir (the creation loop's isDir guard would
|
|
237
|
+
// skip the type entirely — the prune must still run and clean both links).
|
|
238
|
+
fs.rmSync(path.join(root, "personal/policies"), { recursive: true, force: true });
|
|
239
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
240
|
+
|
|
241
|
+
expect(fs.existsSync(path.join(root, "core/policies/a.md"))).toBe(false);
|
|
242
|
+
expect(fs.existsSync(path.join(root, "core/policies/b.md"))).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("keeps a LIVE personal-overlay mirror symlink (only orphans are pruned)", () => {
|
|
246
|
+
fs.mkdirSync(path.join(root, "personal/policies"), { recursive: true });
|
|
247
|
+
fs.writeFileSync(path.join(root, "personal/policies/keep.md"), "keep\n");
|
|
248
|
+
fs.writeFileSync(path.join(root, "personal/policies/drop.md"), "drop\n");
|
|
249
|
+
reindex({ repoRoot: root });
|
|
250
|
+
|
|
251
|
+
// Retire only one source; the other stays live.
|
|
252
|
+
fs.rmSync(path.join(root, "personal/policies/drop.md"));
|
|
253
|
+
reindex({ repoRoot: root });
|
|
254
|
+
|
|
255
|
+
const keep = path.join(root, "core/policies/keep.md");
|
|
256
|
+
expect(fs.lstatSync(keep).isSymbolicLink()).toBe(true);
|
|
257
|
+
expect(fs.readlinkSync(keep)).toBe("../../personal/policies/keep.md");
|
|
258
|
+
expect(fs.readFileSync(keep, "utf-8")).toBe("keep\n");
|
|
259
|
+
expect(fs.existsSync(path.join(root, "core/policies/drop.md"))).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("prunes an orphan personal-overlay mirror DIRECTORY symlink (DEV-1767 shape)", () => {
|
|
263
|
+
// personal/knowledge/<kb>/ mirrors as a directory symlink; deleting the kb
|
|
264
|
+
// must prune the dangling directory symlink too (rmSync on a symlink, not
|
|
265
|
+
// recursive into a resolved dir).
|
|
266
|
+
fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
|
|
267
|
+
fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
|
|
268
|
+
reindex({ repoRoot: root });
|
|
269
|
+
const link = path.join(root, "core/knowledge/my-kb");
|
|
270
|
+
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
|
271
|
+
|
|
272
|
+
fs.rmSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true, force: true });
|
|
273
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
274
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("leaves a real core file and a foreign symlink in core/<type>/ untouched (cross-check invariant)", () => {
|
|
278
|
+
fs.mkdirSync(path.join(root, "core/policies"), { recursive: true });
|
|
279
|
+
// (a) a real release-shipped core file — not a symlink, never ours.
|
|
280
|
+
fs.writeFileSync(path.join(root, "core/policies/shipped.md"), "shipped\n");
|
|
281
|
+
// (b) a human-authored symlink in core/policies pointing SOMEWHERE ELSE
|
|
282
|
+
// (not the managed ../../personal/policies/<name> shape) — even though
|
|
283
|
+
// it is dangling, its target doesn't match, so it must survive.
|
|
284
|
+
fs.symlinkSync("../../somewhere/else.md", path.join(root, "core/policies/foreign.md"));
|
|
285
|
+
// (c) a symlink whose target's basename disagrees with its own name — the
|
|
286
|
+
// exact cross-check catches this: name 'mismatch.md' but target points
|
|
287
|
+
// at personal/policies/other.md → NOT managed, left alone.
|
|
288
|
+
fs.symlinkSync("../../personal/policies/other.md", path.join(root, "core/policies/mismatch.md"));
|
|
289
|
+
|
|
290
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
291
|
+
|
|
292
|
+
expect(fs.lstatSync(path.join(root, "core/policies/shipped.md")).isSymbolicLink()).toBe(false);
|
|
293
|
+
expect(fs.existsSync(path.join(root, "core/policies/shipped.md"))).toBe(true);
|
|
294
|
+
expect(fs.lstatSync(path.join(root, "core/policies/foreign.md")).isSymbolicLink()).toBe(true);
|
|
295
|
+
expect(fs.lstatSync(path.join(root, "core/policies/mismatch.md")).isSymbolicLink()).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
206
298
|
it("skips `_`-prefixed and dotfile skill folders", () => {
|
|
207
299
|
writeSkill("core/skills/_template");
|
|
208
300
|
fs.mkdirSync(path.join(root, "core/skills/.hidden"), { recursive: true });
|
package/src/cli/reindex.ts
CHANGED
|
@@ -20,7 +20,9 @@ import * as path from "path";
|
|
|
20
20
|
import {
|
|
21
21
|
acquireOperationLock,
|
|
22
22
|
OperationLockedError,
|
|
23
|
+
OperationLockUnwritableError,
|
|
23
24
|
OPERATION_LOCKED_EXIT,
|
|
25
|
+
OPERATION_LOCK_UNWRITABLE_EXIT,
|
|
24
26
|
type LockHandle,
|
|
25
27
|
} from "../operation-lock.js";
|
|
26
28
|
|
|
@@ -218,6 +220,15 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
218
220
|
warn(err.message);
|
|
219
221
|
return { status: OPERATION_LOCKED_EXIT };
|
|
220
222
|
}
|
|
223
|
+
if (err instanceof OperationLockUnwritableError) {
|
|
224
|
+
// The lock dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
|
|
225
|
+
// root-owned or restricted ~/.hq). This is an environmental problem on
|
|
226
|
+
// the user's machine, not an HQ fault — surface the actionable guidance
|
|
227
|
+
// and exit cleanly instead of crashing with a raw `EPERM ... open`
|
|
228
|
+
// (HQ-CLI-2).
|
|
229
|
+
warn(err.message);
|
|
230
|
+
return { status: OPERATION_LOCK_UNWRITABLE_EXIT };
|
|
231
|
+
}
|
|
221
232
|
throw err;
|
|
222
233
|
}
|
|
223
234
|
}
|
|
@@ -476,6 +487,41 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
476
487
|
}
|
|
477
488
|
}
|
|
478
489
|
|
|
490
|
+
// --- Cleanup pass C: drop orphan personal-overlay mirror symlinks ----------
|
|
491
|
+
// The mirror above creates core/<type>/<entry> -> ../../personal/<type>/<entry>
|
|
492
|
+
// for each LIVE personal entry, but historically never removed that symlink
|
|
493
|
+
// when its personal source was later deleted or renamed — leaving a dangling
|
|
494
|
+
// link in core/<type>/ (e.g. a retired personal policy that still surfaced via
|
|
495
|
+
// the policy-trigger hook until a human hand-deleted it). This pass prunes it.
|
|
496
|
+
//
|
|
497
|
+
// Ownership discriminator (policy wrapper-sync-orphan-cleanup-cross-check): a
|
|
498
|
+
// core/<type>/<entry> is "managed by this mirror" ONLY when it is a symlink
|
|
499
|
+
// whose target is EXACTLY `../../personal/<type>/<entry>` — i.e. the target
|
|
500
|
+
// encodes the same <type> and <entry> as the link's own location and name.
|
|
501
|
+
// That exact cross-check means a real release-shipped core file (not a
|
|
502
|
+
// symlink), or a human-authored symlink pointing anywhere else, never matches
|
|
503
|
+
// and is left untouched. A managed link is pruned IFF its target no longer
|
|
504
|
+
// resolves (existsFollow=false) — which, given the exact-target shape, is
|
|
505
|
+
// precisely "the personal source was removed". This runs independently of the
|
|
506
|
+
// creation loop above (note: no `isDir(personalDir)` guard) so that deleting
|
|
507
|
+
// the ENTIRE personal/<type>/ directory still prunes every mirror it produced.
|
|
508
|
+
for (const type of ["knowledge", "policies", "workers", "settings"]) {
|
|
509
|
+
const coreDir = path.join(root, "core", type);
|
|
510
|
+
for (const entry of globEntries(coreDir)) {
|
|
511
|
+
const linkPath = path.join(coreDir, entry);
|
|
512
|
+
const lst = lstatOrNull(linkPath);
|
|
513
|
+
if (!lst || !lst.isSymbolicLink()) continue; // real file/dir → not ours
|
|
514
|
+
if (readlinkOrNull(linkPath) !== `../../personal/${type}/${entry}`) continue; // not a managed mirror link
|
|
515
|
+
if (existsFollow(linkPath)) continue; // live: personal source still present
|
|
516
|
+
// Managed mirror symlink whose personal source is gone → prune.
|
|
517
|
+
try {
|
|
518
|
+
fs.rmSync(linkPath);
|
|
519
|
+
} catch {
|
|
520
|
+
/* best-effort */
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
479
525
|
// --- Workers registry regeneration ----------------------------------------
|
|
480
526
|
// Source of truth: each worker.yaml. The registry is a derived index — the
|
|
481
527
|
// generator (when present in the operated-on tree) keeps it in sync.
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -3141,6 +3141,111 @@ describe("sync", () => {
|
|
|
3141
3141
|
expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
|
|
3142
3142
|
});
|
|
3143
3143
|
|
|
3144
|
+
it("stale personal-overlay marker vs release-shipped core dir: skips cleanly, no 'rm -rf' advice (feedback_ce9aeede)", async () => {
|
|
3145
|
+
// Reporter feedback_ce9aeede: a stale personal-overlay symlink marker in
|
|
3146
|
+
// the vault at core/knowledge/public/agent-browser collided with the REAL,
|
|
3147
|
+
// release-shipped core directory of the same name (the overlay mirror once
|
|
3148
|
+
// symlinked it into core/, pushed the marker, then a release shipped a real
|
|
3149
|
+
// dir there). Pre-fix, the pull planner hit the generic dir-vs-object
|
|
3150
|
+
// collision branch and emitted, on EVERY sync, a "manual reconciliation
|
|
3151
|
+
// required (rm -rf the local directory to pull)" warning — advice that here
|
|
3152
|
+
// would DESTROY release-shipped core content to pull an inert marker. The
|
|
3153
|
+
// reporter had to hand-delete the vault object. Fix: recognize the
|
|
3154
|
+
// overlay-mirror core key, keep the local directory, and skip quietly.
|
|
3155
|
+
const markerKey = "core/knowledge/public/agent-browser";
|
|
3156
|
+
const localDir = path.join(
|
|
3157
|
+
tmpDir,
|
|
3158
|
+
"core",
|
|
3159
|
+
"knowledge",
|
|
3160
|
+
"public",
|
|
3161
|
+
"agent-browser",
|
|
3162
|
+
);
|
|
3163
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
3164
|
+
fs.writeFileSync(
|
|
3165
|
+
path.join(localDir, "INDEX.md"),
|
|
3166
|
+
"release-shipped core content",
|
|
3167
|
+
);
|
|
3168
|
+
|
|
3169
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
3170
|
+
{ key: markerKey, size: 40, lastModified: new Date(), etag: '"stale-marker"' },
|
|
3171
|
+
]);
|
|
3172
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
3173
|
+
|
|
3174
|
+
let crashed = false;
|
|
3175
|
+
let result;
|
|
3176
|
+
try {
|
|
3177
|
+
result = await sync({
|
|
3178
|
+
company: "acme",
|
|
3179
|
+
vaultConfig: mockConfig,
|
|
3180
|
+
hqRoot: tmpDir,
|
|
3181
|
+
// personalMode → companyRoot === hqRoot, so a `core/...` key resolves
|
|
3182
|
+
// under the HQ root exactly as the reporter's personal-vault pull did.
|
|
3183
|
+
personalMode: true,
|
|
3184
|
+
});
|
|
3185
|
+
} catch {
|
|
3186
|
+
crashed = true;
|
|
3187
|
+
}
|
|
3188
|
+
const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
3189
|
+
errSpy.mockRestore();
|
|
3190
|
+
|
|
3191
|
+
expect(crashed).toBe(false);
|
|
3192
|
+
expect(result).toBeDefined();
|
|
3193
|
+
// Marker ignored, nothing downloaded, release-shipped dir intact.
|
|
3194
|
+
expect(result!.filesDownloaded).toBe(0);
|
|
3195
|
+
expect(result!.filesSkipped).toBe(1);
|
|
3196
|
+
expect(fs.readFileSync(path.join(localDir, "INDEX.md"), "utf-8")).toBe(
|
|
3197
|
+
"release-shipped core content",
|
|
3198
|
+
);
|
|
3199
|
+
// The dangerous, recurring reconciliation advice is GONE...
|
|
3200
|
+
expect(errOutput).not.toContain("rm -rf");
|
|
3201
|
+
expect(errOutput).not.toContain("manual");
|
|
3202
|
+
// ...replaced by a calm, no-action-needed note naming the stale marker.
|
|
3203
|
+
expect(errOutput).toContain("stale personal-overlay marker");
|
|
3204
|
+
expect(errOutput).toContain(markerKey);
|
|
3205
|
+
});
|
|
3206
|
+
|
|
3207
|
+
it("dir-vs-object collision OUTSIDE the overlay-mirror core namespace still warns to reconcile (no over-broadening)", async () => {
|
|
3208
|
+
// Boundary guard for the feedback_ce9aeede fix: the quiet skip is scoped to
|
|
3209
|
+
// core/{knowledge,policies,workers,settings}/ overlay-mirror keys ONLY. A
|
|
3210
|
+
// dir-vs-object collision at any OTHER key (here `core/docs/...`, a core
|
|
3211
|
+
// subtree that is NOT an overlay-mirror type) is a genuine structural
|
|
3212
|
+
// collision the operator must resolve, so the original warning path — and
|
|
3213
|
+
// its skip-local-only classification — must remain intact.
|
|
3214
|
+
const key = "core/docs/agent-browser";
|
|
3215
|
+
const localDir = path.join(tmpDir, "core", "docs", "agent-browser");
|
|
3216
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
3217
|
+
fs.writeFileSync(path.join(localDir, "inside.md"), "local content");
|
|
3218
|
+
|
|
3219
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
3220
|
+
{ key, size: 40, lastModified: new Date(), etag: '"obj"' },
|
|
3221
|
+
]);
|
|
3222
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
3223
|
+
|
|
3224
|
+
let crashed = false;
|
|
3225
|
+
let result;
|
|
3226
|
+
try {
|
|
3227
|
+
result = await sync({
|
|
3228
|
+
company: "acme",
|
|
3229
|
+
vaultConfig: mockConfig,
|
|
3230
|
+
hqRoot: tmpDir,
|
|
3231
|
+
personalMode: true,
|
|
3232
|
+
});
|
|
3233
|
+
} catch {
|
|
3234
|
+
crashed = true;
|
|
3235
|
+
}
|
|
3236
|
+
const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
3237
|
+
errSpy.mockRestore();
|
|
3238
|
+
|
|
3239
|
+
expect(crashed).toBe(false);
|
|
3240
|
+
expect(result!.filesDownloaded).toBe(0);
|
|
3241
|
+
expect(result!.filesSkipped).toBe(1);
|
|
3242
|
+
expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
|
|
3243
|
+
// Generic collision path preserved: reconciliation warning still emitted,
|
|
3244
|
+
// NOT the overlay-marker note.
|
|
3245
|
+
expect(errOutput).toContain("manual");
|
|
3246
|
+
expect(errOutput).not.toContain("stale personal-overlay marker");
|
|
3247
|
+
});
|
|
3248
|
+
|
|
3144
3249
|
it("pulls a remote symlink record under an .hqinclude dir-only allowlist pattern", async () => {
|
|
3145
3250
|
// Codex round-7 P1 follow-up: pre-fix, computePullPlan called
|
|
3146
3251
|
// shouldSync(localPath) with the default isDir=false. LIST gives
|
package/src/cli/sync.ts
CHANGED
|
@@ -967,7 +967,8 @@ async function executeConflictExecutor(
|
|
|
967
967
|
item.action === "skip-ignored" ||
|
|
968
968
|
item.action === "skip-personal-mode" ||
|
|
969
969
|
item.action === "skip-unchanged" ||
|
|
970
|
-
item.action === "skip-local-only"
|
|
970
|
+
item.action === "skip-local-only" ||
|
|
971
|
+
item.action === "skip-stale-overlay-marker"
|
|
971
972
|
) {
|
|
972
973
|
counters.filesSkipped++;
|
|
973
974
|
continue;
|
|
@@ -1670,6 +1671,15 @@ type PullPlanItem =
|
|
|
1670
1671
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
1671
1672
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
1672
1673
|
| { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
|
|
1674
|
+
// A stale personal-overlay symlink marker in the vault sitting at a key that
|
|
1675
|
+
// is ALSO a real, release-shipped core directory (e.g. an old
|
|
1676
|
+
// `core/knowledge/public/agent-browser` overlay marker after a release
|
|
1677
|
+
// shipped a real directory of the same name). The local release-shipped
|
|
1678
|
+
// directory is authoritative; the marker is inert derived state that must
|
|
1679
|
+
// NEVER overwrite core. Distinct from skip-local-only so the benign,
|
|
1680
|
+
// no-action-needed case is never surfaced with the alarming "rm -rf the
|
|
1681
|
+
// local directory" reconciliation advice.
|
|
1682
|
+
| { action: "skip-stale-overlay-marker"; remoteFile: RemoteFile; localPath: string }
|
|
1673
1683
|
// Remote keys refused by ephemeral-mirror policy. The push walker has
|
|
1674
1684
|
// refused to upload these since 5.33.0; the pull walker now refuses to
|
|
1675
1685
|
// download them so legacy litter in cloud staging drains naturally.
|
|
@@ -1757,6 +1767,33 @@ interface PullPlan {
|
|
|
1757
1767
|
const PULL_INTENTIONAL_DELETE_MIN_ABS = 10;
|
|
1758
1768
|
const PULL_INTENTIONAL_DELETE_RATIO = 0.1;
|
|
1759
1769
|
|
|
1770
|
+
// The four release-shipped `core/<type>/` namespaces the personal-overlay
|
|
1771
|
+
// mirror (reindex) materializes into. A stale mirror symlink pushed to the
|
|
1772
|
+
// vault lands as a single object at exactly `core/<type>/<...>/<entry>`, and if
|
|
1773
|
+
// a later release ships a REAL directory of the same name, pull sees a
|
|
1774
|
+
// (local real dir) vs (remote single object) collision at that key forever.
|
|
1775
|
+
const OVERLAY_MIRROR_CORE_TYPES = ["knowledge", "policies", "workers", "settings"];
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* True when `key` is under a release-shipped personal-overlay-mirror core
|
|
1779
|
+
* namespace (`core/{knowledge,policies,workers,settings}/<entry>/...`).
|
|
1780
|
+
*
|
|
1781
|
+
* A remote LIST entry is always a single S3 object. A REAL release-shipped core
|
|
1782
|
+
* directory only ever syncs as child objects UNDER its key
|
|
1783
|
+
* (`core/knowledge/public/agent-browser/INDEX.md`), never as one object AT the
|
|
1784
|
+
* directory's own key — so a single object at such a key, colliding with a
|
|
1785
|
+
* local real directory, is unambiguously a stale overlay symlink marker, not
|
|
1786
|
+
* live core content. Pure path test — no I/O — safe inside the sync planner.
|
|
1787
|
+
*/
|
|
1788
|
+
function isOverlayMirrorCoreKey(key: string): boolean {
|
|
1789
|
+
const parts = key.split("/");
|
|
1790
|
+
return (
|
|
1791
|
+
parts.length >= 3 &&
|
|
1792
|
+
parts[0] === "core" &&
|
|
1793
|
+
OVERLAY_MIRROR_CORE_TYPES.includes(parts[1])
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1760
1797
|
function computePullPlan(
|
|
1761
1798
|
remoteFiles: RemoteFile[],
|
|
1762
1799
|
journal: SyncJournal,
|
|
@@ -1959,6 +1996,29 @@ function computePullPlan(
|
|
|
1959
1996
|
// skip and warn so the operator can reconcile manually
|
|
1960
1997
|
// (delete the local dir, or restructure the remote).
|
|
1961
1998
|
if (localLstat!.isDirectory() && !isLocalSymlink) {
|
|
1999
|
+
// Stale personal-overlay marker vs. release-shipped core directory:
|
|
2000
|
+
// a single vault object at a `core/<overlay-type>/.../<entry>` key
|
|
2001
|
+
// whose local counterpart is a real directory is an orphaned overlay
|
|
2002
|
+
// symlink marker (reindex mirror artifact) left behind after a release
|
|
2003
|
+
// shipped a real core dir of the same name. The local release-shipped
|
|
2004
|
+
// directory is authoritative and the marker can never be materialized
|
|
2005
|
+
// over it, so keep local and ignore the marker QUIETLY — the generic
|
|
2006
|
+
// "rm -rf the local directory" advice below is actively dangerous here
|
|
2007
|
+
// (it would destroy release-shipped core content to pull inert derived
|
|
2008
|
+
// state), and it recurred on every sync. feedback_ce9aeede.
|
|
2009
|
+
if (isOverlayMirrorCoreKey(remoteFile.key)) {
|
|
2010
|
+
console.error(
|
|
2011
|
+
` Note: ${remoteFile.key} is a stale personal-overlay marker ` +
|
|
2012
|
+
`shadowing a release-shipped core directory; keeping the local ` +
|
|
2013
|
+
`directory and ignoring the marker (no action needed).`,
|
|
2014
|
+
);
|
|
2015
|
+
items.push({
|
|
2016
|
+
action: "skip-stale-overlay-marker",
|
|
2017
|
+
remoteFile,
|
|
2018
|
+
localPath,
|
|
2019
|
+
});
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
1962
2022
|
console.error(
|
|
1963
2023
|
` Warning: ${remoteFile.key} exists locally as a directory; ` +
|
|
1964
2024
|
`cloud has a single object at this key. Skipping; manual ` +
|
package/src/ignore.test.ts
CHANGED
|
@@ -177,8 +177,8 @@ describe("createIgnoreFilter", () => {
|
|
|
177
177
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
178
178
|
expect(shouldSync(path.join(hqRoot, ".claude/worktrees/foo/file.ts"))).toBe(false);
|
|
179
179
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/.claude/worktrees/bar/x.md"))).toBe(false);
|
|
180
|
-
// The .claude/ dir itself (
|
|
181
|
-
expect(shouldSync(path.join(hqRoot, ".claude/
|
|
180
|
+
// The .claude/ dir itself (commands, skills, hooks) still syncs.
|
|
181
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
|
|
@@ -226,13 +226,36 @@ describe("createIgnoreFilter", () => {
|
|
|
226
226
|
expect(
|
|
227
227
|
shouldSync(path.join(hqRoot, "companies/indigo/.claude/audit/instructions.md")),
|
|
228
228
|
).toBe(false);
|
|
229
|
-
// The .claude/ dir's other contents (
|
|
230
|
-
//
|
|
231
|
-
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
|
|
229
|
+
// The .claude/ dir's other contents (commands, skills, hooks) still
|
|
230
|
+
// sync — the rule MUST NOT broaden to .claude/.
|
|
232
231
|
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
233
232
|
expect(shouldSync(path.join(hqRoot, ".claude/hooks/foo.sh"))).toBe(true);
|
|
234
233
|
});
|
|
235
234
|
|
|
235
|
+
it("permissive mode: .claude/settings*.json is machine-local, never synced (qmd PATH regression)", () => {
|
|
236
|
+
// 2026-07-01 e2e-mac report (20260701-qmd-path): cloud sync pulled the
|
|
237
|
+
// operator's .claude/settings.json over a fresh install's copy ~40s
|
|
238
|
+
// after install, byte-identical to the source machine — reverting the
|
|
239
|
+
// installer's day-one env.PATH fix and propagating one host's PATH
|
|
240
|
+
// snapshot to every machine/teammate. This is the recurrence mechanism
|
|
241
|
+
// behind the recurring "qmd: command not found" reports. Settings are
|
|
242
|
+
// machine-local (PATH snapshot, model prefs) and must never round-trip
|
|
243
|
+
// in EITHER direction — the pull planner consults this same filter, so
|
|
244
|
+
// an existing vault copy is also never applied locally.
|
|
245
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
246
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(false);
|
|
247
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.local.json"))).toBe(false);
|
|
248
|
+
// Nested .claude/ inside a company stays local too — same anchor shape
|
|
249
|
+
// as the state/audit rules above.
|
|
250
|
+
expect(
|
|
251
|
+
shouldSync(path.join(hqRoot, "companies/indigo/.claude/settings.json")),
|
|
252
|
+
).toBe(false);
|
|
253
|
+
// Siblings still sync — the rule must not broaden past settings files.
|
|
254
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
255
|
+
expect(shouldSync(path.join(hqRoot, ".claude/hooks/foo.sh"))).toBe(true);
|
|
256
|
+
expect(shouldSync(path.join(hqRoot, ".claude/CLAUDE.md"))).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
236
259
|
it("permissive mode: .hq/ directory is per-host state, never synced (Bug #1/#6/#8)", () => {
|
|
237
260
|
// Bug catalog: `.hq/install-manifest.json`, `.hq/machine-id`, and
|
|
238
261
|
// `.hq/machine.json` are per-host source-of-truth files. The original
|
|
@@ -289,7 +312,10 @@ describe("createIgnoreFilter", () => {
|
|
|
289
312
|
expect(shouldSync(path.join(hqRoot, ".claude"), true)).toBe(true);
|
|
290
313
|
// Files inside still resolve correctly without the hint.
|
|
291
314
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge/x.md"))).toBe(true);
|
|
292
|
-
expect(shouldSync(path.join(hqRoot, ".claude/
|
|
315
|
+
expect(shouldSync(path.join(hqRoot, ".claude/skills/foo/SKILL.md"))).toBe(true);
|
|
316
|
+
// Machine-local settings stay excluded even inside an allowlisted
|
|
317
|
+
// subtree — exclusion layers subtract on top of the allowlist.
|
|
318
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(false);
|
|
293
319
|
// Non-allowlisted siblings stay excluded — privacy guarantee intact.
|
|
294
320
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/data"), true)).toBe(false);
|
|
295
321
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/workers"), true)).toBe(false);
|
package/src/ignore.ts
CHANGED
|
@@ -119,6 +119,17 @@ export const DEFAULT_IGNORES = [
|
|
|
119
119
|
"**/.claude/state/",
|
|
120
120
|
"**/.claude/audit/",
|
|
121
121
|
|
|
122
|
+
// Claude Code settings are machine-local: `env.PATH` is a snapshot of the
|
|
123
|
+
// host's shell PATH taken at install time, and model/permission prefs are
|
|
124
|
+
// per-operator-per-machine. Syncing them propagates one machine's PATH
|
|
125
|
+
// onto every other machine (and overwrites the installer's day-one PATH
|
|
126
|
+
// fix ~40s after a fresh install pulls existing cloud state) — the root
|
|
127
|
+
// cause of the recurring "qmd: command not found" reports (see
|
|
128
|
+
// workspace/e2e-mac/reports/20260701-qmd-path in HQ). Never round-trip
|
|
129
|
+
// them, in either direction.
|
|
130
|
+
"**/.claude/settings.json",
|
|
131
|
+
"**/.claude/settings.local.json",
|
|
132
|
+
|
|
122
133
|
// HQ repos directory (managed separately, not synced). Root-anchored so
|
|
123
134
|
// only hqRoot/repos/ is excluded; bare `repos/` would match any nested
|
|
124
135
|
// directory named repos (for example companies/<co>/knowledge/repos/),
|
|
@@ -13,8 +13,11 @@ import {
|
|
|
13
13
|
withOperationLock,
|
|
14
14
|
withOperationLockSync,
|
|
15
15
|
lockPathFor,
|
|
16
|
+
rethrowLockCreateError,
|
|
16
17
|
OperationLockedError,
|
|
18
|
+
OperationLockUnwritableError,
|
|
17
19
|
OPERATION_LOCKED_EXIT,
|
|
20
|
+
OPERATION_LOCK_UNWRITABLE_EXIT,
|
|
18
21
|
DEFAULT_LOCK_POLL_MS,
|
|
19
22
|
type LockInfo,
|
|
20
23
|
} from "./operation-lock.js";
|
|
@@ -101,6 +104,96 @@ describe("operation-lock", () => {
|
|
|
101
104
|
expect(fs.existsSync(h.path)).toBe(false);
|
|
102
105
|
});
|
|
103
106
|
|
|
107
|
+
// Regression (HQ-CLI-2): when the lock's state dir is not writable, acquiring
|
|
108
|
+
// must fail with an actionable OperationLockUnwritableError, NOT a raw
|
|
109
|
+
// `EPERM: operation not permitted, open` (macOS) / `EACCES` (Linux) crash.
|
|
110
|
+
describe("unwritable lock dir (HQ-CLI-2)", () => {
|
|
111
|
+
// The classification decision (which fs error codes mean "unwritable lock"
|
|
112
|
+
// vs. a genuinely different fault) is verified directly against the exported
|
|
113
|
+
// `rethrowLockCreateError` helper. ESM module namespaces can't be spied, so
|
|
114
|
+
// we can't mock `fs.openSync`; testing the classifier is both deterministic
|
|
115
|
+
// (uid/OS-independent) and a tighter unit of the actual decision.
|
|
116
|
+
const permErr = (code: string, msg: string): NodeJS.ErrnoException =>
|
|
117
|
+
Object.assign(new Error(`${code}: ${msg}, open`), { code, syscall: "open" });
|
|
118
|
+
|
|
119
|
+
it.each(["EPERM", "EACCES", "EROFS"])(
|
|
120
|
+
"classifies %s as OperationLockUnwritableError with actionable guidance",
|
|
121
|
+
(code) => {
|
|
122
|
+
const dir = path.join(stateDir, "locks");
|
|
123
|
+
let thrown: unknown;
|
|
124
|
+
try {
|
|
125
|
+
rethrowLockCreateError(permErr(code, "not permitted"), dir);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
thrown = e;
|
|
128
|
+
}
|
|
129
|
+
expect(thrown).toBeInstanceOf(OperationLockUnwritableError);
|
|
130
|
+
const err = thrown as OperationLockUnwritableError;
|
|
131
|
+
expect(err.lockDir).toBe(dir);
|
|
132
|
+
expect(err.cause.code).toBe(code);
|
|
133
|
+
// Actionable: names the recovery levers so the user can self-serve.
|
|
134
|
+
expect(err.message).toContain("HQ_DISABLE_OP_LOCK=1");
|
|
135
|
+
expect(err.message).toContain("HQ_STATE_DIR");
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
it("POSITIVE CONTROL: a NON-permission fs error propagates unchanged (not masked)", () => {
|
|
140
|
+
// A disk-full error is a genuine, different fault — it must NOT be
|
|
141
|
+
// reclassified as an unwritable-lock error.
|
|
142
|
+
const dir = path.join(stateDir, "locks");
|
|
143
|
+
let thrown: unknown;
|
|
144
|
+
try {
|
|
145
|
+
rethrowLockCreateError(permErr("ENOSPC", "no space left on device"), dir);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
thrown = e;
|
|
148
|
+
}
|
|
149
|
+
expect(thrown).not.toBeInstanceOf(OperationLockUnwritableError);
|
|
150
|
+
expect((thrown as NodeJS.ErrnoException).code).toBe("ENOSPC");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("real unwritable locks dir → OperationLockUnwritableError, distinct exit (skipped as root)", () => {
|
|
154
|
+
// End-to-end proof through the real acquire path: a locks dir with no write
|
|
155
|
+
// bit makes the temp-file create fail (EACCES on Linux / EPERM on macOS),
|
|
156
|
+
// and acquire must surface the typed error rather than crash. CI runners
|
|
157
|
+
// run non-root, so this exercises the full path there.
|
|
158
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) {
|
|
159
|
+
// root bypasses DAC permission bits, so a chmod-based unwritable dir
|
|
160
|
+
// cannot be exercised — the classifier cases above cover the logic.
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const locksDir = path.join(stateDir, "locks");
|
|
164
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
165
|
+
fs.chmodSync(locksDir, 0o500); // r-x: cannot create files inside
|
|
166
|
+
try {
|
|
167
|
+
expect(() => acquireOperationLock(rootA, "reindex", { wait: false })).toThrowError(
|
|
168
|
+
OperationLockUnwritableError,
|
|
169
|
+
);
|
|
170
|
+
} finally {
|
|
171
|
+
fs.chmodSync(locksDir, 0o700); // restore so afterEach cleanup can rm it
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("async acquire also surfaces the typed error on an unwritable locks dir (skipped as root)", async () => {
|
|
176
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const locksDir = path.join(stateDir, "locks");
|
|
180
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
181
|
+
fs.chmodSync(locksDir, 0o500);
|
|
182
|
+
try {
|
|
183
|
+
await expect(
|
|
184
|
+
acquireOperationLockAsync(rootB, "reindex", { wait: false }),
|
|
185
|
+
).rejects.toBeInstanceOf(OperationLockUnwritableError);
|
|
186
|
+
} finally {
|
|
187
|
+
fs.chmodSync(locksDir, 0o700);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("exposes a distinct exit code from the busy case", () => {
|
|
192
|
+
expect(OPERATION_LOCK_UNWRITABLE_EXIT).not.toBe(OPERATION_LOCKED_EXIT);
|
|
193
|
+
expect(OPERATION_LOCK_UNWRITABLE_EXIT).toBe(18);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
104
197
|
it("refuses immediately (wait:false) with the holder's command + pid when a LIVE process holds it", () => {
|
|
105
198
|
// Simulate a DIFFERENT live process holding the lock. PID 1 (init/systemd)
|
|
106
199
|
// is always alive and is never our own pid, so kill(1,0) reports alive and
|