@indigoai-us/hq-cloud 6.11.14 → 6.11.16
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/ci.yml +1 -0
- 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-telemetry.d.ts +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -1
- package/dist/bin/sync-runner-telemetry.js +8 -1
- package/dist/bin/sync-runner-telemetry.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +20 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +154 -9
- 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/share.js +13 -9
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +24 -12
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +16 -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/qmd-reindex.d.ts +7 -0
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +12 -1
- package/dist/qmd-reindex.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 +3 -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-telemetry.ts +8 -1
- package/src/bin/sync-runner.test.ts +413 -0
- package/src/bin/sync-runner.ts +184 -7
- package/src/cli/share.ts +15 -9
- package/src/cli/sync.test.ts +17 -0
- package/src/cli/sync.ts +33 -12
- package/src/context.ts +17 -6
- package/src/qmd-reindex.ts +18 -1
- 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
|
+
});
|