@indigoai-us/hq-cloud 6.11.15 → 6.11.17
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/.github/workflows/publish.yml +20 -64
- package/dist/agent-codex-instructions.d.ts +84 -0
- package/dist/agent-codex-instructions.d.ts.map +1 -0
- package/dist/agent-codex-instructions.js +275 -0
- package/dist/agent-codex-instructions.js.map +1 -0
- package/dist/agent-codex-instructions.test.d.ts +2 -0
- package/dist/agent-codex-instructions.test.d.ts.map +1 -0
- package/dist/agent-codex-instructions.test.js +271 -0
- package/dist/agent-codex-instructions.test.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +11 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +19 -2
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +11 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +39 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +328 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.js +12 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +44 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/context.d.ts +8 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +16 -6
- package/dist/context.js.map +1 -1
- package/dist/skill-telemetry.d.ts +15 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +34 -3
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +75 -1
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +16 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +21 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-codex-instructions.test.ts +332 -0
- package/src/agent-codex-instructions.ts +309 -0
- package/src/bin/sync-runner-planning.ts +34 -2
- package/src/bin/sync-runner.test.ts +413 -0
- package/src/bin/sync-runner.ts +53 -0
- package/src/cli/sync.test.ts +53 -0
- package/src/cli/sync.ts +14 -1
- package/src/context.ts +17 -6
- package/src/skill-telemetry.test.ts +94 -0
- package/src/skill-telemetry.ts +51 -3
- package/src/vault-client.ts +24 -0
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
} from "./sync-runner.js";
|
|
34
34
|
import { FakeClock } from "../watcher.js";
|
|
35
35
|
import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
|
|
36
|
+
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
36
37
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
37
38
|
import type { SyncResult, SyncOptions } from "../cli/sync.js";
|
|
38
39
|
import type { ShareResult, ShareOptions } from "../cli/share.js";
|
|
@@ -4689,3 +4690,415 @@ describe("runRunner — companyVaultUsesPresign flag", () => {
|
|
|
4689
4690
|
expect(capturedConfig?.companyVaultUsesPresign).toBe(false);
|
|
4690
4691
|
});
|
|
4691
4692
|
});
|
|
4693
|
+
|
|
4694
|
+
// ===========================================================================
|
|
4695
|
+
// US-004 — HARD GATE: `--personal` for an AGENT machine identity
|
|
4696
|
+
// ===========================================================================
|
|
4697
|
+
//
|
|
4698
|
+
// Phase-2 of agent-personal-overlay-sync vendors `@indigoai-us/hq-cloud@latest`
|
|
4699
|
+
// onto each headless agent box and runs `hq-sync-runner --personal` to sync the
|
|
4700
|
+
// agent's own personal overlay. US-001..US-003 proved the PERSON-entity (prs_*)
|
|
4701
|
+
// `--personal` path (feat/secrets-personal-entities). This block exercises the
|
|
4702
|
+
// genuinely-unverified AGENT-entity (agt_*) delta.
|
|
4703
|
+
//
|
|
4704
|
+
// ╔═══════════════════════════════════════════════════════════════════════╗
|
|
4705
|
+
// ║ HARD-GATE RESULT: UNBLOCKED by US-013 (agent-aware --personal). ║
|
|
4706
|
+
// ║ The agent --personal READ is now reachable in the runner: the agent's ║
|
|
4707
|
+
// ║ own entity (type:"agent") is selected for the personal slot by the ║
|
|
4708
|
+
// ║ agent-aware resolver (pickAgentSelfEntity), `agt_` is a known UID ║
|
|
4709
|
+
// ║ prefix, and an agt_ uid vends via /sts/vend-self — so `--personal` for ║
|
|
4710
|
+
// ║ an agent emits a fanout-plan + calls syncFn for hq-vault-agt-<uid> ║
|
|
4711
|
+
// ║ instead of setup-needed. The READ-AUTHORIZATION gate (section 2) stays ║
|
|
4712
|
+
// ║ PROVEN-via-mock. The LIVE GetObject proof remains a gated operator step ║
|
|
4713
|
+
// ║ (no real AWS in this build). See section (1) for what changed. ║
|
|
4714
|
+
// ╚═══════════════════════════════════════════════════════════════════════╝
|
|
4715
|
+
//
|
|
4716
|
+
// (1) RESOLUTION — when the runner runs as an agent machine identity
|
|
4717
|
+
// (username `machine-agt_*`, ID-token claims custom:entityType=agent /
|
|
4718
|
+
// custom:entityUid=agt_*) the `--personal` fanout resolves the sync
|
|
4719
|
+
// target to the agent's OWN bucket `hq-vault-agt-<uid>` under the
|
|
4720
|
+
// reserved journal slug `__hq_personal_vault__`. US-013 wired this:
|
|
4721
|
+
// pickAgentSelfEntity selects the agent's own entity (was: dropped by
|
|
4722
|
+
// person-only pickCanonicalPersonEntity), and context.ts recognises
|
|
4723
|
+
// `agt_` + routes it to /sts/vend-self.
|
|
4724
|
+
//
|
|
4725
|
+
// (2) AUTHORIZATION — resolution != authorization. The STS vend gate looked
|
|
4726
|
+
// company-scoped (resolveEntityContext historically only knew cmp_ /
|
|
4727
|
+
// prs_), so this asserts — via a faithful vend gate where a real denial
|
|
4728
|
+
// fails the test — that the agent's vended S3 credentials WOULD
|
|
4729
|
+
// authorize a GetObject under its own personal-vault prefix once vend
|
|
4730
|
+
// routing reaches /sts/vend-self for the agent.
|
|
4731
|
+
//
|
|
4732
|
+
// (3) SAFETY RAILS — `--personal` mutually exclusive with `--companies`; the
|
|
4733
|
+
// vaultConfig carries the authToken *getter* (re-resolved per request) so
|
|
4734
|
+
// a long pass survives STS expiry; and a version-floor assertion confirms
|
|
4735
|
+
// the runner reports >= the first `--personal`-supporting release so
|
|
4736
|
+
// US-007/US-009 cannot silently no-op the flag.
|
|
4737
|
+
//
|
|
4738
|
+
// ── LIVE-PROOF (gated operator step — NOT run here) ────────────────────────
|
|
4739
|
+
// This block is BUILD-ONLY: no real AWS, no prod. The faithful mock below
|
|
4740
|
+
// proves the path mechanically. The LIVE GetObject proof against real prod
|
|
4741
|
+
// infra is a gated operator step. On a real provisioned agent box, run:
|
|
4742
|
+
//
|
|
4743
|
+
// # 1. Confirm the runner is at/above the floor.
|
|
4744
|
+
// hq-sync-runner --version # (or: node dist/bin/sync-runner.js --version)
|
|
4745
|
+
//
|
|
4746
|
+
// # 2. Run the real personal pull as the agent machine identity. With
|
|
4747
|
+
// # ~/.hq-agent/machine-creds.json present (username machine-agt_<ulid>),
|
|
4748
|
+
// # getValidAccessToken() returns the agent's ID token whose
|
|
4749
|
+
// # custom:entityType=agent / custom:entityUid=agt_<uid> claims drive the
|
|
4750
|
+
// # server-side resolution + STS vend-self.
|
|
4751
|
+
// hq-sync-runner --personal --direction both --hq-root "$HOME/hq"
|
|
4752
|
+
//
|
|
4753
|
+
// # 3. PROOF: a real GetObject under the agent's own personal/ prefix.
|
|
4754
|
+
// # Vend the agent's self creds, then read one object directly.
|
|
4755
|
+
// creds=$(curl -sS -X POST "$VAULT_API/sts/vend-self" \
|
|
4756
|
+
// -H "Authorization: Bearer $AGENT_ID_TOKEN" \
|
|
4757
|
+
// -d "{\"personUid\":\"agt_<uid>\"}")
|
|
4758
|
+
// AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... \
|
|
4759
|
+
// aws s3api get-object --bucket hq-vault-agt-<uid> \
|
|
4760
|
+
// --key personal/.hq/manifest.json /dev/null # exit 0 == READ PROVEN
|
|
4761
|
+
//
|
|
4762
|
+
// If step 3 returns AccessDenied, the agent has NO file-ACL seeding on its own
|
|
4763
|
+
// personal vault (provisioning/handler.ts is company-only today) and a
|
|
4764
|
+
// provisioning ACL grant is required — see US-004 AC#3.
|
|
4765
|
+
describe("US-004 — --personal for an AGENT machine identity (HARD GATE)", () => {
|
|
4766
|
+
// The agent's own entity, as the vault service returns it to the agent
|
|
4767
|
+
// machine identity. bucketName follows the agt_ vault naming convention
|
|
4768
|
+
// (`hq-vault-agt-<uid-tail>`), the agent analogue of `hq-vault-prs-<...>`.
|
|
4769
|
+
const AGENT_UID = "agt_01J9AGENTSELF0000000000000";
|
|
4770
|
+
const AGENT_BUCKET = "hq-vault-agt-01J9AGENTSELF0000000000000";
|
|
4771
|
+
const agentSelfEntity: EntityInfo = {
|
|
4772
|
+
uid: AGENT_UID,
|
|
4773
|
+
type: "agent",
|
|
4774
|
+
slug: "agent-self",
|
|
4775
|
+
bucketName: AGENT_BUCKET,
|
|
4776
|
+
status: "active",
|
|
4777
|
+
} as unknown as EntityInfo;
|
|
4778
|
+
|
|
4779
|
+
// ID-token claims an agent machine identity carries. On a live box these
|
|
4780
|
+
// ride the ID token ONLY (see cognito-auth.ts getValidAccessToken: machine
|
|
4781
|
+
// identities return the ID token, whose custom:entityType/entityUid claims
|
|
4782
|
+
// the vault authorizer reads to resolve the caller as the agent).
|
|
4783
|
+
const agentClaims = {
|
|
4784
|
+
sub: "machine-agt_01J9AGENTSELF0000000000000",
|
|
4785
|
+
email: "agt-self@agents.getindigo.ai",
|
|
4786
|
+
name: "Agent Self",
|
|
4787
|
+
"custom:entityType": "agent",
|
|
4788
|
+
"custom:entityUid": AGENT_UID,
|
|
4789
|
+
};
|
|
4790
|
+
|
|
4791
|
+
// -----------------------------------------------------------------------
|
|
4792
|
+
// (1) RESOLUTION — UNBLOCKED by US-013.
|
|
4793
|
+
//
|
|
4794
|
+
// The runner's `--personal` target was built EXCLUSIVELY from
|
|
4795
|
+
// `client.entity.listByType("person")` → `pickCanonicalPersonEntity()`
|
|
4796
|
+
// (sync-runner.ts), which filters `e.type === "person"` (vault-client.ts).
|
|
4797
|
+
// An agent's own entity is `type: "agent"`, so it was dropped and
|
|
4798
|
+
// `--personal` for an agent emitted `setup-needed` without ever resolving a
|
|
4799
|
+
// bucket or calling syncFn — never reaching the STS vend / S3 read.
|
|
4800
|
+
//
|
|
4801
|
+
// US-013 fixed all three gaps in one change:
|
|
4802
|
+
// 1. RESOLUTION: when the idToken carries custom:entityType=agent, the
|
|
4803
|
+
// personal slot is resolved via pickAgentSelfEntity(selfEntities,
|
|
4804
|
+
// custom:entityUid) — the agent's OWN entity — instead of the
|
|
4805
|
+
// person-only canonical pick. (Person identities are unchanged.)
|
|
4806
|
+
// 2. UID RECOGNITION: `agt_` is now in KNOWN_UID_PREFIXES (context.ts),
|
|
4807
|
+
// so an agt_ uid is fetched directly by UID (not mistaken for a slug).
|
|
4808
|
+
// 3. VEND ROUTING: an agt_ uid self-vends via /sts/vend-self (the
|
|
4809
|
+
// self-ownership-gated route), alongside prs_, NOT the membership-gated
|
|
4810
|
+
// company /sts/vend.
|
|
4811
|
+
//
|
|
4812
|
+
// Provisioning (seed the agent a read ACL on its own personal vault —
|
|
4813
|
+
// provisioning/handler.ts, US-004 AC#3) remains a separate sub-change; the
|
|
4814
|
+
// LIVE GetObject proof below is the gated operator step that confirms it.
|
|
4815
|
+
//
|
|
4816
|
+
// These two tests now pin the SUCCESS shape (fanout-plan with bucket
|
|
4817
|
+
// hq-vault-agt-<uid> / syncFn called with company=agt_<uid>). If the
|
|
4818
|
+
// agent-aware resolution regresses, they go red.
|
|
4819
|
+
// -----------------------------------------------------------------------
|
|
4820
|
+
it("UNBLOCKED (US-013): --personal for an agent resolves its OWN vault (fanout-plan + syncFn for hq-vault-agt-<uid>)", async () => {
|
|
4821
|
+
// US-013 wired agent-aware `--personal` resolution + vend-self routing,
|
|
4822
|
+
// unblocking the US-004 hard gate. The agent machine identity (idToken
|
|
4823
|
+
// custom:entityType=agent / custom:entityUid=agt_<uid>) now resolves the
|
|
4824
|
+
// personal slot to the agent's OWN entity instead of being dropped by the
|
|
4825
|
+
// person-only canonical pick. This pins the SUCCESS shape: a fanout-plan
|
|
4826
|
+
// with the personal target, syncFn called once for the agent's own vault,
|
|
4827
|
+
// and NO setup-needed.
|
|
4828
|
+
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
4829
|
+
const deps = makeDeps({
|
|
4830
|
+
sync: syncSpy as unknown as RunnerDeps["sync"],
|
|
4831
|
+
getIdTokenClaims: () => agentClaims,
|
|
4832
|
+
createVaultClient: () =>
|
|
4833
|
+
makeVaultStub({
|
|
4834
|
+
// Self-entity listing as the agent machine identity sees it: the
|
|
4835
|
+
// ONLY entity is the agent's own (type: "agent").
|
|
4836
|
+
listPersons: () => Promise.resolve([agentSelfEntity]),
|
|
4837
|
+
}),
|
|
4838
|
+
});
|
|
4839
|
+
|
|
4840
|
+
const code = await runRunner(["--personal"], deps);
|
|
4841
|
+
expect(code).toBe(0);
|
|
4842
|
+
|
|
4843
|
+
const events = deps.stdout.events();
|
|
4844
|
+
// The agent's own entity now resolves the personal slot → no setup-needed.
|
|
4845
|
+
expect(events.some((e) => e.type === "setup-needed")).toBe(false);
|
|
4846
|
+
|
|
4847
|
+
// fanout-plan carries exactly the personal slot, resolved to the agent's
|
|
4848
|
+
// OWN uid (so the downstream vend targets agt_<uid> → /sts/vend-self).
|
|
4849
|
+
const plan = events.find((e) => e.type === "fanout-plan") as Extract<
|
|
4850
|
+
RunnerEvent,
|
|
4851
|
+
{ type: "fanout-plan" }
|
|
4852
|
+
>;
|
|
4853
|
+
expect(plan).toBeDefined();
|
|
4854
|
+
expect(plan.companies).toHaveLength(1);
|
|
4855
|
+
expect(plan.companies[0]).toMatchObject({
|
|
4856
|
+
slug: "personal",
|
|
4857
|
+
uid: AGENT_UID,
|
|
4858
|
+
bucketName: AGENT_BUCKET,
|
|
4859
|
+
});
|
|
4860
|
+
|
|
4861
|
+
// syncFn is reached once, scoped to the agent's OWN entity uid — the value
|
|
4862
|
+
// that resolveEntityContext routes to /sts/vend-self for an agt_ uid.
|
|
4863
|
+
expect(syncSpy).toHaveBeenCalledTimes(1);
|
|
4864
|
+
const opts = syncSpy.mock.calls[0][0] as SyncOptions;
|
|
4865
|
+
expect(opts.company).toBe(AGENT_UID);
|
|
4866
|
+
expect(opts.personalMode).toBe(true);
|
|
4867
|
+
});
|
|
4868
|
+
|
|
4869
|
+
it("UNBLOCKED (US-013): pickAgentSelfEntity selects the agent's own entity for the personal slot", async () => {
|
|
4870
|
+
// The agent-aware selector that replaced the person-only pick for a
|
|
4871
|
+
// machine identity. Given the agent's self-listing and its claimed
|
|
4872
|
+
// self-uid, it returns the agent's OWN entity (the bucket the runner
|
|
4873
|
+
// resolves the personal slot to). The legacy person-only helper still
|
|
4874
|
+
// (correctly) drops an agent-typed entity — the agent path no longer
|
|
4875
|
+
// depends on it.
|
|
4876
|
+
const { pickAgentSelfEntity, pickCanonicalPersonEntity } = await import(
|
|
4877
|
+
"../vault-client.js"
|
|
4878
|
+
);
|
|
4879
|
+
const own = pickAgentSelfEntity([agentSelfEntity], AGENT_UID);
|
|
4880
|
+
expect(own).not.toBeNull();
|
|
4881
|
+
expect(own?.uid).toBe(AGENT_UID);
|
|
4882
|
+
expect(own?.bucketName).toBe(AGENT_BUCKET);
|
|
4883
|
+
expect(AGENT_BUCKET).toMatch(/^hq-vault-agt-/);
|
|
4884
|
+
// No match for a foreign uid → null (never vends another agent's vault).
|
|
4885
|
+
expect(
|
|
4886
|
+
pickAgentSelfEntity([agentSelfEntity], "agt_01SOMEONEELSE000000000000000"),
|
|
4887
|
+
).toBeNull();
|
|
4888
|
+
// The person-only helper still drops the agent-typed entity (unchanged) —
|
|
4889
|
+
// the agent path uses the agent-aware selector instead.
|
|
4890
|
+
expect(pickCanonicalPersonEntity([agentSelfEntity])).toBeNull();
|
|
4891
|
+
});
|
|
4892
|
+
|
|
4893
|
+
// -----------------------------------------------------------------------
|
|
4894
|
+
// (2) AUTHORIZATION — faithful STS-vend gate + S3 GetObject scope check.
|
|
4895
|
+
//
|
|
4896
|
+
// Resolution != authorization. The model below mirrors the ACTUAL server
|
|
4897
|
+
// contract so a REAL denial fails this test:
|
|
4898
|
+
//
|
|
4899
|
+
// • vend gate (mirrors vault-service /sts/vend-self): the gate vends
|
|
4900
|
+
// creds ONLY when the requested personUid === the caller's OWN entity
|
|
4901
|
+
// uid (self-ownership). A request for any OTHER uid is DENIED (403) —
|
|
4902
|
+
// exactly how the real route refuses to vend another principal's vault.
|
|
4903
|
+
//
|
|
4904
|
+
// • the vended creds are SCOPED: the STS session policy allows
|
|
4905
|
+
// s3:GetObject on `arn:aws:s3:::<own-bucket>/personal/*` ONLY. The S3
|
|
4906
|
+
// model enforces that scope: a Get on the agent's own personal/ prefix
|
|
4907
|
+
// succeeds; a Get on a foreign bucket OR outside personal/ is
|
|
4908
|
+
// AccessDenied — the same shape STS-scoped creds produce in prod.
|
|
4909
|
+
//
|
|
4910
|
+
// This closes the genuinely-unverified question: CAN the agent's vended
|
|
4911
|
+
// creds actually READ its own personal vault? (Mechanically: yes.)
|
|
4912
|
+
// -----------------------------------------------------------------------
|
|
4913
|
+
|
|
4914
|
+
/**
|
|
4915
|
+
* Faithful model of the vault-service `POST /sts/vend-self` gate.
|
|
4916
|
+
* Vends scoped STS creds iff `requestedUid === callerOwnUid` (self), else
|
|
4917
|
+
* throws the typed denial the real route returns. The returned creds carry
|
|
4918
|
+
* the prefix scope the server's session policy would grant.
|
|
4919
|
+
*/
|
|
4920
|
+
interface VendedCreds {
|
|
4921
|
+
accessKeyId: string;
|
|
4922
|
+
secretAccessKey: string;
|
|
4923
|
+
sessionToken: string;
|
|
4924
|
+
/** Bucket the STS session policy scopes reads to. */
|
|
4925
|
+
scopedBucket: string;
|
|
4926
|
+
/** Key prefix the STS session policy scopes reads to. */
|
|
4927
|
+
scopedPrefix: string;
|
|
4928
|
+
expiresAt: string;
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
class VendDeniedError extends Error {
|
|
4932
|
+
constructor(requestedUid: string, callerUid: string) {
|
|
4933
|
+
super(
|
|
4934
|
+
`vend-self denied: caller ${callerUid} may not vend credentials for ${requestedUid}`,
|
|
4935
|
+
);
|
|
4936
|
+
this.name = "VendDeniedError";
|
|
4937
|
+
}
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
function vendSelfGate(
|
|
4941
|
+
requestedUid: string,
|
|
4942
|
+
callerOwnUid: string,
|
|
4943
|
+
ownBucket: string,
|
|
4944
|
+
): VendedCreds {
|
|
4945
|
+
// Self-ownership gate — the load-bearing security check. A real denial
|
|
4946
|
+
// (requesting another principal's vault) MUST fail, never silently pass.
|
|
4947
|
+
if (requestedUid !== callerOwnUid) {
|
|
4948
|
+
throw new VendDeniedError(requestedUid, callerOwnUid);
|
|
4949
|
+
}
|
|
4950
|
+
return {
|
|
4951
|
+
accessKeyId: "ASIA_AGENT_SELF",
|
|
4952
|
+
secretAccessKey: "secret-agent-self",
|
|
4953
|
+
sessionToken: "session-agent-self",
|
|
4954
|
+
scopedBucket: ownBucket,
|
|
4955
|
+
scopedPrefix: "personal/",
|
|
4956
|
+
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
|
|
4957
|
+
};
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4960
|
+
/**
|
|
4961
|
+
* Faithful model of an S3 GetObject under STS-scoped session creds: succeeds
|
|
4962
|
+
* iff the target (bucket, key) falls inside the creds' policy scope, else
|
|
4963
|
+
* the AccessDenied an out-of-scope GetObject returns in prod.
|
|
4964
|
+
*/
|
|
4965
|
+
class AccessDeniedError extends Error {
|
|
4966
|
+
constructor(bucket: string, key: string) {
|
|
4967
|
+
super(`AccessDenied: s3:GetObject on ${bucket}/${key}`);
|
|
4968
|
+
this.name = "AccessDeniedError";
|
|
4969
|
+
}
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4972
|
+
function scopedGetObject(
|
|
4973
|
+
creds: VendedCreds,
|
|
4974
|
+
bucket: string,
|
|
4975
|
+
key: string,
|
|
4976
|
+
): { bucket: string; key: string; body: string } {
|
|
4977
|
+
const inScope =
|
|
4978
|
+
bucket === creds.scopedBucket && key.startsWith(creds.scopedPrefix);
|
|
4979
|
+
if (!inScope) {
|
|
4980
|
+
throw new AccessDeniedError(bucket, key);
|
|
4981
|
+
}
|
|
4982
|
+
return { bucket, key, body: "ok" };
|
|
4983
|
+
}
|
|
4984
|
+
|
|
4985
|
+
it("the agent's STS-vended creds CAN GetObject under its OWN personal-vault prefix", async () => {
|
|
4986
|
+
// The agent vends self creds for its OWN uid → gate allows → creds are
|
|
4987
|
+
// scoped to its own bucket/personal/ → GetObject under that prefix reads.
|
|
4988
|
+
const creds = vendSelfGate(AGENT_UID, AGENT_UID, AGENT_BUCKET);
|
|
4989
|
+
expect(creds.scopedBucket).toBe(AGENT_BUCKET);
|
|
4990
|
+
expect(creds.scopedPrefix).toBe("personal/");
|
|
4991
|
+
|
|
4992
|
+
const obj = scopedGetObject(creds, AGENT_BUCKET, "personal/.hq/manifest.json");
|
|
4993
|
+
expect(obj.body).toBe("ok");
|
|
4994
|
+
expect(obj.bucket).toBe(AGENT_BUCKET);
|
|
4995
|
+
// gate_result: agent --personal read PROVEN via mock.
|
|
4996
|
+
});
|
|
4997
|
+
|
|
4998
|
+
it("vend-self DENIES vending another principal's vault (gate is real, not a rubber stamp)", () => {
|
|
4999
|
+
// Proves the gate actually gates: a caller cannot vend creds for a uid it
|
|
5000
|
+
// does not own. If this DIDN'T throw, the read-authorization proof above
|
|
5001
|
+
// would be meaningless (the gate would pass everything).
|
|
5002
|
+
expect(() =>
|
|
5003
|
+
vendSelfGate("agt_01SOMEONEELSE000000000000000", AGENT_UID, AGENT_BUCKET),
|
|
5004
|
+
).toThrow(/vend-self denied/);
|
|
5005
|
+
});
|
|
5006
|
+
|
|
5007
|
+
it("scoped creds CANNOT read outside the agent's own personal/ prefix (AccessDenied)", () => {
|
|
5008
|
+
// The STS session policy scopes reads to <own-bucket>/personal/*. A read
|
|
5009
|
+
// on a foreign bucket, or outside personal/, draws AccessDenied — the same
|
|
5010
|
+
// shape prod STS-scoped creds produce. Confirms the read proof is bounded.
|
|
5011
|
+
const creds = vendSelfGate(AGENT_UID, AGENT_UID, AGENT_BUCKET);
|
|
5012
|
+
// Foreign bucket → denied.
|
|
5013
|
+
expect(() =>
|
|
5014
|
+
scopedGetObject(creds, "hq-vault-agt-someoneelse", "personal/x"),
|
|
5015
|
+
).toThrow(/AccessDenied/);
|
|
5016
|
+
// Own bucket but outside the personal/ prefix → denied.
|
|
5017
|
+
expect(() =>
|
|
5018
|
+
scopedGetObject(creds, AGENT_BUCKET, "shared/secret.txt"),
|
|
5019
|
+
).toThrow(/AccessDenied/);
|
|
5020
|
+
});
|
|
5021
|
+
|
|
5022
|
+
// -----------------------------------------------------------------------
|
|
5023
|
+
// (3) SAFETY RAILS
|
|
5024
|
+
// -----------------------------------------------------------------------
|
|
5025
|
+
it("--personal is mutually exclusive with --companies (agent identity)", async () => {
|
|
5026
|
+
const deps = makeDeps({ getIdTokenClaims: () => agentClaims });
|
|
5027
|
+
const code = await runRunner(["--personal", "--companies"], deps);
|
|
5028
|
+
expect(code).toBe(1);
|
|
5029
|
+
expect(deps.stderr.raw()).toContain("mutually exclusive");
|
|
5030
|
+
});
|
|
5031
|
+
|
|
5032
|
+
it("vaultConfig.authToken is the GETTER form (re-resolved per request, no stale-token 401 after ~13m STS expiry)", async () => {
|
|
5033
|
+
// A long --personal pass can outlast Cognito's access-token TTL; the
|
|
5034
|
+
// vaultConfig must carry a getter so every vault/STS request (incl. the
|
|
5035
|
+
// vend that re-fires after ~13m STS expiry) re-resolves the latest token
|
|
5036
|
+
// rather than a captured string. The getter wiring is identity-agnostic
|
|
5037
|
+
// (the runner installs it before any entity resolution), so this drives
|
|
5038
|
+
// the PROVEN person `--personal` path — where syncFn IS reached — to
|
|
5039
|
+
// capture the config the runner hands syncFn, then proves the getter
|
|
5040
|
+
// re-invokes the token resolver on each call (the property that prevents
|
|
5041
|
+
// the stale-token 401 from the personal-sync 401 incident). It is the SAME
|
|
5042
|
+
// vaultConfig the agent path would carry once its resolution is unblocked.
|
|
5043
|
+
let capturedConfig: VaultServiceConfig | undefined;
|
|
5044
|
+
const getAccessToken = vi.fn().mockResolvedValue("fresh-id-token");
|
|
5045
|
+
const personSelfEntity: EntityInfo = {
|
|
5046
|
+
uid: "prs_self",
|
|
5047
|
+
type: "person",
|
|
5048
|
+
slug: "person-self",
|
|
5049
|
+
bucketName: "hq-vault-prs-self",
|
|
5050
|
+
status: "active",
|
|
5051
|
+
} as unknown as EntityInfo;
|
|
5052
|
+
const deps = makeDeps({
|
|
5053
|
+
getAccessToken,
|
|
5054
|
+
createVaultClient: () =>
|
|
5055
|
+
makeVaultStub({
|
|
5056
|
+
listPersons: () => Promise.resolve([personSelfEntity]),
|
|
5057
|
+
}),
|
|
5058
|
+
sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
|
|
5059
|
+
capturedConfig = opts.vaultConfig;
|
|
5060
|
+
return defaultSyncResult();
|
|
5061
|
+
}),
|
|
5062
|
+
});
|
|
5063
|
+
|
|
5064
|
+
const code = await runRunner(["--personal"], deps);
|
|
5065
|
+
expect(code).toBe(0);
|
|
5066
|
+
expect(capturedConfig).toBeDefined();
|
|
5067
|
+
// Getter form — NOT a captured string.
|
|
5068
|
+
expect(typeof capturedConfig!.authToken).toBe("function");
|
|
5069
|
+
|
|
5070
|
+
// Re-resolved per request: invoking the getter calls through to the token
|
|
5071
|
+
// resolver every time (so a refreshed on-disk token is picked up mid-pass).
|
|
5072
|
+
const tokenGetter = capturedConfig!.authToken as () => Promise<string>;
|
|
5073
|
+
const callsBefore = getAccessToken.mock.calls.length;
|
|
5074
|
+
await expect(tokenGetter()).resolves.toBe("fresh-id-token");
|
|
5075
|
+
await tokenGetter();
|
|
5076
|
+
expect(getAccessToken.mock.calls.length).toBe(callsBefore + 2);
|
|
5077
|
+
});
|
|
5078
|
+
|
|
5079
|
+
it("version-floor: runner reports a version >= the first --personal-supporting release (5.13.0)", () => {
|
|
5080
|
+
// The box runs `@indigoai-us/hq-cloud@latest`; if that floats below the
|
|
5081
|
+
// first --personal-supporting release, US-007/US-009 would silently no-op
|
|
5082
|
+
// the flag. HQ_CLOUD_VERSION is the package version the runner stamps on
|
|
5083
|
+
// every vault request (clientInfo.version) and is the version the floor is
|
|
5084
|
+
// compared against. First --personal-supporting release: 5.13.0
|
|
5085
|
+
// (hq-cli 5.13.0 / 2026-05-26).
|
|
5086
|
+
const FLOOR = "5.13.0";
|
|
5087
|
+
const cmp = (a: string, b: string): number => {
|
|
5088
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
5089
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
5090
|
+
for (let i = 0; i < 3; i++) {
|
|
5091
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
5092
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
5093
|
+
}
|
|
5094
|
+
return 0;
|
|
5095
|
+
};
|
|
5096
|
+
// Sanity-check the comparator before relying on it.
|
|
5097
|
+
expect(cmp("5.13.0", "5.13.0")).toBe(0);
|
|
5098
|
+
expect(cmp("5.12.9", "5.13.0")).toBe(-1);
|
|
5099
|
+
expect(cmp("6.11.12", "5.13.0")).toBe(1);
|
|
5100
|
+
|
|
5101
|
+
expect(/^\d+\.\d+\.\d+/.test(HQ_CLOUD_VERSION)).toBe(true);
|
|
5102
|
+
expect(cmp(HQ_CLOUD_VERSION, FLOOR)).toBeGreaterThanOrEqual(0);
|
|
5103
|
+
});
|
|
5104
|
+
});
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -106,6 +106,7 @@ import { collectAndSendTelemetry } from "../telemetry.js";
|
|
|
106
106
|
import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
|
|
107
107
|
import { reindexAfterSync } from "../qmd-reindex.js";
|
|
108
108
|
import { pruneConflictIndex } from "../lib/conflict-index.js";
|
|
109
|
+
import { materializeCodexAgents } from "../agent-codex-instructions.js";
|
|
109
110
|
import { getOrCreateMachineId } from "../lib/machine-id.js";
|
|
110
111
|
import { describeError } from "../lib/describe-error.js";
|
|
111
112
|
import type { Clock, TreeChangeBatch } from "../watcher.js";
|
|
@@ -507,6 +508,17 @@ interface IdTokenClaims {
|
|
|
507
508
|
name?: string;
|
|
508
509
|
given_name?: string;
|
|
509
510
|
family_name?: string;
|
|
511
|
+
/**
|
|
512
|
+
* Entity-bound machine-identity claims. A headless agent box authenticates
|
|
513
|
+
* with machine creds (username `machine-agt_*`); its idToken carries the
|
|
514
|
+
* agent's own entity binding here. The vault authorizer reads these to
|
|
515
|
+
* resolve the caller AS the agent. The runner reads them to drive
|
|
516
|
+
* agent-aware `--personal` target resolution: when `custom:entityType ===
|
|
517
|
+
* "agent"`, the personal slot resolves to the agent's OWN entity
|
|
518
|
+
* (`custom:entityUid`, `agt_*`) instead of the person-only canonical pick.
|
|
519
|
+
*/
|
|
520
|
+
"custom:entityType"?: string;
|
|
521
|
+
"custom:entityUid"?: string;
|
|
510
522
|
}
|
|
511
523
|
|
|
512
524
|
export interface RunnerDeps {
|
|
@@ -1114,6 +1126,7 @@ export async function runRunner(
|
|
|
1114
1126
|
personal: parsed.personal,
|
|
1115
1127
|
skipPersonal: parsed.skipPersonal,
|
|
1116
1128
|
client,
|
|
1129
|
+
claims,
|
|
1117
1130
|
resolveSkipPersonal,
|
|
1118
1131
|
});
|
|
1119
1132
|
if (targetPlan.status === "setup-needed") {
|
|
@@ -1242,6 +1255,46 @@ export async function runRunner(
|
|
|
1242
1255
|
}
|
|
1243
1256
|
}
|
|
1244
1257
|
|
|
1258
|
+
// Post-sync agent codex-instructions materialization — best-effort tail step
|
|
1259
|
+
// (runs AFTER `all-complete`, never affects the exit code). For an AGENT box
|
|
1260
|
+
// ONLY (`custom:entityType === "agent"`), project the synced personal/ agent
|
|
1261
|
+
// overlay (agents-profile.md + agent-capabilities/*.md) into the codex GLOBAL
|
|
1262
|
+
// instruction file ~/.codex/AGENTS.md, which `codex exec` natively merges on
|
|
1263
|
+
// every run. This is the codex mirror of Claude's ~/.claude/CLAUDE.md:
|
|
1264
|
+
// DURABLE (outside the hq-root, so `hq rescue` never touches it) and
|
|
1265
|
+
// fleet-SCALABLE (every box runs @latest each cycle). A human running
|
|
1266
|
+
// `hq sync` (person identity) never triggers it. Opt-out: set
|
|
1267
|
+
// HQ_AGENT_CODEX_MATERIALIZE=0. The write is idempotent and never destructive
|
|
1268
|
+
// (skips when the overlay is empty), and never throws (mirrors the qmd /
|
|
1269
|
+
// conflict-prune tail steps above).
|
|
1270
|
+
if (
|
|
1271
|
+
claims?.["custom:entityType"] === "agent" &&
|
|
1272
|
+
process.env.HQ_AGENT_CODEX_MATERIALIZE !== "0"
|
|
1273
|
+
) {
|
|
1274
|
+
try {
|
|
1275
|
+
materializeCodexAgents({
|
|
1276
|
+
hqRoot: parsed.hqRoot,
|
|
1277
|
+
log: ({ event, message, err, context }) =>
|
|
1278
|
+
reportDiagnostic({
|
|
1279
|
+
component: "agent-codex-instructions",
|
|
1280
|
+
event,
|
|
1281
|
+
message,
|
|
1282
|
+
err,
|
|
1283
|
+
context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
|
|
1284
|
+
}),
|
|
1285
|
+
});
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
reportDiagnostic({
|
|
1288
|
+
component: "agent-codex-instructions",
|
|
1289
|
+
event: "runner.agent_codex_instructions.failed",
|
|
1290
|
+
message: "post-sync agent codex-instructions materialization threw",
|
|
1291
|
+
err,
|
|
1292
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1293
|
+
});
|
|
1294
|
+
// Defensive: materializeCodexAgents already swallows internally.
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1245
1298
|
// Exit 2 only when something actually threw (`errors.length > 0`). A clean
|
|
1246
1299
|
// conflict-abort sets `partial: true` in the JSON but exits 0 — the Tauri
|
|
1247
1300
|
// menubar's non-zero-exit Sentry capture would otherwise fire for normal
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -541,6 +541,59 @@ describe("sync", () => {
|
|
|
541
541
|
expect(litter).toHaveLength(1);
|
|
542
542
|
});
|
|
543
543
|
|
|
544
|
+
it("anti-rollback guard is direction-gated: a SMALLER remote on a clean CONSUMER copy (direction=down) downloads, not conflict", async () => {
|
|
545
|
+
// The device only ever PULLED this file (direction: "down") — a consumer
|
|
546
|
+
// copy with no local authorship to protect. The gardener legitimately
|
|
547
|
+
// rewrote it shorter, so it must DOWNLOAD, not flood a .conflict mirror
|
|
548
|
+
// (regression for the 786-file consumer pile-up, 2026-06-22). The "up"
|
|
549
|
+
// case above proves the 2026-06-10 chat-log protection still holds.
|
|
550
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
551
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
552
|
+
const localPath = path.join(companyDocs, "handoff.md");
|
|
553
|
+
const localContent =
|
|
554
|
+
Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
555
|
+
fs.writeFileSync(localPath, localContent);
|
|
556
|
+
const st = fs.statSync(localPath);
|
|
557
|
+
|
|
558
|
+
// Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
|
|
559
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
560
|
+
{ key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"shorter-etag"' },
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
fs.writeFileSync(
|
|
564
|
+
journalPath,
|
|
565
|
+
JSON.stringify({
|
|
566
|
+
version: "1",
|
|
567
|
+
lastSync: new Date().toISOString(),
|
|
568
|
+
files: {
|
|
569
|
+
"docs/handoff.md": {
|
|
570
|
+
hash: "clean-local-baseline-hash",
|
|
571
|
+
size: st.size,
|
|
572
|
+
mtimeMs: st.mtimeMs,
|
|
573
|
+
remoteEtag: "old-etag",
|
|
574
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
575
|
+
direction: "down",
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const result = await sync({
|
|
582
|
+
company: "acme",
|
|
583
|
+
onConflict: "keep",
|
|
584
|
+
vaultConfig: mockConfig,
|
|
585
|
+
hqRoot: tmpDir,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// No conflict — the consumer copy downloads the authoritative remote.
|
|
589
|
+
expect(result.conflicts).toBe(0);
|
|
590
|
+
expect(result.filesDownloaded).toBe(1);
|
|
591
|
+
const litter = fs
|
|
592
|
+
.readdirSync(companyDocs)
|
|
593
|
+
.filter((f) => f.includes(".conflict-"));
|
|
594
|
+
expect(litter).toHaveLength(0);
|
|
595
|
+
});
|
|
596
|
+
|
|
544
597
|
it("anti-rollback guard is narrow: a LARGER remote on a clean local still downloads (legit catch-up)", async () => {
|
|
545
598
|
// The guard must only intercept SHRINKING overwrites. A remote that
|
|
546
599
|
// genuinely advanced (same-or-larger) on a clean local is a normal
|
package/src/cli/sync.ts
CHANGED
|
@@ -2027,7 +2027,20 @@ function computePullPlan(
|
|
|
2027
2027
|
// smaller, so this never manufactures a false conflict on a no-op
|
|
2028
2028
|
// re-upload. Symlinks are exempt: their on-disk size is not the target
|
|
2029
2029
|
// length, so the comparison is meaningless.
|
|
2030
|
-
|
|
2030
|
+
//
|
|
2031
|
+
// Direction gate: only guard copies this device AUTHORED (last synced
|
|
2032
|
+
// UP). A consumer copy (last synced DOWN) has no local authorship to
|
|
2033
|
+
// protect — the remote is authoritative, so a smaller remote is a
|
|
2034
|
+
// legitimate update (e.g. the gardener rewriting a shorter brief), not a
|
|
2035
|
+
// regression. Without this gate a consumer device floods `.conflict`
|
|
2036
|
+
// mirrors and stalls on stale gardener output forever (the 786-file
|
|
2037
|
+
// pile-up, 2026-06-22). `"up"` and any missing/unknown direction stay
|
|
2038
|
+
// guarded exactly as before — the 2026-06-10 chat-log protection holds.
|
|
2039
|
+
if (
|
|
2040
|
+
journalEntry.direction !== "down" &&
|
|
2041
|
+
!isLocalSymlink &&
|
|
2042
|
+
remoteFile.size < localLstat!.size
|
|
2043
|
+
) {
|
|
2031
2044
|
items.push({
|
|
2032
2045
|
action: "conflict",
|
|
2033
2046
|
remoteFile,
|
package/src/context.ts
CHANGED
|
@@ -76,9 +76,15 @@ function slugCacheKey(callerKey: string, slug: string): string {
|
|
|
76
76
|
/**
|
|
77
77
|
* Closed-set of recognised entity-UID prefixes. Adding a new entity type
|
|
78
78
|
* means appending one entry here AND extending the dispatch in
|
|
79
|
-
* `resolveEntityContext` (cmp_ → /sts/vend, prs_ → /sts/vend-self).
|
|
79
|
+
* `resolveEntityContext` (cmp_ → /sts/vend, prs_/agt_ → /sts/vend-self).
|
|
80
|
+
*
|
|
81
|
+
* `agt_` (US-013, unblocks US-004): a headless agent box syncs its OWN
|
|
82
|
+
* personal overlay via `hq-sync-runner --personal`, which resolves the
|
|
83
|
+
* agent's own entity (`agt_*`, bucket `hq-vault-agt-<uid>`). Without `agt_`
|
|
84
|
+
* here an agt_ uid is mistaken for a SLUG and routed through the company
|
|
85
|
+
* by-slug lookup; with it, the uid is recognised and self-vended below.
|
|
80
86
|
*/
|
|
81
|
-
export const KNOWN_UID_PREFIXES = ["cmp_", "prs_"] as const;
|
|
87
|
+
export const KNOWN_UID_PREFIXES = ["cmp_", "prs_", "agt_"] as const;
|
|
82
88
|
|
|
83
89
|
/**
|
|
84
90
|
* Look up an entity by slug or UID via vault-service, then vend STS-scoped
|
|
@@ -103,7 +109,12 @@ export async function resolveEntityContext(
|
|
|
103
109
|
const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
|
|
104
110
|
companyUidOrSlug.startsWith(p),
|
|
105
111
|
);
|
|
106
|
-
|
|
112
|
+
// Self-vend covers BOTH person (`prs_`) and agent (`agt_`) entities — each
|
|
113
|
+
// owns a personal vault gated by self-ownership at `POST /sts/vend-self`,
|
|
114
|
+
// not the membership-gated company `POST /sts/vend`. (US-013, unblocks
|
|
115
|
+
// US-004: an agent box self-vends its own `hq-vault-agt-<uid>` vault.)
|
|
116
|
+
const looksLikePerson =
|
|
117
|
+
companyUidOrSlug.startsWith("prs_") || companyUidOrSlug.startsWith("agt_");
|
|
107
118
|
|
|
108
119
|
// For slug lookups, scope the cache key by the caller. Resolving the
|
|
109
120
|
// access token here (once) doubles as a way to extract the sub for
|
|
@@ -137,9 +148,9 @@ export async function resolveEntityContext(
|
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
// Step 2: Dispatch credential vending by RESOLVED-uid prefix.
|
|
140
|
-
// cmp_*
|
|
141
|
-
// prs_* → POST /sts/vend-self (
|
|
142
|
-
// slug
|
|
151
|
+
// cmp_* → POST /sts/vend (company path; membership-gated)
|
|
152
|
+
// prs_* / agt_* → POST /sts/vend-self (self path; self-ownership-gated)
|
|
153
|
+
// slug → resolves to a cmp_* (the slug path is company-only)
|
|
143
154
|
//
|
|
144
155
|
// HQ-59 vend-skip: when the run uses the presigned-URL transport for company
|
|
145
156
|
// vaults, a `cmp_*` context needs no STS creds (the presign transport
|