@indigoai-us/hq-cloud 6.14.45 → 6.14.47

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
@@ -398,10 +398,14 @@ export type SyncProgressEvent =
398
398
  }
399
399
  | {
400
400
  /**
401
- * Emitted by the PULL leg once per remote key skipped because the server
402
- * will never presign it (for example, a `companies/…`-prefixed key in a
403
- * company-scoped vault or a key with control characters). Such keys are
404
- * bucket poisoning from an outdated direct-S3 client. Like
401
+ * Emitted once per key skipped because it is permanently unstorable.
402
+ *
403
+ * PULL leg: a remote key the server will never presign (for example, a
404
+ * `companies/…`-prefixed key in a company-scoped vault or a key with
405
+ * control characters) — bucket poisoning from an outdated direct-S3
406
+ * client. PUSH leg: a local key the upload validator would reject, so
407
+ * the upload can only ever throw — a doubled-tree `companies/…` key, or
408
+ * a control-character path such as a macOS Finder `Icon\r` file. Like
405
409
  * `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad
406
410
  * object must never error a whole company sync (erroring gated
407
411
  * heartbeat toolset refreshes fleet-wide in the incident). Surfaced for
@@ -33,6 +33,24 @@ describe("isExpectedIgnore", () => {
33
33
  expect(isExpectedIgnore(".claude/skills/foo/.skill-media/hero.png")).toBe(true);
34
34
  });
35
35
 
36
+ it("classifies macOS Finder Icon\\r files as expected noise", () => {
37
+ // Finder scatters one into every directory it renders a custom icon for,
38
+ // and a folder-level cloud-sync agent propagates them tree-wide. Treated
39
+ // exactly like .DS_Store so a seeded tree stays silent rather than firing
40
+ // the "EXCLUDED from sync — review in case this is unintended" warning
41
+ // once per file.
42
+ expect(isExpectedIgnore("Icon\r")).toBe(true);
43
+ expect(isExpectedIgnore("companies/indigo/Icon\r")).toBe(true);
44
+ });
45
+
46
+ it("keeps a real file named Icons noteworthy (no `Icon?` over-match)", () => {
47
+ // The `Icon?` gitignore workaround would swallow these. Dropping real
48
+ // user content is a worse failure than the cruft being excluded.
49
+ expect(isExpectedIgnore("companies/indigo/Icons")).toBe(false);
50
+ expect(isExpectedIgnore("companies/indigo/Icon")).toBe(false);
51
+ expect(isExpectedIgnore("companies/indigo/Icons.md")).toBe(false);
52
+ });
53
+
36
54
  it("keeps nested repos/workspace and ordinary content noteworthy", () => {
37
55
  expect(isExpectedIgnore("companies/indigo/knowledge/repos/foo/notes.md")).toBe(false);
38
56
  expect(isExpectedIgnore("companies/indigo/knowledge/overview.md")).toBe(false);
@@ -85,6 +103,43 @@ describe("createIgnoreFilter", () => {
85
103
  expect(shouldSync(path.join(hqRoot, "node_modules/react/index.js"))).toBe(false);
86
104
  });
87
105
 
106
+ it("permissive mode: macOS Finder Icon\\r files never enter the walk", () => {
107
+ // A CR is a control character, so the vault key validator rejects the key
108
+ // (INVALID_KEY_CONTROL_CHARS) and the upload can only ever throw. Dropping
109
+ // them during the walk is what keeps them off the upload path entirely.
110
+ const shouldSync = createIgnoreFilter(hqRoot);
111
+ expect(shouldSync(path.join(hqRoot, "Icon\r"))).toBe(false);
112
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon\r"))).toBe(false);
113
+ expect(shouldSync(path.join(hqRoot, ".agents/Icon\r"))).toBe(false);
114
+ // Caught at any depth, like the other OS-cruft exclusions.
115
+ expect(shouldSync(path.join(hqRoot, "a/b/c/d/Icon\r"))).toBe(false);
116
+ });
117
+
118
+ it("permissive mode: a real file named Icons still syncs", () => {
119
+ // Regression guard for the `Icon?` gitignore workaround, whose `?`
120
+ // single-char wildcard also matches Icons / Icon1 / IconX. This exclusion
121
+ // matches the exact Finder byte sequence instead, so real content survives.
122
+ const shouldSync = createIgnoreFilter(hqRoot);
123
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icons"))).toBe(true);
124
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon"))).toBe(true);
125
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icon1"))).toBe(true);
126
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/Icons.md"))).toBe(true);
127
+ });
128
+
129
+ it("a literal Icon+CR line in .gitignore does NOT reach the ignore lib", () => {
130
+ // This is why the exclusion is a predicate and not a DEFAULT_IGNORES
131
+ // entry. ignore@5 strips a trailing CR from a pattern line, so the
132
+ // spelling git itself requires silently never matches. Pinning the
133
+ // upstream behavior here means a future ignore-lib bump that changes it
134
+ // surfaces as a failing test rather than a silent double-exclusion.
135
+ fs.writeFileSync(path.join(hqRoot, ".gitignore"), "Icon\r\n");
136
+ const shouldSync = createIgnoreFilter(hqRoot);
137
+ // Still excluded — by the predicate, not by the pattern.
138
+ expect(shouldSync(path.join(hqRoot, "Icon\r"))).toBe(false);
139
+ // Proof the pattern itself is inert: it did not take `Icons` with it.
140
+ expect(shouldSync(path.join(hqRoot, "Icons"))).toBe(true);
141
+ });
142
+
88
143
  it("repos/ exclusion is root-anchored to hqRoot (DEV-1791)", () => {
89
144
  const shouldSync = createIgnoreFilter(hqRoot);
90
145
  expect(shouldSync(path.join(hqRoot, "repos/private/hq-cloud/src/x.ts"))).toBe(false);
package/src/ignore.ts CHANGED
@@ -244,6 +244,52 @@ function segmentToRegex(seg: string): RegExp {
244
244
  return new RegExp(`^${body}$`);
245
245
  }
246
246
 
247
+ /**
248
+ * Match a path whose basename is the macOS Finder custom-icon file: the
249
+ * literal `Icon` followed by a carriage return (U+000D).
250
+ *
251
+ * This CANNOT be expressed as a `DEFAULT_IGNORES` entry, which is why it is a
252
+ * predicate instead of a pattern string. Two independent reasons:
253
+ *
254
+ * 1. A pattern line ending in a literal CR is inert. The `ignore` package
255
+ * strips the trailing CR, so the line degrades to `Icon` and never
256
+ * matches — verified against ignore@5.3.2:
257
+ * `ignore().add("Icon\r\n").ignores("Icon\r") === false`. Git does the
258
+ * same thing (it reads the CR as part of the line ending), which is why
259
+ * the GitHub macOS template has to end that line with TWO CRs.
260
+ * 2. The usual workaround pattern `Icon?` DOES match, but `?` is a
261
+ * single-character wildcard in gitignore syntax, so it also swallows any
262
+ * real file named `Icons`, `Icon1`, `IconX`. Silently dropping user
263
+ * content is worse than the cruft we are excluding.
264
+ *
265
+ * Matching the exact byte sequence keeps the exclusion precise. Finder writes
266
+ * these into every directory it renders a custom icon for, and a folder-level
267
+ * cloud-sync agent (iCloud Desktop, backup tooling) propagates them across an
268
+ * entire tree — including into `.git/`. They carry no user content.
269
+ *
270
+ * They are also unsyncable by construction: a CR is a control character, so
271
+ * the vault key validator rejects the key outright
272
+ * (INVALID_KEY_CONTROL_CHARS). Excluding them at the walk is what keeps them
273
+ * from reaching the upload path at all.
274
+ */
275
+ export function isMacFinderIconFile(relPath: string): boolean {
276
+ const basename = relPath.replace(/\/$/, "").split("/").pop() ?? "";
277
+ return basename === "Icon\r";
278
+ }
279
+
280
+ /**
281
+ * True when any segment of `relPath` contains a character the vault key
282
+ * validator rejects (see `KEY_CONTROL_CHARS` in s3.ts — C0 controls plus DEL).
283
+ *
284
+ * Kept in lock-step with that validator on purpose: a key this returns `true`
285
+ * for can never be stored, so planning an upload for it only produces a
286
+ * guaranteed per-file failure.
287
+ */
288
+ export function hasControlCharacters(relPath: string): boolean {
289
+ // eslint-disable-next-line no-control-regex
290
+ return /[\x00-\x1F\x7F]/.test(relPath);
291
+ }
292
+
247
293
  export const EXPECTED_IGNORE_SEGMENTS = new Set([
248
294
  ".git",
249
295
  "node_modules",
@@ -307,6 +353,11 @@ export function isExpectedIgnore(relPath: string): boolean {
307
353
  return (
308
354
  basename === ".DS_Store" ||
309
355
  basename === "Thumbs.db" ||
356
+ // Same class as .DS_Store: OS-generated, zero user content. Classified
357
+ // expected so a tree full of them stays silent instead of firing the
358
+ // "EXCLUDED from sync — review in case this is unintended" warning once
359
+ // per file (Finder scatters one into every directory it touches).
360
+ basename === "Icon\r" ||
310
361
  basename === "company.yaml" ||
311
362
  basename === "INDEX.md" ||
312
363
  basename === "modules.lock" ||
@@ -373,6 +424,12 @@ export function createIgnoreFilter(
373
424
  const relative = path.relative(hqRoot, filePath);
374
425
  if (!relative || relative.startsWith("..")) return true; // outside HQ root
375
426
 
427
+ // macOS Finder custom-icon files. Excluded here rather than as a
428
+ // DEFAULT_IGNORES pattern because neither available spelling works —
429
+ // see isMacFinderIconFile for the ignore@5 CR-stripping proof and the
430
+ // `Icon?` over-match hazard.
431
+ if (isMacFinderIconFile(relative)) return false;
432
+
376
433
  // Gitignore dir-only patterns (`foo/`) only match candidate paths that
377
434
  // end with `/`. The `ignore` lib has no stat awareness, so when the
378
435
  // caller knows the entry is a directory we hand the matcher the
package/src/s3.test.ts CHANGED
@@ -1679,6 +1679,12 @@ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 202
1679
1679
  ["a/../b.md", "INVALID_KEY_DOT_COMPONENT"],
1680
1680
  ["a/./b.md", "INVALID_KEY_DOT_COMPONENT"],
1681
1681
  ["bad\x00key.md", "INVALID_KEY_CONTROL_CHARS"],
1682
+ // macOS Finder custom-icon file. Named explicitly because this is the
1683
+ // spelling that reaches the validator in the wild: Finder writes one into
1684
+ // every directory it renders a custom icon for, and the push walker now
1685
+ // excludes them precisely because this throw is unavoidable.
1686
+ ["Icon\r", "INVALID_KEY_CONTROL_CHARS"],
1687
+ ["docs/Icon\r", "INVALID_KEY_CONTROL_CHARS"],
1682
1688
  ])(
1683
1689
  "validateVaultUploadKey mirrors the server rule set: %j → %s",
1684
1690
  (key, code) => {
@@ -50,13 +50,21 @@ interface CompanyFixture {
50
50
  name: string;
51
51
  }
52
52
 
53
- function makeJoinerRoot(companies: readonly CompanyFixture[]): string {
53
+ function makeJoinerRoot(
54
+ companies: readonly CompanyFixture[],
55
+ opts: { createCompanyDirs?: boolean } = {},
56
+ ): string {
57
+ const createCompanyDirs = opts.createCompanyDirs ?? true;
54
58
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-joiner-"));
55
59
  fs.mkdirSync(path.join(root, "companies"), { recursive: true });
56
60
  // The pull materializes the company directory; the manifest entry is the
57
- // thing that was missing.
58
- for (const c of companies) {
59
- fs.mkdirSync(path.join(root, "companies", c.slug), { recursive: true });
61
+ // thing that was missing. When `createCompanyDirs` is false we DON'T
62
+ // pre-create them — that models a company with zero shared files, where the
63
+ // runner itself must materialize the directory (see the zero-file test).
64
+ if (createCompanyDirs) {
65
+ for (const c of companies) {
66
+ fs.mkdirSync(path.join(root, "companies", c.slug), { recursive: true });
67
+ }
60
68
  }
61
69
  fs.writeFileSync(
62
70
  path.join(root, "companies", "manifest.yaml"),
@@ -202,6 +210,37 @@ describe("joiner manifest reconciliation (end to end)", () => {
202
210
  expect(readConfig(hqRoot).activeCompany).toBe("boring-ecom");
203
211
  });
204
212
 
213
+ it("materializes the company directory and writes the manifest entry even when zero files are shared", async () => {
214
+ // The agency/client-sharing case: a member genuinely belongs to a company,
215
+ // but nothing has been shared into their scope yet, so the pull downloads
216
+ // zero files and never creates `companies/<slug>/`. Before the fix the
217
+ // reconciler (which gates on that directory) skipped the slug and HQ never
218
+ // learned about the company. The runner now materializes the directory on a
219
+ // completed pull leg regardless of file count, so the manifest entry lands.
220
+ hqRoot = makeJoinerRoot([BORING_ECOM], { createCompanyDirs: false });
221
+ const deps = makeDeps([BORING_ECOM]);
222
+
223
+ // Precondition: the directory does NOT exist yet.
224
+ expect(fs.existsSync(path.join(hqRoot, "companies", "boring-ecom"))).toBe(
225
+ false,
226
+ );
227
+
228
+ expect(await runRunner(["--companies", "--hq-root", hqRoot], deps)).toBe(0);
229
+
230
+ // The runner created the directory even though the pull shared nothing...
231
+ expect(fs.existsSync(path.join(hqRoot, "companies", "boring-ecom"))).toBe(
232
+ true,
233
+ );
234
+ // ...and the manifest entry is present, so HQ now knows about the company.
235
+ const companies = readManifest(hqRoot);
236
+ expect(companies["boring-ecom"]).toEqual({
237
+ name: "Boring Ecom",
238
+ cloud_uid: "cmp_01KR1QTBSATVKFRTZ5CFNEYA0Q",
239
+ bucket_name: "hq-vault-boring-ecom",
240
+ });
241
+ expect(readConfig(hqRoot).activeCompany).toBe("boring-ecom");
242
+ });
243
+
205
244
  it("writes both entries but refuses to guess activeCompany for a two-company member", async () => {
206
245
  const acme: CompanyFixture = { uid: "cmp_acme", slug: "acme", name: "Acme" };
207
246
  hqRoot = makeJoinerRoot([BORING_ECOM, acme]);