@indigoai-us/hq-cloud 6.14.48 → 6.14.49

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Child-job authentication against a read-only HQ state directory.
3
+ *
4
+ * WHY (client report + live reproduction 2026-08-10)
5
+ * --------------------------------------------------
6
+ * "The parent runner can authenticate and reach every provider, but the skill
7
+ * subprocesses cannot use that machine authentication. They fall back to
8
+ * refreshing a human HQ session, which then fails because the job's HQ state
9
+ * directory is read-only."
10
+ *
11
+ * Two independent defects produced that:
12
+ *
13
+ * 1. The state dir was hardcoded to `os.homedir()/.hq` with no override, while
14
+ * the CREDS path already had one (HQ_MACHINE_CREDS_FILE). So auth required
15
+ * a writable HOME even for a machine identity: with valid machine creds and
16
+ * a read-only `~/.hq`, `getValidMachineTokens` died on the lock candidate
17
+ * write with EACCES *before any network call*.
18
+ *
19
+ * 2. A machine context that cannot find its creds does not error —
20
+ * `isMachineIdentity()` returns false and the caller proceeds to the human
21
+ * path, which on a headless box tries to open a browser and hangs.
22
+ *
23
+ * These tests pin the fixes: HQ_STATE_DIR redirects state, writes degrade
24
+ * instead of throwing, and HQ_REQUIRE_MACHINE_IDENTITY makes the downgrade loud.
25
+ */
26
+
27
+ import * as fs from "fs";
28
+ import * as os from "os";
29
+ import * as path from "path";
30
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
31
+ import type { CognitoTokens } from "./cognito-auth.js";
32
+
33
+ let originalHome: string | undefined;
34
+ let originalStateDir: string | undefined;
35
+ let originalCredsEnv: string | undefined;
36
+ let originalRequireEnv: string | undefined;
37
+ let tmpHome: string;
38
+
39
+ beforeEach(() => {
40
+ originalHome = process.env.HOME;
41
+ originalStateDir = process.env.HQ_STATE_DIR;
42
+ originalCredsEnv = process.env.HQ_MACHINE_CREDS_FILE;
43
+ originalRequireEnv = process.env.HQ_REQUIRE_MACHINE_IDENTITY;
44
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-ro-state-test-"));
45
+ process.env.HOME = tmpHome;
46
+ delete process.env.HQ_STATE_DIR;
47
+ delete process.env.HQ_MACHINE_CREDS_FILE;
48
+ delete process.env.HQ_REQUIRE_MACHINE_IDENTITY;
49
+ vi.resetModules();
50
+ });
51
+
52
+ afterEach(() => {
53
+ const restore = (key: string, value: string | undefined): void => {
54
+ if (value === undefined) delete process.env[key];
55
+ else process.env[key] = value;
56
+ };
57
+ restore("HOME", originalHome);
58
+ restore("HQ_STATE_DIR", originalStateDir);
59
+ restore("HQ_MACHINE_CREDS_FILE", originalCredsEnv);
60
+ restore("HQ_REQUIRE_MACHINE_IDENTITY", originalRequireEnv);
61
+ try {
62
+ fs.chmodSync(path.join(tmpHome, ".hq"), 0o700);
63
+ } catch {
64
+ /* dir may not exist */
65
+ }
66
+ fs.rmSync(tmpHome, { recursive: true, force: true });
67
+ });
68
+
69
+ const TOKENS = {
70
+ accessToken: "access-token-value",
71
+ idToken: "id-token-value",
72
+ refreshToken: "",
73
+ expiresAt: Date.now() + 3_600_000,
74
+ tokenType: "Bearer",
75
+ } as const satisfies CognitoTokens;
76
+
77
+ describe("HQ_STATE_DIR override", () => {
78
+ it("defaults to ~/.hq when unset", async () => {
79
+ const mod = await import("./cognito-auth.js");
80
+ expect(mod.hqStateDir()).toBe(path.join(tmpHome, ".hq"));
81
+ expect(mod.tokenCacheFile()).toBe(
82
+ path.join(tmpHome, ".hq", "cognito-tokens.json"),
83
+ );
84
+ });
85
+
86
+ it("redirects state to a writable dir, so a read-only HOME can still cache", async () => {
87
+ const writable = fs.mkdtempSync(path.join(os.tmpdir(), "hq-writable-"));
88
+ process.env.HQ_STATE_DIR = writable;
89
+ const mod = await import("./cognito-auth.js");
90
+
91
+ expect(mod.hqStateDir()).toBe(writable);
92
+ mod.saveCachedTokens({ ...TOKENS });
93
+ // Landed in the override, NOT under HOME.
94
+ expect(fs.existsSync(path.join(writable, "cognito-tokens.json"))).toBe(true);
95
+ expect(fs.existsSync(path.join(tmpHome, ".hq", "cognito-tokens.json"))).toBe(
96
+ false,
97
+ );
98
+ expect(mod.loadCachedTokens()?.accessToken).toBe(TOKENS.accessToken);
99
+ fs.rmSync(writable, { recursive: true, force: true });
100
+ });
101
+
102
+ it("is read per call, so it can be set after the module loads", async () => {
103
+ const mod = await import("./cognito-auth.js");
104
+ const before = mod.hqStateDir();
105
+ process.env.HQ_STATE_DIR = "/tmp/hq-state-late-binding";
106
+ expect(mod.hqStateDir()).not.toBe(before);
107
+ expect(mod.hqStateDir()).toBe("/tmp/hq-state-late-binding");
108
+ });
109
+ });
110
+
111
+ describe("read-only state directory degrades instead of failing auth", () => {
112
+ it("saveCachedTokens does not throw when the state dir is not writable", async () => {
113
+ const stateDir = path.join(tmpHome, ".hq");
114
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
115
+ fs.chmodSync(stateDir, 0o500); // read-only
116
+ const mod = await import("./cognito-auth.js");
117
+
118
+ // Previously: EACCES on the tmp write, killing the caller's auth.
119
+ expect(() => mod.saveCachedTokens({ ...TOKENS })).not.toThrow();
120
+ // Nothing persisted...
121
+ expect(fs.existsSync(path.join(stateDir, "cognito-tokens.json"))).toBe(false);
122
+ // ...but the session this process just minted is not lost.
123
+ expect(mod.loadCachedTokens()?.accessToken).toBe(TOKENS.accessToken);
124
+ });
125
+
126
+ it("still propagates a genuine write fault (not a permission problem)", async () => {
127
+ // A FILE where the state dir should be is a real misconfiguration (ENOTDIR),
128
+ // not a read-only mount — it must not be silently swallowed.
129
+ const asFile = path.join(tmpHome, "state-as-file");
130
+ fs.writeFileSync(asFile, "not a directory");
131
+ process.env.HQ_STATE_DIR = path.join(asFile, "nested");
132
+ const mod = await import("./cognito-auth.js");
133
+ expect(() => mod.saveCachedTokens({ ...TOKENS })).toThrow();
134
+ });
135
+ });
136
+
137
+ describe("HQ_REQUIRE_MACHINE_IDENTITY makes the silent downgrade loud", () => {
138
+ it("is off by default — human fallback stays available", async () => {
139
+ const mod = await import("./cognito-auth.js");
140
+ expect(mod.machineIdentityRequired()).toBe(false);
141
+ expect(() => mod.assertMachineIdentityWhenRequired()).not.toThrow();
142
+ });
143
+
144
+ it("throws a diagnosable error naming the path checked when creds are missing", async () => {
145
+ process.env.HQ_REQUIRE_MACHINE_IDENTITY = "1";
146
+ const mod = await import("./cognito-auth.js");
147
+ expect(mod.isMachineIdentity()).toBe(false);
148
+ try {
149
+ mod.assertMachineIdentityWhenRequired();
150
+ throw new Error("expected assertMachineIdentityWhenRequired to throw");
151
+ } catch (err) {
152
+ const message = (err as Error).message;
153
+ // Names the exact path AND how it was derived — the missing diagnosis in
154
+ // the original "falling back to a human session" report.
155
+ expect(message).toContain(".hq-agent/machine-creds.json");
156
+ expect(message).toContain("os.homedir()");
157
+ expect(message).toContain("HQ_MACHINE_CREDS_FILE");
158
+ expect(message).not.toContain("browser");
159
+ }
160
+ });
161
+
162
+ it("names HQ_MACHINE_CREDS_FILE as the source when it is set but unusable", async () => {
163
+ process.env.HQ_REQUIRE_MACHINE_IDENTITY = "true";
164
+ process.env.HQ_MACHINE_CREDS_FILE = path.join(tmpHome, "nope.json");
165
+ const mod = await import("./cognito-auth.js");
166
+ expect(() => mod.assertMachineIdentityWhenRequired()).toThrow(
167
+ /path from HQ_MACHINE_CREDS_FILE/,
168
+ );
169
+ });
170
+
171
+ it("is a no-op once machine creds ARE resolvable", async () => {
172
+ const credsPath = path.join(tmpHome, "machine-creds.json");
173
+ fs.writeFileSync(
174
+ credsPath,
175
+ JSON.stringify({
176
+ username: "agt-test@agents.getindigo.ai",
177
+ secret: "secret-value",
178
+ entityType: "agent",
179
+ entityUid: "agt_01TESTTESTTESTTESTTESTTEST",
180
+ }),
181
+ );
182
+ process.env.HQ_REQUIRE_MACHINE_IDENTITY = "1";
183
+ process.env.HQ_MACHINE_CREDS_FILE = credsPath;
184
+ const mod = await import("./cognito-auth.js");
185
+ expect(mod.isMachineIdentity()).toBe(true);
186
+ expect(() => mod.assertMachineIdentityWhenRequired()).not.toThrow();
187
+ });
188
+ });