@indigoai-us/hq-cloud 5.33.0 → 5.34.0

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/src/cli/sync.ts CHANGED
@@ -17,10 +17,12 @@ import {
17
17
  hashFile,
18
18
  hashSymlinkTarget,
19
19
  updateEntry,
20
+ removeEntry,
20
21
  getEntry,
21
22
  normalizeEtag,
22
23
  } from "../journal.js";
23
24
  import { createIgnoreFilter } from "../ignore.js";
25
+ import { isEphemeralPath } from "./share.js";
24
26
  import { resolveConflict } from "./conflict.js";
25
27
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
26
28
  import {
@@ -240,6 +242,26 @@ export interface SyncResult {
240
242
  newFiles: Array<{ path: string; bytes: number }>;
241
243
  /** Convenience count: `newFiles.length`. */
242
244
  newFilesCount: number;
245
+ /**
246
+ * Count of remote keys refused at planning time because they matched
247
+ * `EPHEMERAL_PATH_PATTERN` (conflict-mirror files that must never round-
248
+ * trip through the bucket). Mirrors `ShareResult.filesExcludedByPolicy`
249
+ * so push and pull report the same shape. Pre-fix this count was always
250
+ * 0 on the pull side and legacy `.conflict-*` litter rode every sync —
251
+ * see Bug #2 in workspace/reports/hq-cloud-5.33.0-deep-test.md.
252
+ */
253
+ filesExcludedByPolicy: number;
254
+ /**
255
+ * Count of journal-known keys applied as local deletes during this pull
256
+ * because the remote LIST no longer contains them — the cross-machine
257
+ * delete-propagation signal that Bug #9 closes. The peer's push leg
258
+ * removed the object from S3 (`hq sync` push side verified-to-work in
259
+ * the deep-test addendum), but pre-fix the pull side never enumerated
260
+ * "what's missing-from-remote-that-was-there-before", so the file
261
+ * lingered locally forever. Always 0 when no journal-known keys have
262
+ * disappeared from the remote.
263
+ */
264
+ filesTombstoned: number;
243
265
  }
244
266
 
245
267
  /**
@@ -279,6 +301,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
279
301
  let bytesDownloaded = 0;
280
302
  let filesSkipped = 0;
281
303
  let conflicts = 0;
304
+ let filesTombstoned = 0;
282
305
  const conflictPaths: string[] = [];
283
306
 
284
307
  // List all remote files (IAM session policy filters at the AWS layer)
@@ -322,6 +345,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
322
345
  filesSkipped++;
323
346
  continue;
324
347
  }
348
+ if (item.action === "skip-excluded-policy") {
349
+ // Policy-excluded items count separately from `filesSkipped` so the
350
+ // pull result mirrors the push side's `filesExcludedByPolicy`
351
+ // counter — `filesSkipped` stays a measure of "unchanged on this
352
+ // run", not a catch-all for everything we didn't download.
353
+ continue;
354
+ }
325
355
 
326
356
  const { remoteFile, localPath } = item;
327
357
 
@@ -407,6 +437,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
407
437
  aborted: true,
408
438
  newFiles: plan.newFiles,
409
439
  newFilesCount: plan.newFilesCount,
440
+ filesExcludedByPolicy: plan.filesExcludedByPolicy,
441
+ // Abort short-circuits before the tombstone loop runs; report
442
+ // 0 so the field shape stays stable for consumers that
443
+ // destructure it.
444
+ filesTombstoned: 0,
410
445
  };
411
446
  }
412
447
  if (resolution === "keep" || resolution === "skip") {
@@ -526,6 +561,108 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
526
561
  }
527
562
  emit({ type: "new-files", files: enrichedNewFiles });
528
563
 
564
+ // Codex P1 (PR #24 follow-up): scope-gate tombstone candidates with
565
+ // a per-object HEAD before unlinking. `listRemoteFiles` is STS-scoped
566
+ // (guest sessions with `allowedPrefixes`, role downgrade, custom
567
+ // sync-mode prefix sets — see the AccessDenied branch in the download
568
+ // loop above), so a journal entry's absence from LIST does not prove
569
+ // the object was deleted; it may simply be invisible to this session.
570
+ // HEAD each candidate:
571
+ // - HEAD returns metadata → object exists → NOT in our LIST scope →
572
+ // skip the tombstone (peer didn't delete it; we just can't see it).
573
+ // - HEAD returns null (NotFound) → confirmed deleted → tombstone.
574
+ // - HEAD throws AccessDenied → can't tell → defensive skip; journal
575
+ // stays so next sync (with broader scope) can re-evaluate.
576
+ // - HEAD throws transient → defensive skip + emit error.
577
+ // Bounded concurrency mirrors the new-files attribution pass above.
578
+ if (plan.tombstones.length > 0) {
579
+ const HEAD_VERIFY_CONCURRENCY = 5;
580
+ const verified: string[] = [];
581
+ for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
582
+ const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
583
+ const results = await Promise.all(
584
+ batch.map(async (key) => {
585
+ try {
586
+ const head = await headRemoteFile(ctx, key);
587
+ return head === null ? key : null;
588
+ } catch (err) {
589
+ if (isAccessDenied(err)) return null;
590
+ emit({
591
+ type: "error",
592
+ path: key,
593
+ message: `tombstone HEAD verify failed (deferring): ${
594
+ err instanceof Error ? err.message : String(err)
595
+ }`,
596
+ });
597
+ return null;
598
+ }
599
+ }),
600
+ );
601
+ for (const k of results) {
602
+ if (k !== null) verified.push(k);
603
+ }
604
+ }
605
+ plan.tombstones = verified;
606
+ }
607
+
608
+ // Bug #9 — apply cross-machine delete propagation. Each tombstone is a
609
+ // key the journal records as previously synced but the remote LIST no
610
+ // longer contains. We delete the local file (or symlink, or empty dir
611
+ // remnant) and drop the journal entry so the next sync's planner stays
612
+ // converged. Failures are reported but non-fatal — the entry stays in
613
+ // the journal and the next run retries.
614
+ for (const key of plan.tombstones) {
615
+ const localPath = path.join(companyRoot, key);
616
+ let removedSomething = false;
617
+ try {
618
+ const lstat = fs.lstatSync(localPath);
619
+ if (lstat.isSymbolicLink() || lstat.isFile()) {
620
+ fs.unlinkSync(localPath);
621
+ removedSomething = true;
622
+ } else if (lstat.isDirectory()) {
623
+ // A dir at a key — likely from a (local-dir, cloud-file) historic
624
+ // state. Don't recursively rm-rf the operator's dir; just drop
625
+ // the journal entry so we converge with reality.
626
+ }
627
+ } catch (err: unknown) {
628
+ const code =
629
+ err && typeof err === "object" && "code" in err
630
+ ? (err as { code?: string }).code
631
+ : undefined;
632
+ // ENOENT → local already gone; safe to drop the journal entry.
633
+ // Other errors (EACCES/EPERM/EBUSY/etc.) leave the local file in
634
+ // place — if we dropped the journal entry anyway, the pull side
635
+ // would forget the peer's delete and a later push could re-upload
636
+ // the still-present local file, silently undoing the peer's delete.
637
+ // Surface the error and KEEP the journal entry so the next sync
638
+ // retries the unlink after the operator fixes the permission.
639
+ if (code !== "ENOENT") {
640
+ emit({
641
+ type: "error",
642
+ path: key,
643
+ message: `tombstone unlink failed: ${
644
+ err instanceof Error ? err.message : String(err)
645
+ }`,
646
+ });
647
+ // Skip removeEntry / filesTombstoned / progress event — the
648
+ // tombstone hasn't actually been honored. Next sync retries.
649
+ continue;
650
+ }
651
+ }
652
+ removeEntry(journal, key);
653
+ filesTombstoned++;
654
+ emit({
655
+ type: "progress",
656
+ path: key,
657
+ bytes: 0,
658
+ deleted: true,
659
+ // Suffix differentiates a tombstone from a normal delete in the
660
+ // tty stream — matches the push-side `defaultConsoleLogger`
661
+ // tombstone surface in share.ts.
662
+ message: removedSomething ? "tombstone (cross-machine delete)" : "tombstone (already absent locally)",
663
+ });
664
+ }
665
+
529
666
  // Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
530
667
  // ticks even when nothing transferred. updateEntry only fires on actual
531
668
  // downloads; without this, a no-op sync leaves lastSync at the time of the
@@ -542,6 +679,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
542
679
  aborted: false,
543
680
  newFiles: plan.newFiles,
544
681
  newFilesCount: plan.newFilesCount,
682
+ filesExcludedByPolicy: plan.filesExcludedByPolicy,
683
+ filesTombstoned,
545
684
  };
546
685
  }
547
686
 
@@ -590,6 +729,10 @@ type PullPlanItem =
590
729
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
591
730
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
592
731
  | { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
732
+ // Remote keys refused by ephemeral-mirror policy. The push walker has
733
+ // refused to upload these since 5.33.0; the pull walker now refuses to
734
+ // download them so legacy litter in cloud staging drains naturally.
735
+ | { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string }
593
736
  | {
594
737
  action: "conflict";
595
738
  remoteFile: RemoteFile;
@@ -622,6 +765,15 @@ interface PullPlan {
622
765
  /** Files classified as new (no local counterpart at classification time). */
623
766
  newFiles: Array<{ path: string; bytes: number }>;
624
767
  newFilesCount: number;
768
+ /** Count of remote keys refused by ephemeral-mirror policy. */
769
+ filesExcludedByPolicy: number;
770
+ /**
771
+ * Journal-known keys missing from the remote LIST. The executor will
772
+ * apply each as a local delete (file or symlink) + journal removal,
773
+ * propagating the peer's push-side delete cross-machine (Bug #9).
774
+ * Carried on the plan so the executor can iterate without re-walking.
775
+ */
776
+ tombstones: string[];
625
777
  }
626
778
 
627
779
  /**
@@ -649,6 +801,17 @@ function computePullPlan(
649
801
  for (const remoteFile of remoteFiles) {
650
802
  const localPath = path.join(companyRoot, remoteFile.key);
651
803
 
804
+ // Ephemeral-mirror filter — symmetric with the push-side walker. Bug #2
805
+ // in the 5.33.0 deep-test: the push side has refused to upload conflict
806
+ // mirrors since 5.33.0, but the pull side downloaded them freely from
807
+ // staging, so legacy `.conflict-*` litter rode every sync into clean
808
+ // trees. Refuse them here; the V2 verification test (direct S3 inject
809
+ // bypassing the push filter entirely) is the regression contract.
810
+ if (isEphemeralPath(remoteFile.key)) {
811
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
812
+ continue;
813
+ }
814
+
652
815
  if (personalMode && remoteFile.key.startsWith("companies/")) {
653
816
  // Default: drop every `companies/...` key — the legacy contract
654
817
  // is that the personal bucket should never contain them.
@@ -705,21 +868,47 @@ function computePullPlan(
705
868
  // dangling link on every run.
706
869
  // 3. A regular file: indistinguishable from current behaviour.
707
870
  let localLstat: fs.Stats | null = null;
871
+ let localPathBlockedByFileAncestor = false;
708
872
  try {
709
873
  localLstat = fs.lstatSync(localPath);
710
874
  } catch (err: unknown) {
711
- // ENOENT means truly absent; other errors propagate.
712
- if (
713
- err &&
714
- typeof err === "object" &&
715
- "code" in err &&
716
- (err as { code?: string }).code !== "ENOENT"
717
- ) {
875
+ const code =
876
+ err && typeof err === "object" && "code" in err
877
+ ? (err as { code?: string }).code
878
+ : undefined;
879
+ // ENOENT → truly absent → treated as "new file from cloud".
880
+ // ENOTDIR an ancestor of localPath is a regular file (cloud has a
881
+ // dir at that key, local has a file). Pre-fix this threw
882
+ // and aborted the whole company sync (Bug #10 in the
883
+ // 5.33.0 deep-test verification — \`v4-dir-vs-file\` repro
884
+ // wedged the personal company at \"errored\" status,
885
+ // skipping every later file). Recover by classifying as
886
+ // a structural collision the operator must resolve
887
+ // manually, and let the rest of the company process.
888
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
718
889
  throw err;
719
890
  }
891
+ if (code === "ENOTDIR") {
892
+ localPathBlockedByFileAncestor = true;
893
+ }
720
894
  }
721
895
  const localExists = localLstat !== null;
722
896
 
897
+ if (localPathBlockedByFileAncestor) {
898
+ // Symmetric counterpart to the (local-dir, cloud-file) warning below.
899
+ // Emit the same "manual reconciliation required" surface so the
900
+ // operator sees one consistent message for both topologies; record as
901
+ // skip-local-only so the file is not silently dropped from the
902
+ // count and the executor never tries to lstat/write it.
903
+ console.error(
904
+ ` Warning: an ancestor of ${remoteFile.key} exists locally as a regular file; ` +
905
+ `cloud has a deeper key under that path. Skipping; manual ` +
906
+ `reconciliation required (rm the conflicting local file to pull).`,
907
+ );
908
+ items.push({ action: "skip-local-only", remoteFile, localPath });
909
+ continue;
910
+ }
911
+
723
912
  if (localExists) {
724
913
  const isLocalSymlink = localLstat!.isSymbolicLink();
725
914
  // Kind-mismatch guard: a remote LIST entry is always a single
@@ -788,6 +977,7 @@ function computePullPlan(
788
977
  let bytesToDownload = 0;
789
978
  let filesToSkip = 0;
790
979
  let filesToConflict = 0;
980
+ let filesExcludedByPolicy = 0;
791
981
  const newFiles: Array<{ path: string; bytes: number }> = [];
792
982
  for (const item of items) {
793
983
  if (item.action === "download") {
@@ -798,11 +988,106 @@ function computePullPlan(
798
988
  }
799
989
  } else if (item.action === "conflict") {
800
990
  filesToConflict++;
991
+ } else if (item.action === "skip-excluded-policy") {
992
+ filesExcludedByPolicy++;
993
+ // Excluded-policy items don't roll into filesToSkip — they're a
994
+ // distinct class surfaced via filesExcludedByPolicy so consumers
995
+ // can render a "N refused by policy" line independently of the
996
+ // generic "N unchanged" tally.
801
997
  } else {
802
998
  filesToSkip++;
803
999
  }
804
1000
  }
805
1001
 
1002
+ // Bug #9 — cross-machine delete propagation. The peer's push leg
1003
+ // already removed the file from S3 (verified in the deep-test
1004
+ // addendum: `aws s3 ls` showed the keys absent post-push). Pre-fix
1005
+ // the pull side enumerated remote keys and downloaded anything
1006
+ // missing locally, but never enumerated "what's missing-from-remote-
1007
+ // that-was-there-before" — so the file stayed forever on every
1008
+ // receiver and `filesTombstoned: 0` showed up on every pull.
1009
+ //
1010
+ // Closing the loop: walk the journal, find every entry whose key is
1011
+ // NOT in the remote LIST set, AND whose path passes the current
1012
+ // ignore filter (paths newly excluded by .hqignore must not trigger
1013
+ // mass-delete), AND — in personalMode — survives the same companies/*
1014
+ // gating the download branch applies. The executor will apply each
1015
+ // as a local delete + journal removal. Symmetric to the push side's
1016
+ // `propagateDeletes` plan in share.ts.
1017
+ const remoteKeySet = new Set<string>();
1018
+ for (const rf of remoteFiles) remoteKeySet.add(rf.key);
1019
+ const tombstones: string[] = [];
1020
+ for (const key of Object.keys(journal.files)) {
1021
+ if (remoteKeySet.has(key)) continue;
1022
+ // PersonalMode key gating — mirror the download branch.
1023
+ if (personalMode && key.startsWith("companies/")) {
1024
+ const slug = key.split("/")[1] ?? "";
1025
+ const isTeamSyncedOrphan =
1026
+ teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
1027
+ if (!includeLocalCompanies || isTeamSyncedOrphan) continue;
1028
+ }
1029
+ // Ephemeral keys are filtered both directions; never tombstone-
1030
+ // propagate a conflict-mirror.
1031
+ if (isEphemeralPath(key)) continue;
1032
+ // Honor the current ignore filter — if a path was previously synced
1033
+ // but is now ignored (operator edited .hqignore), do NOT delete
1034
+ // the local copy. They're keeping it deliberately.
1035
+ const localPath = path.join(companyRoot, key);
1036
+ if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
1037
+ // Codex P1 (PR #24 round 3): detect local edits before tombstoning.
1038
+ // Delete-vs-local-edit race: peer deleted the file remotely while
1039
+ // this machine edited it locally before the next sync. Without
1040
+ // this check, the tombstone executor would unlink the locally-
1041
+ // edited file and drop the journal entry, silently destroying the
1042
+ // user's unsynced work. Compare local hash against the journal
1043
+ // baseline; if they diverge, defer the tombstone. The next pull
1044
+ // run after the operator resolves (re-pushes / overwrites) the
1045
+ // local state will re-evaluate.
1046
+ try {
1047
+ const lstat = fs.lstatSync(localPath);
1048
+ if (lstat.isFile()) {
1049
+ const localHash = hashFile(localPath);
1050
+ const journalEntry = journal.files[key];
1051
+ if (journalEntry?.hash && journalEntry.hash !== localHash) {
1052
+ // Local has unsynced edits — defer this tombstone.
1053
+ continue;
1054
+ }
1055
+ } else if (lstat.isSymbolicLink()) {
1056
+ // Codex P1 (PR #24 round 4): symlinks have target strings
1057
+ // that can be locally edited too. Pre-fix, `isFile()` was
1058
+ // false for symlinks so the divergence guard skipped them,
1059
+ // and a locally-edited symlink (`ln -sfn new-target old-link`
1060
+ // before the peer's remote-delete) was unlinked silently —
1061
+ // the exact race the guard is meant to prevent. Compare the
1062
+ // readlink hash against the journal baseline; defer on
1063
+ // divergence. readlink errors (race / EACCES) also defer.
1064
+ try {
1065
+ const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
1066
+ const journalEntry = journal.files[key];
1067
+ if (journalEntry?.hash && journalEntry.hash !== localHash) {
1068
+ continue;
1069
+ }
1070
+ } catch {
1071
+ continue;
1072
+ }
1073
+ }
1074
+ // Directories: no hash comparison. The tombstone executor's
1075
+ // dir branch doesn't recursively rm-rf — it just drops the
1076
+ // journal entry, which is the safe-by-default behavior.
1077
+ } catch (err) {
1078
+ const code =
1079
+ err && typeof err === "object" && "code" in err
1080
+ ? (err as { code?: string }).code
1081
+ : undefined;
1082
+ // ENOENT → local already gone; safe to drop journal entry via
1083
+ // the executor's tombstone path.
1084
+ // Other lstat errors (EACCES on parent dir, etc.) → defer:
1085
+ // we can't read local state, so we can't safely decide.
1086
+ if (code !== "ENOENT") continue;
1087
+ }
1088
+ tombstones.push(key);
1089
+ }
1090
+
806
1091
  return {
807
1092
  items,
808
1093
  filesToDownload,
@@ -811,6 +1096,8 @@ function computePullPlan(
811
1096
  filesToConflict,
812
1097
  newFiles,
813
1098
  newFilesCount: newFiles.length,
1099
+ filesExcludedByPolicy,
1100
+ tombstones,
814
1101
  };
815
1102
  }
816
1103
 
@@ -83,7 +83,7 @@ describe("createIgnoreFilter", () => {
83
83
  expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
84
84
  });
85
85
 
86
- it("permissive mode: .hq-* internal state is ignored, .hqignore family + .hq/ still sync", () => {
86
+ it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
87
87
  const shouldSync = createIgnoreFilter(hqRoot);
88
88
  // Internal state files that must never round-trip through the bucket.
89
89
  expect(shouldSync(path.join(hqRoot, ".hq-sync.pid"))).toBe(false);
@@ -92,11 +92,28 @@ describe("createIgnoreFilter", () => {
92
92
  expect(shouldSync(path.join(hqRoot, ".hq-embeddings-pending.json"))).toBe(false);
93
93
  expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq-foo.json"))).toBe(false);
94
94
  expect(shouldSync(path.join(hqRoot, ".hq-cache/blob.bin"))).toBe(false);
95
- // Sync-config files and the .hq/ directory still sync.
95
+ // Sync-config files still sync (no hyphen, doesn't match `.hq-*`).
96
96
  expect(shouldSync(path.join(hqRoot, ".hqignore"))).toBe(true);
97
97
  expect(shouldSync(path.join(hqRoot, ".hqsyncignore"))).toBe(true);
98
98
  expect(shouldSync(path.join(hqRoot, ".hqinclude"))).toBe(true);
99
- expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq/config.json"))).toBe(true);
99
+ });
100
+
101
+ it("permissive mode: .hq/ directory is per-host state, never synced (Bug #1/#6/#8)", () => {
102
+ // Bug catalog: `.hq/install-manifest.json`, `.hq/machine-id`, and
103
+ // `.hq/machine.json` are per-host source-of-truth files. The original
104
+ // `DEFAULT_IGNORES` only covered the `.hq-*` wildcard (with hyphen) and
105
+ // explicitly left the `.hq/` directory unaffected — so the install
106
+ // manifest and machine-id round-tripped through S3 between hosts,
107
+ // breaking the per-machine identity contract that the 5.33.0
108
+ // machine-id fix relies on.
109
+ const shouldSync = createIgnoreFilter(hqRoot);
110
+ expect(shouldSync(path.join(hqRoot, ".hq/install-manifest.json"))).toBe(false);
111
+ expect(shouldSync(path.join(hqRoot, ".hq/machine-id"))).toBe(false);
112
+ expect(shouldSync(path.join(hqRoot, ".hq/machine.json"))).toBe(false);
113
+ expect(shouldSync(path.join(hqRoot, ".hq/config.json"))).toBe(false);
114
+ expect(shouldSync(path.join(hqRoot, ".hq"), true)).toBe(false);
115
+ // Nested .hq/ inside a company also stays local (per-host scoping).
116
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq/config.json"))).toBe(false);
100
117
  });
101
118
 
102
119
  it("allowlist mode: presence of .hqinclude switches to opt-in", () => {
package/src/ignore.ts CHANGED
@@ -71,9 +71,15 @@ export const DEFAULT_IGNORES = [
71
71
  // covers `.hq-sync.pid`, `.hq-sync-journal.json`, `.hq-sync-state.json`,
72
72
  // `.hq-embeddings-pending.json`, and any future internal-state file. The
73
73
  // `.hqignore` / `.hqsyncignore` / `.hqinclude` config files don't match
74
- // (no hyphen) and the `.hq/` directory is unaffected.
74
+ // (no hyphen). The `.hq/` directory holds per-host source-of-truth files
75
+ // (`install-manifest.json`, `machine-id`, `machine.json`, `config.json`)
76
+ // that MUST NEVER round-trip through the bucket — see Bug #1/#6/#8 in
77
+ // the 5.33.0 deep-test report. Leaking these between machines makes two
78
+ // hosts share an installer id / machine-id and breaks the contract the
79
+ // 5.33.0 machine-id fix relies on.
75
80
  "*.pid",
76
81
  ".hq-*",
82
+ ".hq/",
77
83
  "modules.lock",
78
84
  // hq-root identity marker — discovered locally per-machine, never synced.
79
85
  // Root-anchored: only the literal `core.yaml` at hq-root matches. Without
package/src/s3.test.ts CHANGED
@@ -115,12 +115,21 @@ describe("uploadFile", () => {
115
115
  fs.writeFileSync(tmpFile, "hello");
116
116
  });
117
117
 
118
- it("omits Metadata when no author is provided (back-compat)", async () => {
118
+ it("omits author Metadata fields when no author is provided (back-compat)", async () => {
119
+ // Pre-Bug-#5: this test asserted Metadata was undefined entirely. Now
120
+ // \`hq-mode\` is stamped on every upload to preserve source-side
121
+ // permissions, so the assertion is narrower: author fields stay absent
122
+ // when no author is passed, but \`hq-mode\` is present.
119
123
  await uploadFile(makeCtx(), tmpFile, "attribution-test.md");
120
124
 
121
125
  const put = sentCommands.find((c) => c.name === "PutObjectCommand");
122
126
  expect(put).toBeDefined();
123
- expect(put!.input.Metadata).toBeUndefined();
127
+ const meta = put!.input.Metadata as Record<string, string> | undefined;
128
+ expect(meta?.["created-by"]).toBeUndefined();
129
+ expect(meta?.["created-by-sub"]).toBeUndefined();
130
+ expect(meta?.["created-at"]).toBeUndefined();
131
+ // \`hq-mode\` IS present — that's the new Bug #5 contract.
132
+ expect(meta?.["hq-mode"]).toMatch(/^[0-7]{3}$/);
124
133
  });
125
134
 
126
135
  it("stamps created-by + created-by-sub + created-at when author is provided", async () => {
@@ -179,6 +188,37 @@ describe("uploadFile", () => {
179
188
  expect(Date.now() - stamped).toBeLessThan(60 * 1000);
180
189
  });
181
190
 
191
+ it("stamps source file mode as hq-mode metadata (Bug #5 — preserve permissions)", async () => {
192
+ // Bug #5 (broader than originally reported): every uploaded file's mode
193
+ // collapsed to the receiver's umask default (0644 on the verification
194
+ // hosts). 0755 scripts arrived non-executable, breaking every shell-tool
195
+ // workflow. Fix: stamp the source-side \`mode & 0o777\` into S3 user
196
+ // metadata as \`hq-mode\` (octal string), then chmod on download.
197
+ fs.chmodSync(tmpFile, 0o755);
198
+
199
+ await uploadFile(makeCtx(), tmpFile, "exec.sh");
200
+
201
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
202
+ expect(put).toBeDefined();
203
+ const meta = put!.input.Metadata as Record<string, string> | undefined;
204
+ expect(meta?.["hq-mode"]).toBe("755");
205
+ });
206
+
207
+ it("stamps various modes correctly (0600, 0640, 0700, 0750)", async () => {
208
+ // Verification report V5: all four modes collapsed to 0644 receiver-side
209
+ // because the upload carried no mode at all. Pin each mode round-trips
210
+ // through the metadata header in canonical octal-string form (no leading
211
+ // zero — the parser uses parseInt(..., 8)).
212
+ for (const mode of [0o600, 0o640, 0o700, 0o750]) {
213
+ sentCommands.length = 0;
214
+ fs.chmodSync(tmpFile, mode);
215
+ await uploadFile(makeCtx(), tmpFile, "f.bin");
216
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
217
+ const meta = put!.input.Metadata as Record<string, string> | undefined;
218
+ expect(meta?.["hq-mode"]).toBe(mode.toString(8));
219
+ }
220
+ });
221
+
182
222
  it("elides non-ASCII or empty author fields rather than throwing", async () => {
183
223
  // S3 user-defined metadata must be ASCII-only and total ≤ 2KB. Partial
184
224
  // attribution beats hard failure — values that fail the printable check
@@ -630,6 +670,106 @@ describe("downloadFile", () => {
630
670
  expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
631
671
  });
632
672
 
673
+ it("applies hq-mode metadata via chmod after byte write (Bug #5 — preserve permissions)", async () => {
674
+ // Round-trip pair to the s3.upload test: source-side mode lives in
675
+ // \`Metadata['hq-mode']\` as an octal string; the receiver must chmod
676
+ // the file to that exact mode after writing the bytes. Pre-fix the
677
+ // receiver took the umask default and 0755 scripts arrived 0644.
678
+ nextGetObjectResponse = {
679
+ Body: (async function* () {
680
+ yield new Uint8Array([35, 33, 47, 98, 105, 110]); // "#!/bin"
681
+ })(),
682
+ Metadata: { "hq-mode": "755" },
683
+ };
684
+
685
+ const localPath = path.join(tmpRoot, "exec.sh");
686
+ await downloadFile(makeCtx(), "exec.sh", localPath);
687
+
688
+ // mask to permission bits — the upper bits encode file-type (S_IFREG)
689
+ // and are not preserved on chmod.
690
+ const stat = fs.statSync(localPath);
691
+ expect((stat.mode & 0o777).toString(8)).toBe("755");
692
+ });
693
+
694
+ it("rounds-trips multiple modes (0600/0640/0700/0750/0755) via hq-mode", async () => {
695
+ // Verification report V5 multi-mode pin: every mode collapsed to 0644
696
+ // because the receiver had no mode signal at all. With \`hq-mode\` in
697
+ // metadata, all five modes must arrive at their exact source value.
698
+ for (const octal of ["600", "640", "700", "750", "755"]) {
699
+ nextGetObjectResponse = {
700
+ Body: (async function* () {
701
+ yield new Uint8Array([97]); // "a"
702
+ })(),
703
+ Metadata: { "hq-mode": octal },
704
+ };
705
+ const localPath = path.join(tmpRoot, `mode-${octal}.bin`);
706
+ await downloadFile(makeCtx(), `mode-${octal}.bin`, localPath);
707
+ expect((fs.statSync(localPath).mode & 0o777).toString(8)).toBe(octal);
708
+ }
709
+ });
710
+
711
+ it("rejects malformed hq-mode metadata via strict-octal regex (Codex P2)", async () => {
712
+ // Codex review on PR #24 caught: parseInt(modeOctal, 8) accepts
713
+ // partial-prefix garbage — "755junk" parses to 0o755 instead of
714
+ // NaN — so tampered or malformed metadata could still chmod the
715
+ // local file. Strict regex MUST reject anything that isn't pure
716
+ // octal digits before parseInt sees it; the file then arrives at
717
+ // umask default like the legacy back-compat path.
718
+ const malformed = [
719
+ "755junk", // trailing garbage — parseInt parses 0o755 pre-fix
720
+ "0x755", // hex-looking prefix
721
+ "8", // out-of-octal-range digit
722
+ "9", // ditto
723
+ "-755", // signed
724
+ "abc", // non-numeric
725
+ "", // empty
726
+ "7777", // mode > 0o777 after parse
727
+ "12345", // too long (more than 4 octal digits)
728
+ " 755 ", // whitespace
729
+ ];
730
+ for (const bad of malformed) {
731
+ nextGetObjectResponse = {
732
+ Body: (async function* () {
733
+ yield new Uint8Array([116]); // "t"
734
+ })(),
735
+ Metadata: { "hq-mode": bad },
736
+ };
737
+ const localPath = path.join(tmpRoot, `bad-${malformed.indexOf(bad)}.bin`);
738
+ await downloadFile(makeCtx(), `bad-${malformed.indexOf(bad)}.bin`, localPath);
739
+ // Mode MUST be the umask default (whatever the test process inherits),
740
+ // NOT a partial-parse of the malformed string. Specifically, even if
741
+ // the malformed string would partial-parse to 0755, the file must NOT
742
+ // arrive at 0755 — it must take the umask default. We can't pin the
743
+ // exact default value (test runner umask varies), but we can pin that
744
+ // a partial-parse value (0o755) does NOT match for "755junk" cases.
745
+ const mode = (fs.statSync(localPath).mode & 0o777);
746
+ // Heuristic: if the parsed mode would have been 0o755, fail loudly.
747
+ // (umask default on most CI runners is 0o644 or 0o664, never 0o755.)
748
+ if (bad.startsWith("755") || bad === "0x755") {
749
+ expect(mode).not.toBe(0o755);
750
+ }
751
+ }
752
+ });
753
+
754
+ it("downloads with default umask permissions when hq-mode metadata is absent (back-compat)", async () => {
755
+ // Legacy uploads from pre-fix engines have no \`hq-mode\` metadata.
756
+ // The receiver must NOT crash and must NOT change the mode — let
757
+ // the OS default apply, mirroring the pre-fix behavior.
758
+ nextGetObjectResponse = {
759
+ Body: (async function* () {
760
+ yield new Uint8Array([108, 101, 103, 97, 99, 121]); // "legacy"
761
+ })(),
762
+ Metadata: {},
763
+ };
764
+
765
+ const localPath = path.join(tmpRoot, "legacy.bin");
766
+ await expect(
767
+ downloadFile(makeCtx(), "legacy.bin", localPath),
768
+ ).resolves.toBeDefined();
769
+ // No assertion on mode — receiver default is whatever umask set.
770
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("legacy");
771
+ });
772
+
633
773
  it("returns the object's user-metadata (including created-by) for a regular file", async () => {
634
774
  nextGetObjectResponse = {
635
775
  Body: (async function* () {