@indigoai-us/hq-cli 5.240.0 → 5.241.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.241.0] — 2026-09-27
6
+
7
+ ### Fixed
8
+
9
+ - A bot whose only persistent storage is a network share can now enroll.
10
+ Enrollment wrote its credentials and then locked them to the owner, and on
11
+ a CIFS/SMB share mounted with a fixed file mode — Azure Files is the one
12
+ that found this — that last step fails outright. Enrollment died there,
13
+ left a temp file holding the private key behind, and offered no way
14
+ forward: the only writable alternative was storage a container rebuild
15
+ erases.
16
+
17
+ The write now reports whether owner-only permissions actually took, rather
18
+ than assuming they did, and `hq agent enroll` says plainly when they did
19
+ not: the credential is readable by anything else with access to that
20
+ share. The file is still requested owner-only, the result is read back
21
+ from the filesystem instead of trusted, a refused chmod on ordinary
22
+ storage still fails loudly, and a failed write no longer leaves the secret
23
+ behind in a temp file.
24
+ - Resume can keep a lane on its recorded account when it uses a CLI-selected
25
+ model, even if that account's configured default differs. A lane without an
26
+ account allowlist may use any otherwise eligible account.
27
+ - With a worker-profile account pin, resume keeps a healthy recorded account
28
+ that is allowed by the pin even when the pin includes other accounts and the
29
+ recorded account's default differs from the lane's CLI-selected model.
30
+
5
31
  ## [5.240.0] — 2026-09-27
6
32
 
7
33
  ### Added
@@ -129,6 +129,14 @@ export interface EnrollResult {
129
129
  companyMismatch?: string;
130
130
  /** True when this enrolled beside a person's session in its own directory. */
131
131
  local: boolean;
132
+ /**
133
+ * False when the filesystem could not make the credential owner-only — a
134
+ * share mounted with a fixed file_mode. The identity works; anything else
135
+ * with access to that share can read it.
136
+ */
137
+ permissionsEnforced: boolean;
138
+ /** errno of the refused chmod, when there was one (EPERM on Azure Files). */
139
+ permissionsError?: string;
132
140
  /** The tree this identity lives in. */
133
141
  agentDir: string;
134
142
  }
@@ -252,7 +252,7 @@ export async function enrollHost(opts, deps = {}) {
252
252
  // server can bind the code to it, and a failed call leaves a harmless
253
253
  // unenrolled key behind (regenerated on the next attempt).
254
254
  const pair = (deps.generateKeyPair ?? generateHostKeyPair)();
255
- writeHostKeyPair(paths, pair);
255
+ const keyWrite = writeHostKeyPair(paths, pair);
256
256
  let res;
257
257
  try {
258
258
  res = await doFetch(`${apiBaseUrl}${ENROLL_PATH}`, {
@@ -290,8 +290,12 @@ export async function enrollHost(opts, deps = {}) {
290
290
  companySlug: parsed.companySlug,
291
291
  apiBaseUrl,
292
292
  };
293
- writeMachineCreds(paths, creds);
293
+ const credsWrite = writeMachineCreds(paths, creds);
294
+ const enforced = keyWrite.enforced && credsWrite.enforced;
295
+ const permissionsError = keyWrite.chmodError ?? credsWrite.chmodError;
294
296
  return {
297
+ permissionsEnforced: enforced,
298
+ ...(permissionsError ? { permissionsError } : {}),
295
299
  local: target.local,
296
300
  agentDir: paths.agentDir,
297
301
  agentUid: parsed.agentUid,
@@ -305,6 +309,10 @@ export async function enrollHost(opts, deps = {}) {
305
309
  : {}),
306
310
  };
307
311
  }
312
+ /** Directory label for the permissions note — never the credential itself. */
313
+ function paths_agentDirLabel() {
314
+ return agentKitPaths(os.homedir(), process.env).agentDir;
315
+ }
308
316
  export function registerAgentEnrollCommand(agent) {
309
317
  agent
310
318
  .command("enroll <code>")
@@ -330,6 +338,15 @@ export function registerAgentEnrollCommand(agent) {
330
338
  ` directory beside them: ${result.agentDir}. Their session was not read or changed.\n` +
331
339
  ` Every hq agent command finds this tree on its own — no environment to set.`));
332
340
  }
341
+ if (!result.permissionsEnforced) {
342
+ // Not a warning to bury: the credential is on disk and working,
343
+ // but this filesystem cannot keep it to one owner.
344
+ console.log(chalk.yellow(` note: this filesystem could not make the credential owner-only` +
345
+ `${result.permissionsError ? ` (chmod: ${result.permissionsError})` : ""}.\n` +
346
+ ` Anything with access to ${paths_agentDirLabel()} can read it. That is\n` +
347
+ ` usually a network share mounted with a fixed file_mode. Keep the\n` +
348
+ ` share itself restricted, or move the identity to local storage.`));
349
+ }
333
350
  console.log(` host fingerprint: ${result.hostFingerprint}`);
334
351
  console.log(` credentials: ${result.credsPath} (0600)`);
335
352
  console.log(` host key: ${result.hostKeyPath} (0600)`);
@@ -5921,6 +5921,10 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
5921
5921
  : lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
5922
5922
  ? lane.model
5923
5923
  : undefined;
5924
+ const recordedModelAccount = lane.account !== undefined &&
5925
+ (lane.model_source === "cli" || lane.model_source === "preserved")
5926
+ ? lane.account
5927
+ : undefined;
5924
5928
  const preservedModel = repairUnavailableAccount && lane.model_source === "account"
5925
5929
  ? lane.model ?? currentConfig?.model
5926
5930
  : undefined;
@@ -5944,7 +5948,8 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
5944
5948
  currentConfig?.enabled === true &&
5945
5949
  currentTargetAccount?.enabled === true &&
5946
5950
  currentTargetAccount.logged_in === true &&
5947
- (model === undefined || currentConfig.model === undefined || currentConfig.model === model))
5951
+ (model === undefined || currentConfig.model === undefined || currentConfig.model === model ||
5952
+ lane.account === recordedModelAccount))
5948
5953
  return undefined;
5949
5954
  const selected = selectClaudeAccount({
5950
5955
  hosts: balanceHosts.snapshots,
@@ -5952,6 +5957,7 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
5952
5957
  config,
5953
5958
  ...(!repairUnavailableAccount && allowedAccounts !== undefined ? { allowedAccounts } : {}),
5954
5959
  ...(model !== undefined ? { model } : {}),
5960
+ ...(recordedModelAccount !== undefined ? { recordedModelAccount } : {}),
5955
5961
  unavailableHosts: balanceHosts.unavailableHosts,
5956
5962
  });
5957
5963
  const historyReason = lane.account === undefined
@@ -7,6 +7,22 @@
7
7
  * module reads `~/.hq/cognito-tokens.json` — a human's session is never
8
8
  * consulted, copied, or reused by an agent identity (policy
9
9
  * never-extract-stored-session-tokens-to-forge-privileged-calls).
10
+ *
11
+ * NOT EVERY FILESYSTEM CAN KEEP THAT PROMISE. A CIFS/SMB mount (Azure Files
12
+ * is the one that found this) is mounted with a fixed `file_mode` and fails
13
+ * `chmod` outright with EPERM; an NFS export or a Docker volume may accept
14
+ * the call and ignore it. A bot whose only persistent storage is such a
15
+ * share used to be unable to enroll at all — enrollment died on the chmod,
16
+ * leaving a stray temp file behind, and the only alternative was to keep the
17
+ * identity on disposable storage where a rebuild erases it.
18
+ *
19
+ * So the write no longer fails there: it reports whether owner-only
20
+ * permissions actually took, and the caller says so out loud. The file is
21
+ * still requested 0600 at creation, the outcome is read back from the
22
+ * filesystem rather than assumed, and a host that cannot honour it learns
23
+ * that its credential is readable by anything else with access to the same
24
+ * share. Refusing to enroll would not have made that credential safer; it
25
+ * would only have moved it somewhere it silently disappears.
10
26
  */
11
27
  import type { AgentKitPaths, KitComponent } from "./paths.js";
12
28
  /** Exact `machine-creds.json` shape for an external agent (api-contract v1). */
@@ -33,8 +49,20 @@ export interface HostKeyPair {
33
49
  /** Contract fingerprint: sha256(DER SPKI public key) hex, first 16 chars. */
34
50
  export declare function hostFingerprint(publicPem: string): string;
35
51
  export declare function generateHostKeyPair(): HostKeyPair;
36
- export declare function writeHostKeyPair(paths: Pick<AgentKitPaths, "hostKeyPath" | "hostKeyPubPath">, pair: HostKeyPair): void;
37
- export declare function writeMachineCreds(paths: Pick<AgentKitPaths, "machineCredsPath">, creds: ExternalMachineCreds): void;
52
+ /**
53
+ * What a write could actually guarantee about who can read the file.
54
+ * `enforced: false` means the bytes are on disk but readable beyond the
55
+ * owner — true of a share mounted with a fixed `file_mode`.
56
+ */
57
+ export interface PrivateFileOutcome {
58
+ enforced: boolean;
59
+ /** Mode observed after the write, when it could be read back. */
60
+ observedMode?: number;
61
+ /** errno of a refused chmod (EPERM on Azure Files), when there was one. */
62
+ chmodError?: string;
63
+ }
64
+ export declare function writeHostKeyPair(paths: Pick<AgentKitPaths, "hostKeyPath" | "hostKeyPubPath">, pair: HostKeyPair): PrivateFileOutcome;
65
+ export declare function writeMachineCreds(paths: Pick<AgentKitPaths, "machineCredsPath">, creds: ExternalMachineCreds): PrivateFileOutcome;
38
66
  /**
39
67
  * Read the creds file without trusting it: returns null unless it is an
40
68
  * external-agent file with every contract field present.
@@ -7,6 +7,22 @@
7
7
  * module reads `~/.hq/cognito-tokens.json` — a human's session is never
8
8
  * consulted, copied, or reused by an agent identity (policy
9
9
  * never-extract-stored-session-tokens-to-forge-privileged-calls).
10
+ *
11
+ * NOT EVERY FILESYSTEM CAN KEEP THAT PROMISE. A CIFS/SMB mount (Azure Files
12
+ * is the one that found this) is mounted with a fixed `file_mode` and fails
13
+ * `chmod` outright with EPERM; an NFS export or a Docker volume may accept
14
+ * the call and ignore it. A bot whose only persistent storage is such a
15
+ * share used to be unable to enroll at all — enrollment died on the chmod,
16
+ * leaving a stray temp file behind, and the only alternative was to keep the
17
+ * identity on disposable storage where a rebuild erases it.
18
+ *
19
+ * So the write no longer fails there: it reports whether owner-only
20
+ * permissions actually took, and the caller says so out loud. The file is
21
+ * still requested 0600 at creation, the outcome is read back from the
22
+ * filesystem rather than assumed, and a host that cannot honour it learns
23
+ * that its credential is readable by anything else with access to the same
24
+ * share. Refusing to enroll would not have made that credential safer; it
25
+ * would only have moved it somewhere it silently disappears.
10
26
  */
11
27
  import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto";
12
28
  import * as fs from "node:fs";
@@ -23,20 +39,73 @@ export function generateHostKeyPair() {
23
39
  const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
24
40
  return { privatePem, publicPem, fingerprint: hostFingerprint(publicPem) };
25
41
  }
42
+ /**
43
+ * chmod failures that mean "this filesystem does not do modes" rather than
44
+ * "something is wrong". Anything else still throws: a refused chmod on a
45
+ * local disk is a real fault and must not be swallowed.
46
+ */
47
+ const MODE_UNSUPPORTED_CODES = new Set([
48
+ "EPERM",
49
+ "EACCES",
50
+ "EINVAL",
51
+ "ENOTSUP",
52
+ "EOPNOTSUPP",
53
+ ]);
26
54
  /** Write a file atomically with the given mode, replacing any existing one. */
27
55
  function writePrivateFile(dest, contents, mode) {
28
56
  fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
29
- const tmp = `${dest}.tmp.${process.pid}`;
30
- fs.writeFileSync(tmp, contents, { mode });
31
- fs.chmodSync(tmp, mode);
32
- fs.renameSync(tmp, dest);
57
+ // Date.now() as well as the pid: two writes from one process must not
58
+ // collide on a temp name, and a leftover from a crashed run must not be
59
+ // adopted by the next one.
60
+ const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
61
+ let chmodError;
62
+ try {
63
+ fs.writeFileSync(tmp, contents, { mode });
64
+ try {
65
+ fs.chmodSync(tmp, mode);
66
+ }
67
+ catch (err) {
68
+ const code = err.code ?? "";
69
+ if (!MODE_UNSUPPORTED_CODES.has(code))
70
+ throw err;
71
+ chmodError = code;
72
+ }
73
+ // Read the truth back rather than trusting either call: a mount can
74
+ // accept chmod and ignore it just as easily as it can refuse.
75
+ let observedMode;
76
+ try {
77
+ observedMode = fs.statSync(tmp).mode & 0o777;
78
+ }
79
+ catch {
80
+ /* stat is not worth failing a write over */
81
+ }
82
+ fs.renameSync(tmp, dest);
83
+ return {
84
+ enforced: observedMode === undefined ? !chmodError : (observedMode & 0o077) === 0,
85
+ ...(observedMode === undefined ? {} : { observedMode }),
86
+ ...(chmodError ? { chmodError } : {}),
87
+ };
88
+ }
89
+ catch (err) {
90
+ // Never leave the temp file behind — it holds the same secret as the
91
+ // file that failed to land.
92
+ try {
93
+ fs.unlinkSync(tmp);
94
+ }
95
+ catch {
96
+ /* already gone */
97
+ }
98
+ throw err;
99
+ }
33
100
  }
34
101
  export function writeHostKeyPair(paths, pair) {
35
- writePrivateFile(paths.hostKeyPath, pair.privatePem, 0o600);
102
+ const key = writePrivateFile(paths.hostKeyPath, pair.privatePem, 0o600);
103
+ // The public half is public; its mode is not a promise worth reporting.
36
104
  writePrivateFile(paths.hostKeyPubPath, pair.publicPem, 0o644);
105
+ return key;
37
106
  }
38
107
  export function writeMachineCreds(paths, creds) {
39
- writePrivateFile(paths.machineCredsPath, `${JSON.stringify(creds, null, 2)}\n`, 0o600);
108
+ return writePrivateFile(paths.machineCredsPath, `${JSON.stringify(creds, null, 2)}\n`, 0o600);
40
109
  }
41
110
  /**
42
111
  * Read the creds file without trusting it: returns null unless it is an
@@ -70,6 +70,8 @@ export declare function selectClaudeAccount(input: {
70
70
  config: LanesConfig;
71
71
  allowedAccounts?: readonly string[];
72
72
  model?: string;
73
+ /** Keep the recorded account eligible for its already-running CLI model. */
74
+ recordedModelAccount?: string;
73
75
  explicitAccount?: string;
74
76
  unavailableHosts?: readonly BalanceHostGap[];
75
77
  }): ClaudeAccountSelection;
@@ -104,7 +104,8 @@ function buildLoads(input) {
104
104
  const hosts = input.hosts.map((host) => accountHostLoad(host, account));
105
105
  const targetLoad = hosts.find((host) => sameHost(host.host, targetHost));
106
106
  const configuredModel = configAccounts[account]?.model;
107
- const ineligible = targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad) ?? (input.model !== undefined && configuredModel !== undefined && configuredModel !== input.model
107
+ const ineligible = targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad) ?? (account !== input.recordedModelAccount &&
108
+ input.model !== undefined && configuredModel !== undefined && configuredModel !== input.model
108
109
  ? "model_mismatch"
109
110
  : undefined);
110
111
  if (ineligible !== undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.240.0",
3
+ "version": "5.241.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {