@indigoai-us/hq-cloud 6.14.1 → 6.14.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/dist/bin/sync-runner-company.d.ts +4 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +50 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +21 -7
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +162 -11
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.d.ts.map +1 -1
- package/dist/cli/accept.js +29 -0
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/invite.d.ts.map +1 -1
- package/dist/cli/invite.js +33 -0
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/promote.d.ts.map +1 -1
- package/dist/cli/promote.js +33 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/reindex.d.ts +23 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +85 -12
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +95 -13
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.js +47 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +123 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync-scope.test.js +84 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +44 -3
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +133 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.d.ts +21 -0
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +31 -6
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +44 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +23 -2
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +13 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +25 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +25 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +45 -14
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +61 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +36 -6
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +34 -11
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +30 -0
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +29 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +48 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +44 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +75 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +56 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +12 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +12 -0
- package/dist/scope-shrink.js.map +1 -1
- package/dist/scope-shrink.test.js +31 -0
- package/dist/scope-shrink.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts +41 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +111 -7
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +187 -5
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +3 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/pull-scope.d.ts +21 -0
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +46 -13
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/telemetry-events.d.ts +31 -0
- package/dist/telemetry-events.d.ts.map +1 -0
- package/dist/telemetry-events.js +140 -0
- package/dist/telemetry-events.js.map +1 -0
- package/dist/telemetry-events.test.d.ts +2 -0
- package/dist/telemetry-events.test.d.ts.map +1 -0
- package/dist/telemetry-events.test.js +73 -0
- package/dist/telemetry-events.test.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/vault-client.d.ts +38 -5
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +69 -6
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +31 -1
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +49 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +49 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +2 -0
- package/scripts/presign-transport-e2e.mjs +2 -2
- package/src/bin/sync-runner-company.ts +65 -1
- package/src/bin/sync-runner-watch-loop.ts +24 -8
- package/src/bin/sync-runner.test.ts +194 -16
- package/src/bin/sync-runner.ts +209 -11
- package/src/cli/accept.ts +27 -0
- package/src/cli/invite.ts +31 -0
- package/src/cli/promote.ts +31 -0
- package/src/cli/reindex.test.ts +113 -13
- package/src/cli/reindex.ts +123 -23
- package/src/cli/share.test.ts +139 -2
- package/src/cli/share.ts +48 -2
- package/src/cli/sync-scope.test.ts +90 -0
- package/src/cli/sync.test.ts +154 -0
- package/src/cli/sync.ts +224 -5
- package/src/company-resolver.test.ts +51 -0
- package/src/company-resolver.ts +40 -5
- package/src/ignore.test.ts +18 -1
- package/src/ignore.ts +24 -2
- package/src/index.ts +24 -1
- package/src/journal.ts +12 -0
- package/src/lib/exit-codes.ts +24 -0
- package/src/object-io.test.ts +66 -1
- package/src/object-io.ts +51 -14
- package/src/operation-lock.test.ts +33 -0
- package/src/operation-lock.ts +46 -11
- package/src/personal-vault.test.ts +57 -0
- package/src/personal-vault.ts +48 -2
- package/src/s3.test.ts +87 -0
- package/src/s3.ts +93 -2
- package/src/scope-shrink.test.ts +32 -0
- package/src/scope-shrink.ts +12 -0
- package/src/skill-telemetry.test.ts +247 -4
- package/src/skill-telemetry.ts +134 -6
- package/src/sync/event-sync.ts +37 -0
- package/src/sync/pull-scope.ts +62 -13
- package/src/telemetry-events.test.ts +88 -0
- package/src/telemetry-events.ts +205 -0
- package/src/telemetry.ts +1 -1
- package/src/vault-client.test.ts +39 -1
- package/src/vault-client.ts +115 -14
- package/src/watcher.test.ts +70 -0
- package/src/watcher.ts +68 -0
package/src/s3.ts
CHANGED
|
@@ -541,6 +541,90 @@ export function toPosixKey(key: string): string {
|
|
|
541
541
|
return key.split("\\").join("/");
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Client-side mirror of hq-pro's server validator
|
|
546
|
+
* (`vault-service/handlers/files-acl.ts validateObjectKey`). The direct-S3
|
|
547
|
+
* STS transport bypasses server key validation entirely — the upload
|
|
548
|
+
* primitives (uploadFile / uploadSymlink) are the only shared choke point —
|
|
549
|
+
* so we enforce the SAME rules here BEFORE the PUT. Incident 2026-07-11: a
|
|
550
|
+
* client with a stale doubled local tree (companies/{slug}/companies/{slug}/…)
|
|
551
|
+
* pushed literal `companies/…` keys into a company-scoped vault bucket via
|
|
552
|
+
* direct S3; agent boxes could LIST them but the presign download API
|
|
553
|
+
* rejected every one (INVALID_KEY_COMPANIES_SCOPED), erroring the whole
|
|
554
|
+
* company sync fleet-wide. A poisoned key must fail loudly at the writer,
|
|
555
|
+
* not at every reader.
|
|
556
|
+
*
|
|
557
|
+
* `scope` selects the companies/-prefix rule: company vault buckets are
|
|
558
|
+
* already company-scoped so a `companies/…` key is always wrong there;
|
|
559
|
+
* personal vaults legitimately carry `companies/{slug}/…` keys for local
|
|
560
|
+
* (non-cloud) companies. Callers derive scope from the EntityContext uid
|
|
561
|
+
* (`cmp_*` → "company", otherwise "personal").
|
|
562
|
+
*
|
|
563
|
+
* Errors carry the same `code` values the server throws so telemetry and
|
|
564
|
+
* operator messaging line up across client and server.
|
|
565
|
+
*/
|
|
566
|
+
// Matching control chars is the whole point — block NUL/0x00–0x1F/0x7F in keys
|
|
567
|
+
// (they can smuggle past path checks or corrupt HTTP headers on the signed URL).
|
|
568
|
+
// eslint-disable-next-line no-control-regex
|
|
569
|
+
const KEY_CONTROL_CHARS = /[\x00-\x1F\x7F]/;
|
|
570
|
+
|
|
571
|
+
export function validateVaultUploadKey(
|
|
572
|
+
key: string,
|
|
573
|
+
scope: "company" | "personal",
|
|
574
|
+
): void {
|
|
575
|
+
const fail = (message: string, code: string): never => {
|
|
576
|
+
throw Object.assign(new Error(message), { code });
|
|
577
|
+
};
|
|
578
|
+
if (key.length === 0) {
|
|
579
|
+
fail("Invalid key: must not be empty", "INVALID_KEY_EMPTY");
|
|
580
|
+
}
|
|
581
|
+
if (key.startsWith("/")) {
|
|
582
|
+
fail("Invalid key: leading '/' not allowed", "INVALID_KEY_LEADING_SLASH");
|
|
583
|
+
}
|
|
584
|
+
if (key.endsWith("/")) {
|
|
585
|
+
fail(
|
|
586
|
+
"Invalid key: trailing '/' (folder marker, not an object)",
|
|
587
|
+
"INVALID_KEY_TRAILING_SLASH",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (scope === "company" && key.startsWith("companies/")) {
|
|
591
|
+
fail(
|
|
592
|
+
"Invalid key: vault keys are bucket-relative and the vault is already " +
|
|
593
|
+
"company-scoped — do not prefix with 'companies/<slug>/'. This " +
|
|
594
|
+
"usually means a stale doubled local tree " +
|
|
595
|
+
"(companies/<slug>/companies/<slug>/…); remove the inner copy.",
|
|
596
|
+
"INVALID_KEY_COMPANIES_SCOPED",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (KEY_CONTROL_CHARS.test(key)) {
|
|
600
|
+
fail("Invalid key: contains control characters", "INVALID_KEY_CONTROL_CHARS");
|
|
601
|
+
}
|
|
602
|
+
if (key.includes("\\")) {
|
|
603
|
+
// Unreachable after toPosixKey normalization at the upload boundary, but
|
|
604
|
+
// kept so the validator mirrors the server rule set 1:1 for other callers.
|
|
605
|
+
fail(
|
|
606
|
+
"Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
|
|
607
|
+
"INVALID_KEY_BACKSLASH",
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (key.includes("//")) {
|
|
611
|
+
fail("Invalid key: consecutive slashes not allowed", "INVALID_KEY_DOUBLE_SLASH");
|
|
612
|
+
}
|
|
613
|
+
for (const segment of key.split("/")) {
|
|
614
|
+
if (segment === "." || segment === "..") {
|
|
615
|
+
fail(
|
|
616
|
+
"Invalid key: '.' and '..' path components are not allowed",
|
|
617
|
+
"INVALID_KEY_DOT_COMPONENT",
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Scope for {@link validateVaultUploadKey}, derived from the entity uid. */
|
|
624
|
+
function uploadScopeFor(ctx: EntityContext): "company" | "personal" {
|
|
625
|
+
return ctx.uid.startsWith("cmp_") ? "company" : "personal";
|
|
626
|
+
}
|
|
627
|
+
|
|
544
628
|
export async function uploadFile(
|
|
545
629
|
ctx: EntityContext,
|
|
546
630
|
localPath: string,
|
|
@@ -548,8 +632,12 @@ export async function uploadFile(
|
|
|
548
632
|
author?: UploadAuthor,
|
|
549
633
|
precondition?: PutPrecondition,
|
|
550
634
|
): Promise<{ etag: string }> {
|
|
551
|
-
// Boundary guardrail: never store a non-POSIX key (see toPosixKey)
|
|
635
|
+
// Boundary guardrail: never store a non-POSIX key (see toPosixKey), and
|
|
636
|
+
// never store a key the server-side validator would reject (see
|
|
637
|
+
// validateVaultUploadKey — the direct-S3 STS transport bypasses server
|
|
638
|
+
// validation, so this is the last line of defense against key poisoning).
|
|
552
639
|
key = toPosixKey(key);
|
|
640
|
+
validateVaultUploadKey(key, uploadScopeFor(ctx));
|
|
553
641
|
const io = resolveObjectIO(ctx);
|
|
554
642
|
const body = fs.readFileSync(localPath);
|
|
555
643
|
|
|
@@ -616,8 +704,11 @@ export async function uploadSymlink(
|
|
|
616
704
|
author?: UploadAuthor,
|
|
617
705
|
precondition?: PutPrecondition,
|
|
618
706
|
): Promise<{ etag: string }> {
|
|
619
|
-
// Boundary guardrail: never store a non-POSIX key (see toPosixKey)
|
|
707
|
+
// Boundary guardrail: never store a non-POSIX key (see toPosixKey), and
|
|
708
|
+
// never store a key the server-side validator would reject (see
|
|
709
|
+
// validateVaultUploadKey).
|
|
620
710
|
key = toPosixKey(key);
|
|
711
|
+
validateVaultUploadKey(key, uploadScopeFor(ctx));
|
|
621
712
|
const io = resolveObjectIO(ctx);
|
|
622
713
|
const symlinkBody = encodeSymlinkBody(target);
|
|
623
714
|
|
package/src/scope-shrink.test.ts
CHANGED
|
@@ -240,6 +240,38 @@ describe("buildScopeShrinkPlan", () => {
|
|
|
240
240
|
expect(plan.orphans).toEqual([]);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
// US-006: a session transcript authored + pushed locally (direction: "up")
|
|
244
|
+
// under the push-only `sessions/{personUid}/` prefix must NEVER be orphaned by
|
|
245
|
+
// a scope shrink, even though `sessions/` sits outside the effective pull
|
|
246
|
+
// scope (it is subtracted from every pull scope as an excludePrefix). This is
|
|
247
|
+
// the authorship arm of the push-only contract: sessions are pushed, then
|
|
248
|
+
// never pulled, and the direction:"up" exemption keeps the author's own
|
|
249
|
+
// transcript from being pruned. Regression guard for AC4/AC6.
|
|
250
|
+
it("never orphans a locally-authored session (direction: 'up') when sessions is outside pull scope (US-006)", () => {
|
|
251
|
+
const journal: SyncJournal = {
|
|
252
|
+
...emptyJournal(),
|
|
253
|
+
files: {
|
|
254
|
+
"sessions/prs_1/2026-07-11T10-00-00.json": {
|
|
255
|
+
hash: "h",
|
|
256
|
+
size: 1,
|
|
257
|
+
syncedAt: "2026-07-11T00:00:00.000Z",
|
|
258
|
+
direction: "up",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const plan = buildScopeShrinkPlan({
|
|
263
|
+
journal,
|
|
264
|
+
hqRoot,
|
|
265
|
+
// Last pull saw everything ([""] = full scope); the current pull scope
|
|
266
|
+
// covers knowledge but NOT the push-only sessions prefix — exactly the
|
|
267
|
+
// shape the sessions exclusion produces on the download side.
|
|
268
|
+
lastPrefixSet: [""],
|
|
269
|
+
currentPrefixSet: ["knowledge/"],
|
|
270
|
+
});
|
|
271
|
+
expect(plan.orphans).toEqual([]);
|
|
272
|
+
expect(plan.scopeChangeDetected).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
243
275
|
// ── Authorship guard ──────────────────────────────────────────────────────
|
|
244
276
|
// Sync mode decides whether you mirror OTHER people's files; it must never
|
|
245
277
|
// orphan content the caller authored. Owners hold their whole vault by
|
package/src/scope-shrink.ts
CHANGED
|
@@ -18,6 +18,18 @@
|
|
|
18
18
|
* tombstone write — that lives in `journal.ts`. It DOES touch disk for the
|
|
19
19
|
* orphan classification (hash + stat) because cleanliness is a function of
|
|
20
20
|
* the file's current on-disk state vs the journal.
|
|
21
|
+
*
|
|
22
|
+
* PUSH-ONLY PREFIXES ARE NEVER PRUNED HERE (US-006). Session transcripts under
|
|
23
|
+
* `sessions/{personUid}/...` are push-only: pushed into the vault, then
|
|
24
|
+
* subtracted from every pull scope (incl. `all`/owner) via
|
|
25
|
+
* `resolvePullScope().excludePrefixes`. That exclude set is applied ONLY to the
|
|
26
|
+
* download filter (`computePullPlan`) and is DELIBERATELY NOT passed into this
|
|
27
|
+
* module's `currentPrefixSet`. So a session file that was authored locally rides
|
|
28
|
+
* the `direction:"up"` skip below, and one materialized on demand (`hq files
|
|
29
|
+
* get`) rides the pin union already folded into the caller's inclusion
|
|
30
|
+
* `prefixSet` — either way it is never pulled AND never orphaned. Feeding the
|
|
31
|
+
* exclude set into `currentPrefixSet` would prune exactly those files; callers
|
|
32
|
+
* must not.
|
|
21
33
|
*/
|
|
22
34
|
|
|
23
35
|
import * as fs from "fs";
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import {
|
|
6
7
|
extractSkillEvents,
|
|
7
8
|
extractCodexSkillEvents,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
codexRowTurnId,
|
|
10
11
|
parseCodexSessionMeta,
|
|
11
12
|
collectAndSendSkillTelemetry,
|
|
13
|
+
computeSkillVersion,
|
|
12
14
|
readFileRegion,
|
|
13
15
|
} from "./skill-telemetry.js";
|
|
14
16
|
import type { SkillInvocationBatch } from "./vault-client.js";
|
|
@@ -226,6 +228,58 @@ describe("extractCodexSkillToolEvents (Codex model-driven SKILL.md reads)", () =
|
|
|
226
228
|
expect(ev.source).toBe("model");
|
|
227
229
|
});
|
|
228
230
|
|
|
231
|
+
it("reconstructs <slug>:<name> when reading a company skill's real path", () => {
|
|
232
|
+
// Codex loads a company skill by reading companies/<slug>/skills/<name>/
|
|
233
|
+
// SKILL.md; the slug must be recovered so the run attributes to the company
|
|
234
|
+
// rather than being dropped to a bare, unattributed name.
|
|
235
|
+
const ev = extractCodexSkillToolEvents(
|
|
236
|
+
execEnd("sed -n '1,240p' companies/indigo/skills/signals/SKILL.md"),
|
|
237
|
+
ctx,
|
|
238
|
+
)[0];
|
|
239
|
+
expect(ev.skill).toBe("indigo:signals");
|
|
240
|
+
expect(ev.source).toBe("model");
|
|
241
|
+
// The dedup/idempotency uuid keys off the reconstructed token.
|
|
242
|
+
expect(ev.uuid).toBe("codex:skill:sess-x:turn-A:indigo:signals");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("reconstructs the slug for any company and absolute path", () => {
|
|
246
|
+
expect(
|
|
247
|
+
extractCodexSkillToolEvents(
|
|
248
|
+
execEnd("cat /home/ec2-user/hq/companies/beta/skills/support/SKILL.md"),
|
|
249
|
+
ctx,
|
|
250
|
+
)[0].skill,
|
|
251
|
+
).toBe("beta:support");
|
|
252
|
+
// Hyphenated slug + hyphenated skill name both survive.
|
|
253
|
+
expect(
|
|
254
|
+
extractCodexSkillToolEvents(
|
|
255
|
+
execEnd("rg -n x companies/indigo-auto/skills/call-prep-brief/SKILL.md"),
|
|
256
|
+
ctx,
|
|
257
|
+
)[0].skill,
|
|
258
|
+
).toBe("indigo-auto:call-prep-brief");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("leaves an already-namespaced symlink read untouched (no double prefix)", () => {
|
|
262
|
+
// Reading via the .claude/skills/<slug>:<name>/ symlink already carries the
|
|
263
|
+
// prefix in the captured directory — it must NOT get a second one.
|
|
264
|
+
expect(
|
|
265
|
+
extractCodexSkillToolEvents(
|
|
266
|
+
execEnd("cat .claude/skills/indigo:signals/SKILL.md"),
|
|
267
|
+
ctx,
|
|
268
|
+
)[0].skill,
|
|
269
|
+
).toBe("indigo:signals");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("does not fabricate a company prefix for a non-company skill", () => {
|
|
273
|
+
// A core skill read from its bare directory stays bare — the reconstruction
|
|
274
|
+
// only triggers on the companies/<slug>/skills/ shape.
|
|
275
|
+
expect(
|
|
276
|
+
extractCodexSkillToolEvents(
|
|
277
|
+
execEnd("cat .claude/skills/deploy/SKILL.md"),
|
|
278
|
+
ctx,
|
|
279
|
+
)[0].skill,
|
|
280
|
+
).toBe("deploy");
|
|
281
|
+
});
|
|
282
|
+
|
|
229
283
|
it("matches normal Codex skill paths without backtracking ambiguity", () => {
|
|
230
284
|
expect(
|
|
231
285
|
extractCodexSkillToolEvents(
|
|
@@ -1102,10 +1156,13 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
|
|
|
1102
1156
|
await fs.writeFile(
|
|
1103
1157
|
path.join(dir, "s.jsonl"),
|
|
1104
1158
|
[
|
|
1105
|
-
// cwd inside the company repo → attributed.
|
|
1159
|
+
// Core skill, cwd inside the company repo → attributed by cwd.
|
|
1106
1160
|
row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:00:00Z", cwd: inRepo, uuid: "u1", message: { role: "user", content: "<command-name>/deploy</command-name>" } }),
|
|
1107
|
-
//
|
|
1161
|
+
// Company skill `indigo:hello-world` run at hqRoot (maps to no repo) →
|
|
1162
|
+
// attributed by SKILL NAMESPACE to the indigo company, regardless of cwd.
|
|
1108
1163
|
row({ type: "assistant", sessionId: "s1", timestamp: "2026-06-10T10:01:00Z", cwd: atRoot, message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "indigo:hello-world" } }] } }),
|
|
1164
|
+
// Core skill at hqRoot (no company repo, no namespace) → unattributed.
|
|
1165
|
+
row({ type: "user", sessionId: "s1", timestamp: "2026-06-10T10:02:00Z", cwd: atRoot, uuid: "u3", message: { role: "user", content: "<command-name>/handoff</command-name>" } }),
|
|
1109
1166
|
].join("\n") + "\n",
|
|
1110
1167
|
"utf-8",
|
|
1111
1168
|
);
|
|
@@ -1121,11 +1178,15 @@ describe("collectAndSendSkillTelemetry — companyUid attribution", () => {
|
|
|
1121
1178
|
cursorPath: path.join(tmp, "cursor.json"),
|
|
1122
1179
|
});
|
|
1123
1180
|
|
|
1124
|
-
expect(result.eventsSent).toBe(
|
|
1181
|
+
expect(result.eventsSent).toBe(3);
|
|
1125
1182
|
const events = captured.flatMap((b) => b.events);
|
|
1126
1183
|
const bySkill = new Map(events.map((e) => [e.skill, e]));
|
|
1184
|
+
// Core skill attributed by its cwd (inside the company repo).
|
|
1127
1185
|
expect(bySkill.get("deploy")!.companyUid).toBe(COMPANY_UID);
|
|
1128
|
-
|
|
1186
|
+
// Company skill attributed by its namespace, even though cwd maps to no repo.
|
|
1187
|
+
expect(bySkill.get("indigo:hello-world")!.companyUid).toBe(COMPANY_UID);
|
|
1188
|
+
// Core skill at hqRoot with no owning repo → stays unattributed.
|
|
1189
|
+
expect("companyUid" in bySkill.get("handoff")!).toBe(false);
|
|
1129
1190
|
// Never the reserved sentinel.
|
|
1130
1191
|
for (const e of events) expect(e.companyUid).not.toBe("unattributed");
|
|
1131
1192
|
|
|
@@ -1275,3 +1336,185 @@ describe("readFileRegion — HQ-SYNC-WEB-15 (SIGABRT on >2GiB single read)", ()
|
|
|
1275
1336
|
expect(got).toBe(30);
|
|
1276
1337
|
});
|
|
1277
1338
|
});
|
|
1339
|
+
|
|
1340
|
+
describe("computeSkillVersion (US-015)", () => {
|
|
1341
|
+
function sha256(bytes: string): string {
|
|
1342
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
it("hashes .claude/skills/<skill>/SKILL.md to a sha256:<hex> marker", async () => {
|
|
1346
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
|
|
1347
|
+
const body = "---\nname: deploy\n---\nDeploy things.\n";
|
|
1348
|
+
const dir = path.join(tmp, ".claude", "skills", "deploy");
|
|
1349
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1350
|
+
await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
|
|
1351
|
+
|
|
1352
|
+
expect(await computeSkillVersion(tmp, "deploy")).toBe(sha256(body));
|
|
1353
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
it("resolves a namespaced skill by its full invocation name (colon in dir name)", async () => {
|
|
1357
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
|
|
1358
|
+
const body = "hello world skill\n";
|
|
1359
|
+
const dir = path.join(tmp, ".claude", "skills", "indigo:hello-world");
|
|
1360
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1361
|
+
await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
|
|
1362
|
+
|
|
1363
|
+
expect(await computeSkillVersion(tmp, "indigo:hello-world")).toBe(sha256(body));
|
|
1364
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
it("falls back to .agents/skills/ (Codex bridge layout)", async () => {
|
|
1368
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
|
|
1369
|
+
const body = "codex-bridged skill\n";
|
|
1370
|
+
const dir = path.join(tmp, ".agents", "skills", "learn");
|
|
1371
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1372
|
+
await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
|
|
1373
|
+
|
|
1374
|
+
expect(await computeSkillVersion(tmp, "learn")).toBe(sha256(body));
|
|
1375
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("returns undefined for a missing hqRoot, a missing file, or a traversal attempt", async () => {
|
|
1379
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
|
|
1380
|
+
expect(await computeSkillVersion(undefined, "deploy")).toBeUndefined();
|
|
1381
|
+
expect(await computeSkillVersion(tmp, "does-not-exist")).toBeUndefined();
|
|
1382
|
+
// Path-traversal / separator-bearing names never map to a SKILL.md here.
|
|
1383
|
+
expect(await computeSkillVersion(tmp, "../../etc/passwd")).toBeUndefined();
|
|
1384
|
+
expect(await computeSkillVersion(tmp, "a/b")).toBeUndefined();
|
|
1385
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
describe("collectAndSendSkillTelemetry — skillVersion emission (US-015)", () => {
|
|
1390
|
+
function stubClient(captured: SkillInvocationBatch[]) {
|
|
1391
|
+
return {
|
|
1392
|
+
async getTelemetryOptIn() {
|
|
1393
|
+
return { enabled: true, updatedAt: null };
|
|
1394
|
+
},
|
|
1395
|
+
async postSkillInvocations(batch: SkillInvocationBatch) {
|
|
1396
|
+
captured.push(batch);
|
|
1397
|
+
return { ok: true, written: batch.events.length, skipped: [] };
|
|
1398
|
+
},
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
function row(obj: Record<string, unknown>): string {
|
|
1402
|
+
return JSON.stringify(obj);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
it("stamps the SKILL.md content hash onto the shipped event", async () => {
|
|
1406
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-emit-"));
|
|
1407
|
+
// hqRoot IS the session cwd, so the invocation passes the scope filter and
|
|
1408
|
+
// the resolver can find the skill under hqRoot/.claude/skills.
|
|
1409
|
+
const hqRoot = path.join(tmp, "hq");
|
|
1410
|
+
const skillBody = "---\nname: deploy\n---\nDeploy.\n";
|
|
1411
|
+
const skillDir = path.join(hqRoot, ".claude", "skills", "deploy");
|
|
1412
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
1413
|
+
await fs.writeFile(path.join(skillDir, "SKILL.md"), skillBody, "utf-8");
|
|
1414
|
+
|
|
1415
|
+
const projects = path.join(tmp, "projects");
|
|
1416
|
+
const dir = path.join(projects, "-hq");
|
|
1417
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1418
|
+
await fs.writeFile(
|
|
1419
|
+
path.join(dir, "s.jsonl"),
|
|
1420
|
+
row({
|
|
1421
|
+
type: "user",
|
|
1422
|
+
sessionId: "s1",
|
|
1423
|
+
timestamp: "2026-07-09T09:00:00Z",
|
|
1424
|
+
cwd: hqRoot,
|
|
1425
|
+
uuid: "u1",
|
|
1426
|
+
message: { role: "user", content: "<command-name>/deploy</command-name>" },
|
|
1427
|
+
}) + "\n",
|
|
1428
|
+
"utf-8",
|
|
1429
|
+
);
|
|
1430
|
+
|
|
1431
|
+
const captured: SkillInvocationBatch[] = [];
|
|
1432
|
+
const result = await collectAndSendSkillTelemetry({
|
|
1433
|
+
client: stubClient(captured),
|
|
1434
|
+
machineId: "m",
|
|
1435
|
+
installerVersion: "t",
|
|
1436
|
+
hqRoot,
|
|
1437
|
+
claudeProjectsRoot: projects,
|
|
1438
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
1439
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
expect(result.eventsSent).toBe(1);
|
|
1443
|
+
const ev = captured[0].events[0];
|
|
1444
|
+
expect(ev.skill).toBe("deploy");
|
|
1445
|
+
expect(ev.skillVersion).toBe(
|
|
1446
|
+
`sha256:${createHash("sha256").update(skillBody).digest("hex")}`,
|
|
1447
|
+
);
|
|
1448
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it("emits the injected resolver's marker (wiring is decoupled from disk)", async () => {
|
|
1452
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-inj-"));
|
|
1453
|
+
const projects = path.join(tmp, "projects");
|
|
1454
|
+
const dir = path.join(projects, "-p");
|
|
1455
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1456
|
+
await fs.writeFile(
|
|
1457
|
+
path.join(dir, "s.jsonl"),
|
|
1458
|
+
row({
|
|
1459
|
+
type: "assistant",
|
|
1460
|
+
sessionId: "s2",
|
|
1461
|
+
timestamp: "2026-07-09T10:00:00Z",
|
|
1462
|
+
cwd: "/x",
|
|
1463
|
+
message: {
|
|
1464
|
+
role: "assistant",
|
|
1465
|
+
content: [{ type: "tool_use", id: "t1", name: "Skill", input: { skill: "code-review" } }],
|
|
1466
|
+
},
|
|
1467
|
+
}) + "\n",
|
|
1468
|
+
"utf-8",
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
const captured: SkillInvocationBatch[] = [];
|
|
1472
|
+
const result = await collectAndSendSkillTelemetry({
|
|
1473
|
+
client: stubClient(captured),
|
|
1474
|
+
machineId: "m",
|
|
1475
|
+
installerVersion: "t",
|
|
1476
|
+
claudeProjectsRoot: projects,
|
|
1477
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
1478
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
1479
|
+
resolveSkillVersion: (skill) => `sha256:stub-for-${skill}`,
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
expect(result.eventsSent).toBe(1);
|
|
1483
|
+
expect(captured[0].events[0].skillVersion).toBe("sha256:stub-for-code-review");
|
|
1484
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
it("omits skillVersion when the SKILL.md can't be resolved (additive/back-compat)", async () => {
|
|
1488
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-none-"));
|
|
1489
|
+
const projects = path.join(tmp, "projects");
|
|
1490
|
+
const dir = path.join(projects, "-p");
|
|
1491
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1492
|
+
await fs.writeFile(
|
|
1493
|
+
path.join(dir, "s.jsonl"),
|
|
1494
|
+
row({
|
|
1495
|
+
type: "user",
|
|
1496
|
+
sessionId: "s3",
|
|
1497
|
+
timestamp: "2026-07-09T11:00:00Z",
|
|
1498
|
+
cwd: "/x",
|
|
1499
|
+
uuid: "u3",
|
|
1500
|
+
message: { role: "user", content: "<command-name>/unknown-skill</command-name>" },
|
|
1501
|
+
}) + "\n",
|
|
1502
|
+
"utf-8",
|
|
1503
|
+
);
|
|
1504
|
+
|
|
1505
|
+
// No hqRoot → the default resolver can't locate any SKILL.md → field omitted.
|
|
1506
|
+
const captured: SkillInvocationBatch[] = [];
|
|
1507
|
+
const result = await collectAndSendSkillTelemetry({
|
|
1508
|
+
client: stubClient(captured),
|
|
1509
|
+
machineId: "m",
|
|
1510
|
+
installerVersion: "t",
|
|
1511
|
+
claudeProjectsRoot: projects,
|
|
1512
|
+
codexSessionsRoot: path.join(tmp, "codex"),
|
|
1513
|
+
cursorPath: path.join(tmp, "cursor.json"),
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
expect(result.eventsSent).toBe(1);
|
|
1517
|
+
expect("skillVersion" in captured[0].events[0]).toBe(false);
|
|
1518
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1519
|
+
});
|
|
1520
|
+
});
|