@indigoai-us/hq-cli 5.153.0 → 5.155.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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `hq_attachment_fetch` — pull a chat attachment named on an inbox item down
3
+ * to a private local file the bot can open.
4
+ *
5
+ * Inbox items carry attachments as `{ vaultPath, companyUid, … }` where
6
+ * `vaultPath` is SCOPE-relative (`chat/attachments/{dm|chan}/<id>/<file>`) and
7
+ * `companyUid` is the vault scope the bytes live in: the company uid for a
8
+ * company channel, or the SENDER's person uid (`prs_…`) for a DM, because a DM
9
+ * attachment is stored in the sender's personal vault. Neither `hq_files_read`
10
+ * (which only accepts `companies/<slug>/…` and returns clamped text) nor
11
+ * anything else on the MCP surface could read those, so bots answered
12
+ * "I can't open the file".
13
+ *
14
+ * Bytes come through the same vault presign path `hq files cat` uses
15
+ * (`createCompanyPresignClient` → POST /v1/files/presign), minted with the
16
+ * bot's own machine token, so the server's per-key ACL stays the authority.
17
+ * The client-side checks here are the tenant boundary the bot can state
18
+ * before any request leaves the machine. They matter because an attachment
19
+ * ref is SENDER-controlled while the server authorizes against the BOT's own
20
+ * access: without them a sender could attach any key the bot can read (say
21
+ * `knowledge/restricted/x.csv` in the bot's company) and have the bot fetch
22
+ * it for them.
23
+ * - the key must be a plain relative path with no traversal, under
24
+ * `chat/attachments/`;
25
+ * - a DM key (`chat/attachments/dm/<a>--<b>/…`) must name this bot in the
26
+ * pair, whichever scope it is read from;
27
+ * - a `cmp_` scope must be the bot's own enrolled company;
28
+ * - a `prs_` scope is only a DM attachment between that person and this
29
+ * bot (the pair naming both).
30
+ *
31
+ * Files land under `<agentDir>/attachments/<scopeUid>/` (dir 0700, file 0600)
32
+ * with a sanitized name, so nothing from the message can pick the location.
33
+ */
34
+ import { createHash } from "node:crypto";
35
+ import * as fs from "node:fs";
36
+ import * as path from "node:path";
37
+ import { McpToolInputError } from "./jsonrpc.js";
38
+ /**
39
+ * Refuse anything larger than this. Chat uploads are capped at 25 MB
40
+ * (MAX_CHAT_ATTACHMENT_BYTES in hq-desktop-app); 26 MiB leaves a little
41
+ * headroom over that without letting a crafted key pull a large vault object.
42
+ */
43
+ export const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024;
44
+ /** Upper bound on the inline text rendering returned alongside the path. */
45
+ export const MAX_ATTACHMENT_TEXT_CHARS = 20_000;
46
+ const SCOPE_UID = /^(cmp|prs)_[A-Za-z0-9_-]{1,200}$/;
47
+ /** Every chat upload lands under this prefix in its scope. */
48
+ export const CHAT_ATTACHMENT_PREFIX = "chat/attachments/";
49
+ const DM_KEY = /^chat\/attachments\/dm\/([^/]+)\/[^/]+$/;
50
+ function notChatAttachment(p) {
51
+ return new McpToolInputError(`${JSON.stringify(p)} is not a chat attachment (expected chat/attachments/…); ` +
52
+ `hq_attachment_fetch only downloads files attached to HQ messages. For vault files and folders use hq_files_list / hq_files_read.`);
53
+ }
54
+ /**
55
+ * Validate a scope-relative attachment key. Accepts only `chat/attachments/…`
56
+ * file keys exactly as inbox items carry them. Folder / share refs (`/`,
57
+ * `…/`) get a "not a chat attachment" error pointing at hq_files_read.
58
+ * Rejects absolute paths, traversal, empty / dot segments, backslashes and
59
+ * control characters. Returns the key with any leading slashes stripped.
60
+ */
61
+ export function assertAttachmentKey(p) {
62
+ const trimmed = p.trim();
63
+ // A folder or share ref is not a traversal attempt, just the wrong tool.
64
+ if (trimmed.endsWith("/"))
65
+ throw notChatAttachment(p);
66
+ const clean = trimmed.replace(/^\/+/, "");
67
+ const segs = clean.split("/");
68
+ const bad = clean.length === 0 ||
69
+ clean.length > 1000 ||
70
+ /\\/.test(clean) ||
71
+ [...clean].some((c) => c.charCodeAt(0) < 0x20 || c.charCodeAt(0) === 0x7f) ||
72
+ segs.some((s) => s === "" || s === "." || s === "..");
73
+ if (bad) {
74
+ throw new McpToolInputError(`"vault_path" must be a relative vault key without traversal (got ${JSON.stringify(p)})`);
75
+ }
76
+ if (segs[0] === "companies") {
77
+ throw new McpToolInputError(`"vault_path" is scope-relative (e.g. chat/attachments/dm/<id>/<file>); pass it exactly as hq_inbox_read returned it, without companies/<slug>/`);
78
+ }
79
+ if (!clean.startsWith(CHAT_ATTACHMENT_PREFIX)) {
80
+ throw notChatAttachment(p);
81
+ }
82
+ return clean;
83
+ }
84
+ /** Throws unless `scopeUid` is a well-formed cmp_/prs_ uid. No network. */
85
+ export function assertAttachmentScopeUid(scopeUid) {
86
+ if (!SCOPE_UID.test(scopeUid)) {
87
+ throw new McpToolInputError(`"company_uid" must be a cmp_… or prs_… uid from hq_inbox_read (got ${JSON.stringify(scopeUid)})`);
88
+ }
89
+ }
90
+ /**
91
+ * The checks that need no network: uid format, DM-pair membership for any
92
+ * `chat/attachments/dm/…` key, and the prs_ scope rule. Run before resolving
93
+ * the bot's company so a refused ref never costs (or leaks) a request.
94
+ */
95
+ export function assertAttachmentRef(input) {
96
+ const { scopeUid, key, botEntityUid } = input;
97
+ assertAttachmentScopeUid(scopeUid);
98
+ const isDmKey = key.startsWith(`${CHAT_ATTACHMENT_PREFIX}dm/`);
99
+ const m = DM_KEY.exec(key);
100
+ const members = m ? m[1].split("--") : [];
101
+ const botInPair = members.length === 2 && members.includes(botEntityUid);
102
+ if (isDmKey && !botInPair) {
103
+ throw new McpToolInputError(`a DM attachment is only readable by a member of that DM; this agent (${botEntityUid}) is not in ${JSON.stringify(key.split("/")[3] ?? "")}`);
104
+ }
105
+ if (scopeUid.startsWith("prs_") && !(m && botInPair && members.includes(scopeUid))) {
106
+ throw new McpToolInputError(`a personal-vault attachment is only readable when it is a DM between that person and this agent (chat/attachments/dm/<uid>--<uid>/…)`);
107
+ }
108
+ }
109
+ /**
110
+ * Tenant gate. Throws unless the scope is this bot's own company, or a
111
+ * person vault holding a DM attachment between that person and this bot.
112
+ */
113
+ export function assertAttachmentScope(input) {
114
+ const { scopeUid, key, botCompanyUid, botEntityUid } = input;
115
+ assertAttachmentRef({ scopeUid, key, botEntityUid });
116
+ if (scopeUid.startsWith("cmp_") && scopeUid !== botCompanyUid) {
117
+ throw new McpToolInputError(`attachment belongs to company ${scopeUid}; this agent is enrolled in ${botCompanyUid} and cannot read another company's files`);
118
+ }
119
+ }
120
+ /** Reduce an untrusted name to a safe single path component. */
121
+ export function sanitizeAttachmentFileName(raw) {
122
+ const base = raw.trim().split(/[/\\]/).pop() ?? "";
123
+ const cleaned = base.replace(/[^\w.\- ()[\]]+/g, "_").replace(/^[.\s]+/, "");
124
+ return cleaned.slice(0, 180) || "file";
125
+ }
126
+ const TEXT_EXT = new Set(["csv", "tsv", "txt", "md", "json", "yaml", "yml", "xml", "html", "log"]);
127
+ function renderText(buf, name, contentType) {
128
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
129
+ const textual = TEXT_EXT.has(ext) || (contentType ?? "").startsWith("text/") || contentType === "application/json";
130
+ if (!textual || buf.includes(0))
131
+ return undefined;
132
+ const text = buf.toString("utf8");
133
+ return text.length > MAX_ATTACHMENT_TEXT_CHARS
134
+ ? `${text.slice(0, MAX_ATTACHMENT_TEXT_CHARS)}\n… [truncated ${text.length - MAX_ATTACHMENT_TEXT_CHARS} chars; read the local file for the rest]`
135
+ : text;
136
+ }
137
+ /**
138
+ * Download `key` from `scopeUid` and write it privately under `attachmentsDir`.
139
+ * `download` is the presign-backed reader; the caller has already gated scope.
140
+ */
141
+ export async function saveAttachment(input) {
142
+ const { attachmentsDir, scopeUid, key } = input;
143
+ const bytes = await input.download(scopeUid, key);
144
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
145
+ throw new Error(`attachment is ${bytes.length} bytes, over the ${MAX_ATTACHMENT_BYTES}-byte limit`);
146
+ }
147
+ const name = sanitizeAttachmentFileName(input.name || key.split("/").pop() || "file");
148
+ const dir = path.join(attachmentsDir, sanitizeAttachmentFileName(scopeUid));
149
+ for (const d of [attachmentsDir, dir]) {
150
+ fs.mkdirSync(d, { recursive: true, mode: 0o700 });
151
+ fs.chmodSync(d, 0o700);
152
+ }
153
+ // A short digest of scope+key keeps two same-named files apart and makes a
154
+ // re-fetch of the same attachment overwrite rather than pile up.
155
+ const digest = createHash("sha256").update(`${scopeUid}\0${key}`).digest("hex").slice(0, 12);
156
+ const localPath = path.join(dir, `${digest}-${name}`);
157
+ if (path.dirname(localPath) !== dir)
158
+ throw new Error("refusing to write outside the attachments directory");
159
+ const tmp = `${localPath}.${process.pid}.tmp`;
160
+ try {
161
+ fs.writeFileSync(tmp, bytes, { mode: 0o600 });
162
+ fs.chmodSync(tmp, 0o600);
163
+ fs.renameSync(tmp, localPath);
164
+ }
165
+ catch (err) {
166
+ // Never leave a partial copy of the attachment behind.
167
+ fs.rmSync(tmp, { force: true });
168
+ throw err;
169
+ }
170
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
171
+ const text = renderText(bytes, name, input.contentType);
172
+ const note = text !== undefined
173
+ ? undefined
174
+ : ext === "xlsx" || ext === "xls"
175
+ ? "Spreadsheet saved locally; open localPath with a spreadsheet library (e.g. openpyxl / pandas) to read the sheets."
176
+ : "Binary file saved locally; open localPath with a suitable tool.";
177
+ return {
178
+ localPath,
179
+ name,
180
+ ...(input.contentType ? { contentType: input.contentType } : {}),
181
+ sizeBytes: bytes.length,
182
+ vaultPath: key,
183
+ companyUid: scopeUid,
184
+ ...(text !== undefined ? { text } : {}),
185
+ ...(note ? { note } : {}),
186
+ };
187
+ }
188
+ //# sourceMappingURL=attachments.js.map
@@ -25,8 +25,11 @@ export interface HqRunResult {
25
25
  }
26
26
  export interface McpToolClients {
27
27
  creds: ExternalMachineCreds;
28
- /** Kit layout; the inbox tools read the mirror and done-state here. */
29
- paths: Pick<AgentKitPaths, "inboxDir">;
28
+ /**
29
+ * Kit layout; the inbox tools read the mirror and done-state here, and
30
+ * `hq_attachment_fetch` writes downloaded attachments under attachmentsDir.
31
+ */
32
+ paths: Pick<AgentKitPaths, "inboxDir" | "attachmentsDir">;
30
33
  /** Run `hq <args…>` as the machine identity; never throws. */
31
34
  runHq: (args: string[]) => Promise<HqRunResult>;
32
35
  getToken: () => Promise<string>;
@@ -37,6 +40,13 @@ export interface McpToolClients {
37
40
  * match on file names and paths, not on contents.
38
41
  */
39
42
  syncFiles?: boolean;
43
+ /** The cmp_ uid of this agent's enrolled company (creds.companySlug). */
44
+ resolveCompanyUid: () => Promise<string>;
45
+ /**
46
+ * Read one vault object's bytes by scope uid (cmp_/prs_) + scope-relative
47
+ * key through the presign API, as the machine identity.
48
+ */
49
+ downloadVaultObject: (scopeUid: string, key: string) => Promise<Buffer>;
40
50
  /** Authenticated JSON call against the control plane. */
41
51
  apiJson: (token: string, path: string, init?: {
42
52
  method?: string;
@@ -48,6 +58,7 @@ export interface McpToolClients {
48
58
  }>;
49
59
  }
50
60
  export declare const MAX_TOOL_OUTPUT_CHARS = 40000;
61
+ export declare const ATTACHMENTS_HINT: string;
51
62
  export declare function clampOutput(text: string, max?: number): string;
52
63
  /** Vault paths are company-anchored and must not escape. */
53
64
  export declare function assertVaultPath(p: string): string;
@@ -19,8 +19,12 @@ import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
20
  import { isRecipientUid } from "../../../lib/notify-recipient.js";
21
21
  import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
22
+ import { assertAttachmentKey, assertAttachmentRef, assertAttachmentScope, saveAttachment } from "./attachments.js";
22
23
  import { errorResult, McpToolInputError, textResult } from "./jsonrpc.js";
23
24
  export const MAX_TOOL_OUTPUT_CHARS = 40_000;
25
+ export const ATTACHMENTS_HINT = "Some messages carry files. For each entry in a message's `attachments` whose vaultPath starts with chat/attachments/, " +
26
+ "call hq_attachment_fetch with that vaultPath and companyUid to download it; hq_files_read cannot open message attachments. " +
27
+ "Anything else (a folder or shared vault path) is not a chat attachment: use hq_files_list / hq_files_read for those.";
24
28
  function str(args, key, opts = {}) {
25
29
  const v = args[key];
26
30
  if (v === undefined || v === null) {
@@ -148,7 +152,8 @@ export function buildAgentMcpTools(clients) {
148
152
  },
149
153
  {
150
154
  name: "hq_files_read",
151
- description: "Read one vault file's contents (hq files cat). Text files only; output is truncated at 40k chars.",
155
+ description: "Read one company vault file's contents (hq files cat). Text files only; output is truncated at 40k chars. " +
156
+ "For files attached to an HQ message use hq_attachment_fetch instead.",
152
157
  inputSchema: {
153
158
  type: "object",
154
159
  properties: { path: { type: "string", description: "companies/<slug>/<file>" } },
@@ -239,7 +244,8 @@ export function buildAgentMcpTools(clients) {
239
244
  {
240
245
  name: "hq_inbox_read",
241
246
  description: "Read this agent's HQ inbox (DMs, channel mentions, jobs), oldest first. By default only items not yet " +
242
- "marked handled. Reply with hq_dm_send, then mark each one handled with hq_inbox_done so it is never answered twice.",
247
+ "marked handled. Items with `attachments` carry files: download each with hq_attachment_fetch (pass its vaultPath " +
248
+ "and companyUid) when its vaultPath is under chat/attachments/ before answering. Reply with hq_dm_send, then mark each one handled with hq_inbox_done so it is never answered twice.",
243
249
  inputSchema: {
244
250
  type: "object",
245
251
  properties: {
@@ -262,7 +268,64 @@ export function buildAgentMcpTools(clients) {
262
268
  .filter((e) => includeDone || !done.has(e.id))
263
269
  .slice(0, limit)
264
270
  .map((e) => summarizeInboxEntry(e, done.has(e.id)));
265
- return textResult(clampOutput(JSON.stringify({ count: rows.length, messages: rows, ...(serverNote ? { note: serverNote } : {}) }, null, 2)));
271
+ const hasFiles = rows.some((r) => r.attachments?.length);
272
+ return textResult(clampOutput(JSON.stringify({
273
+ count: rows.length,
274
+ messages: rows,
275
+ ...(serverNote ? { note: serverNote } : {}),
276
+ ...(hasFiles ? { attachmentsHint: ATTACHMENTS_HINT } : {}),
277
+ }, null, 2)));
278
+ },
279
+ },
280
+ {
281
+ name: "hq_attachment_fetch",
282
+ description: "Download a file attached to an HQ chat message (from hq_inbox_read `attachments`, vaultPath under chat/attachments/) " +
283
+ "to a private local file and return its localPath, contentType and size. Text-like files (csv, txt, json, …) also come back inline, bounded. " +
284
+ "Works for any type (xlsx, pdf, images): open localPath with a suitable tool. Only chat attachments in this agent's company, " +
285
+ "or in a DM with this agent; for other vault files or folders use hq_files_list / hq_files_read.",
286
+ inputSchema: {
287
+ type: "object",
288
+ properties: {
289
+ vault_path: { type: "string", description: "attachments[].vaultPath exactly as hq_inbox_read returned it (chat/attachments/…)" },
290
+ company_uid: { type: "string", description: "attachments[].companyUid (cmp_… or prs_…)" },
291
+ name: { type: "string", description: "attachments[].name (optional; used for the local file name)" },
292
+ content_type: { type: "string", description: "attachments[].contentType (optional)" },
293
+ },
294
+ required: ["vault_path", "company_uid"],
295
+ additionalProperties: false,
296
+ },
297
+ handler: async (args) => {
298
+ const key = assertAttachmentKey(str(args, "vault_path", { required: true, max: 1000 }));
299
+ const scopeUid = str(args, "company_uid", { required: true, max: 220 }).trim();
300
+ const name = str(args, "name", { max: 500 });
301
+ const contentType = str(args, "content_type", { max: 200 });
302
+ // Format, DM-pair and prs_ checks need no network: refuse bad refs
303
+ // before resolving the company (a lookup that sends the bot's token).
304
+ assertAttachmentRef({ scopeUid, key, botEntityUid: creds.entityUid });
305
+ if (scopeUid.startsWith("cmp_")) {
306
+ let botCompanyUid;
307
+ try {
308
+ botCompanyUid = await clients.resolveCompanyUid();
309
+ }
310
+ catch (err) {
311
+ return errorResult(`could not resolve this agent's company: ${err instanceof Error ? err.message : String(err)}`);
312
+ }
313
+ assertAttachmentScope({ scopeUid, key, botCompanyUid, botEntityUid: creds.entityUid });
314
+ }
315
+ try {
316
+ const result = await saveAttachment({
317
+ attachmentsDir: clients.paths.attachmentsDir,
318
+ scopeUid,
319
+ key,
320
+ name,
321
+ contentType,
322
+ download: clients.downloadVaultObject,
323
+ });
324
+ return textResult(clampOutput(JSON.stringify(result, null, 2)));
325
+ }
326
+ catch (err) {
327
+ return errorResult(`attachment fetch failed: ${err instanceof Error ? err.message : String(err)}`);
328
+ }
266
329
  },
267
330
  },
268
331
  {
@@ -7,6 +7,7 @@
7
7
  * ~/.hq-agent/kit.json kit install settings (hq root, intervals)
8
8
  * ~/.hq-agent/state/ component-{sync,mesh,inbox}, last-heartbeat.json
9
9
  * ~/.hq-agent/inbox/ mirrored inbox items (one JSON per message)
10
+ * ~/.hq-agent/attachments/<scope>/ message attachments fetched on demand (0700 / 0600)
10
11
  * ~/.hq-agent/logs/*.log one log per kit service
11
12
  * ~/.hq-agent/skills/<name>/SKILL.md
12
13
  *
@@ -39,6 +40,8 @@ export interface AgentKitPaths {
39
40
  kitConfigPath: string;
40
41
  stateDir: string;
41
42
  inboxDir: string;
43
+ /** Private (0700) home for message attachments fetched by hq_attachment_fetch. */
44
+ attachmentsDir: string;
42
45
  logsDir: string;
43
46
  skillsDir: string;
44
47
  lastHeartbeatPath: string;
@@ -7,6 +7,7 @@
7
7
  * ~/.hq-agent/kit.json kit install settings (hq root, intervals)
8
8
  * ~/.hq-agent/state/ component-{sync,mesh,inbox}, last-heartbeat.json
9
9
  * ~/.hq-agent/inbox/ mirrored inbox items (one JSON per message)
10
+ * ~/.hq-agent/attachments/<scope>/ message attachments fetched on demand (0700 / 0600)
10
11
  * ~/.hq-agent/logs/*.log one log per kit service
11
12
  * ~/.hq-agent/skills/<name>/SKILL.md
12
13
  *
@@ -102,6 +103,7 @@ export function agentKitPaths(home = os.homedir(), env = process.env) {
102
103
  kitConfigPath: path.join(dir, KIT_CONFIG_NAME),
103
104
  stateDir,
104
105
  inboxDir: path.join(dir, "inbox"),
106
+ attachmentsDir: path.join(dir, "attachments"),
105
107
  logsDir: path.join(dir, "logs"),
106
108
  skillsDir: path.join(dir, "skills"),
107
109
  lastHeartbeatPath: path.join(stateDir, LAST_HEARTBEAT_NAME),
@@ -4,8 +4,8 @@
4
4
  * Naming an account and omitting one mean different things: an explicit
5
5
  * disabled id is refused; omitting picks the least-loaded eligible account.
6
6
  * Status reports `configured` and `logged_in` separately. logged_in is
7
- * derived from whether a provider credential file EXISTS — this module
8
- * never reads, prints, copies, or hashes that file.
7
+ * derived from credential-file existence or a provider status command; this
8
+ * module never reads, prints, copies, or hashes credential files.
9
9
  */
10
10
  import { type LaneRecord, type LaneProvider } from "./types.js";
11
11
  import type { LanesConfig } from "./types.js";
@@ -19,13 +19,22 @@ export declare const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
19
19
  * file here at all.
20
20
  */
21
21
  export declare const PROVIDER_CREDENTIAL_FILES: Record<LaneProvider, readonly string[]>;
22
- export type AuthState = "file" | "absent" | "unknown";
22
+ export type AuthState = "file" | "status" | "absent" | "unknown";
23
+ export interface AccountStatusProbeResult {
24
+ status: number | null;
25
+ stdout: string;
26
+ stderr: string;
27
+ }
23
28
  export interface AccountDeps {
24
29
  existsSync: (p: string) => boolean;
30
+ /** Provider status output is parsed for a boolean only; it is never printed. */
31
+ probe?: (command: string, args: string[], env: NodeJS.ProcessEnv) => AccountStatusProbeResult;
32
+ env?: NodeJS.ProcessEnv;
25
33
  }
26
34
  export interface AccountResolveContext {
27
35
  hqRoot?: string;
28
36
  existsSync?: (p: string) => boolean;
37
+ accountDeps?: Omit<AccountDeps, "existsSync">;
29
38
  loadLanes?: (hqRoot: string) => LaneRecord[];
30
39
  /** Already-resolved model for this lane. Beats cliModel and account defaults. */
31
40
  model?: string;
@@ -38,6 +47,8 @@ export interface AccountResolveContext {
38
47
  * set is never selected, so no refusal has to be decided under the lock.
39
48
  */
40
49
  allowedAccounts?: readonly string[];
50
+ /** Ordered preferences; callers pass these only for a new lane's first assignment. */
51
+ preferredAccounts?: readonly string[];
41
52
  }
42
53
  export interface ResolvedAccount {
43
54
  provider: LaneProvider;
@@ -54,13 +65,7 @@ export interface AccountStatus {
54
65
  disabled_reason?: string;
55
66
  /** Present in personal/lanes/config.json. */
56
67
  configured: boolean;
57
- /**
58
- * True when a provider credential file exists. False only when every
59
- * candidate is absent AND absence is conclusive for that provider
60
- * (Codex, Grok). Null for Claude when no candidate exists: Claude can
61
- * authenticate via keychain, so a missing file is not logged-out.
62
- * Never inspects file contents.
63
- */
68
+ /** True/false comes from file existence or the provider status command. */
64
69
  logged_in: boolean | null;
65
70
  auth_state: AuthState;
66
71
  envVar: string;
@@ -79,7 +84,7 @@ export declare function authStateFor(provider: LaneProvider, present: boolean):
79
84
  export declare function loggedInFromAuthState(state: AuthState): boolean | null;
80
85
  /** Admission and listing share this: refuse only a conclusive logged-out. */
81
86
  export declare function isLoggedOut(status: Pick<AccountStatus, "logged_in">): boolean;
82
- /** Auto-select requires a proven credential file, not Claude-unknown. */
87
+ /** Auto-select requires a positive file or provider-status login result. */
83
88
  export declare function isCredentialProven(status: Pick<AccountStatus, "logged_in">): boolean;
84
89
  /**
85
90
  * Occupying states hold a slot: running / awaiting_input / blocked have a
@@ -4,8 +4,8 @@
4
4
  * Naming an account and omitting one mean different things: an explicit
5
5
  * disabled id is refused; omitting picks the least-loaded eligible account.
6
6
  * Status reports `configured` and `logged_in` separately. logged_in is
7
- * derived from whether a provider credential file EXISTS — this module
8
- * never reads, prints, copies, or hashes that file.
7
+ * derived from credential-file existence or a provider status command; this
8
+ * module never reads, prints, copies, or hashes credential files.
9
9
  */
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
@@ -76,7 +76,7 @@ export function loggedInFromAuthState(state) {
76
76
  export function isLoggedOut(status) {
77
77
  return status.logged_in === false;
78
78
  }
79
- /** Auto-select requires a proven credential file, not Claude-unknown. */
79
+ /** Auto-select requires a positive file or provider-status login result. */
80
80
  export function isCredentialProven(status) {
81
81
  return status.logged_in === true;
82
82
  }
@@ -205,8 +205,12 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
205
205
  }
206
206
  const proven = [];
207
207
  for (const id of enabled) {
208
- const account = providerCfg.accounts[id];
209
- if (hasCredentialFile(provider, account.home, existsSync))
208
+ const status = accountStatus(provider, id, config, {
209
+ existsSync,
210
+ ...(ctx.accountDeps?.probe ? { probe: ctx.accountDeps.probe } : {}),
211
+ ...(ctx.accountDeps?.env ? { env: ctx.accountDeps.env } : {}),
212
+ });
213
+ if (status.logged_in === true)
210
214
  proven.push(id);
211
215
  }
212
216
  if (proven.length === 0) {
@@ -235,6 +239,9 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
235
239
  const cap = accountMaxRunning(providerCfg, id);
236
240
  return runningLaneCount(lanes, provider, id) < cap;
237
241
  });
242
+ const preferred = underCap.length > 0
243
+ ? ctx.preferredAccounts?.find((id) => underCap.includes(id))
244
+ : undefined;
238
245
  const pool = underCap.length > 0 ? underCap : runnable;
239
246
  const ranked = pool
240
247
  .map((id) => ({
@@ -243,7 +250,7 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
243
250
  lastStarted: lastStartedAt(lanes, provider, id),
244
251
  }))
245
252
  .sort(compareEligible);
246
- const chosen = ranked[0].id;
253
+ const chosen = preferred ?? ranked[0].id;
247
254
  return boundAccount(provider, chosen, providerCfg.accounts[chosen]);
248
255
  }
249
256
  export function resolveAccount(provider, requestedId, config, ctx = {}) {
@@ -293,14 +300,28 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
293
300
  };
294
301
  }
295
302
  const present = hasCredentialFile(provider, account.home, deps.existsSync);
296
- const auth_state = authStateFor(provider, present);
303
+ let auth_state = authStateFor(provider, present);
304
+ let logged_in = loggedInFromAuthState(auth_state);
305
+ if (!present && provider === "claude" && deps.probe !== undefined) {
306
+ try {
307
+ const result = deps.probe("claude", ["auth", "status"], { ...(deps.env ?? process.env), CLAUDE_CONFIG_DIR: account.home });
308
+ const providerStatus = claudeLoginStatus(result);
309
+ if (providerStatus !== null) {
310
+ auth_state = "status";
311
+ logged_in = providerStatus;
312
+ }
313
+ }
314
+ catch {
315
+ process.stderr.write(`hq lanes: Claude auth status probe failed for account ${accountId}\n`);
316
+ }
317
+ }
297
318
  const status = {
298
319
  provider,
299
320
  id: accountId,
300
321
  home: account.home,
301
322
  enabled: account.enabled,
302
323
  configured: true,
303
- logged_in: loggedInFromAuthState(auth_state),
324
+ logged_in,
304
325
  auth_state,
305
326
  envVar,
306
327
  };
@@ -312,6 +333,27 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
312
333
  export function listAccountStatuses(provider, config, deps = defaultAccountDeps()) {
313
334
  return Object.keys(config.providers[provider].accounts).map((id) => accountStatus(provider, id, config, deps));
314
335
  }
336
+ function claudeLoginStatus(result) {
337
+ const text = result.stdout.trim();
338
+ if (text.startsWith("{")) {
339
+ try {
340
+ const parsed = JSON.parse(text);
341
+ if (parsed !== null &&
342
+ typeof parsed === "object" &&
343
+ !Array.isArray(parsed) &&
344
+ typeof parsed.loggedIn === "boolean") {
345
+ return parsed.loggedIn;
346
+ }
347
+ }
348
+ catch (err) {
349
+ process.stderr.write(`hq lanes: Claude auth status returned invalid JSON: ${err instanceof Error ? err.message : String(err)}\n`);
350
+ return null;
351
+ }
352
+ }
353
+ if (/\bnot logged in\b/i.test(`${result.stdout}\n${result.stderr}`))
354
+ return false;
355
+ return null;
356
+ }
315
357
  /** Environment assignment the worker process should receive. */
316
358
  export function accountEnv(resolved) {
317
359
  return { [resolved.envVar]: resolved.home };
@@ -0,0 +1,19 @@
1
+ import type { AdmissionHost } from "./admission.js";
2
+ export declare const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
3
+ export declare const OUTPOST_DIR = "/etc/outpost";
4
+ export type OutpostIdentity = {
5
+ kind: "id";
6
+ id: string;
7
+ } | {
8
+ kind: "unknown";
9
+ } | {
10
+ kind: "not_outpost";
11
+ };
12
+ export declare function readOutpostIdentity(existsSync?: (p: string) => boolean, readFileSync?: (p: string) => string): OutpostIdentity;
13
+ export declare function resolveAdmissionHost(deps?: {
14
+ hostname?: () => string;
15
+ readOutpostIdentity?: () => OutpostIdentity;
16
+ existsSync?: (p: string) => boolean;
17
+ readFileSync?: (p: string) => string;
18
+ }): AdmissionHost;
19
+ //# sourceMappingURL=admission-host.d.ts.map
@@ -0,0 +1,33 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
4
+ export const OUTPOST_DIR = "/etc/outpost";
5
+ export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
6
+ const hasMarker = existsSync(OUTPOST_ID_PATH);
7
+ const hasDir = existsSync(OUTPOST_DIR);
8
+ if (hasMarker) {
9
+ let text;
10
+ try {
11
+ text = readFileSync(OUTPOST_ID_PATH);
12
+ }
13
+ catch (err) {
14
+ process.stderr.write(`hq lanes: failed to read ${OUTPOST_ID_PATH}: ${err instanceof Error ? err.message : String(err)}\n`);
15
+ throw err;
16
+ }
17
+ const id = text.trim();
18
+ if (!id)
19
+ return hasDir ? { kind: "unknown" } : { kind: "not_outpost" };
20
+ return { kind: "id", id };
21
+ }
22
+ if (hasDir)
23
+ return { kind: "unknown" };
24
+ return { kind: "not_outpost" };
25
+ }
26
+ export function resolveAdmissionHost(deps = {}) {
27
+ const identity = deps.readOutpostIdentity?.() ?? readOutpostIdentity(deps.existsSync, deps.readFileSync);
28
+ return {
29
+ hostname: (deps.hostname ?? os.hostname)(),
30
+ outpostId: identity.kind === "id" ? identity.id : null,
31
+ };
32
+ }
33
+ //# sourceMappingURL=admission-host.js.map
@@ -173,6 +173,29 @@ function parseFastModeModels(value, field) {
173
173
  }
174
174
  return value.map((model) => model.trim());
175
175
  }
176
+ function parsePreferredAccounts(provider, value, accounts) {
177
+ const field = `providers.${provider}.preferred_accounts`;
178
+ if (value === undefined)
179
+ return [];
180
+ if (!Array.isArray(value) ||
181
+ value.some((id) => typeof id !== "string" ||
182
+ id.trim().length === 0 ||
183
+ id.trim() !== id)) {
184
+ throw new LanesConfigError("invalid_preferred_accounts", `${field} must be an array of non-empty account ids without surrounding whitespace`, { provider, field });
185
+ }
186
+ const preferred = value;
187
+ const seen = new Set();
188
+ for (const id of preferred) {
189
+ if (seen.has(id)) {
190
+ throw new LanesConfigError("invalid_preferred_accounts", `${field} contains duplicate account ${JSON.stringify(id)}`, { provider, field, account: id });
191
+ }
192
+ if (accounts[id] === undefined) {
193
+ throw new LanesConfigError("preferred_account_unconfigured", `${field} names unconfigured account ${JSON.stringify(id)}`, { provider, field, account: id, accounts: Object.keys(accounts) });
194
+ }
195
+ seen.add(id);
196
+ }
197
+ return [...preferred];
198
+ }
176
199
  function rejectDuplicateHomes(provider, accounts) {
177
200
  const seen = new Map();
178
201
  for (const [id, account] of Object.entries(accounts)) {
@@ -232,6 +255,7 @@ function parseProvider(provider, raw, deps) {
232
255
  max_running: positiveInt(raw.max_running, `providers.${provider}.max_running`, DEFAULT_PROVIDER_MAX_RUNNING[provider]),
233
256
  max_queued: positiveInt(raw.max_queued, `providers.${provider}.max_queued`, DEFAULT_MAX_QUEUED),
234
257
  accounts,
258
+ preferred_accounts: parsePreferredAccounts(provider, raw.preferred_accounts, accounts),
235
259
  };
236
260
  if (provider === "codex") {
237
261
  parsed.fast_mode_models = parseFastModeModels(raw.fast_mode_models, `providers.${provider}.fast_mode_models`);
@@ -301,6 +325,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
301
325
  max_running: DEFAULT_PROVIDER_MAX_RUNNING[provider],
302
326
  max_queued: DEFAULT_MAX_QUEUED,
303
327
  accounts: defaultAccountMap(provider, deps),
328
+ preferred_accounts: [],
304
329
  };
305
330
  if (provider === "codex")
306
331
  providerConfig.fast_mode_models = [];
@@ -37,6 +37,7 @@ export interface ProviderRejection {
37
37
  /** Which event on the stream carried the failure. */
38
38
  event: "codex.turn.failed" | "claude.result.is_error" | "grok.error";
39
39
  }
40
+ export declare function isProviderLoginFailure(failure: ProviderRejection | null): boolean;
40
41
  export declare function capMessage(message: string): string;
41
42
  /**
42
43
  * Whether a later run could succeed without the operator changing anything.
@@ -56,10 +57,9 @@ export declare function retryableFromStatus(status: number | null): boolean;
56
57
  export declare function parseCodexTurnFailed(text: string): ProviderRejection | null;
57
58
  /**
58
59
  * Claude `-p --output-format json`: the last `type: "result"` object with
59
- * `is_error: true` and a numeric `api_error_status`. A result that is an
60
- * error with no status is not a provider rejection: it is either the quota
61
- * text shape quota.ts reads, or the worker's own failure, and keeps the
62
- * existing classification.
60
+ * `is_error: true` and a numeric `api_error_status`, or the CLI's explicit
61
+ * unauthenticated message. Other status-less errors keep the existing
62
+ * classification because they may be quota text or a worker failure.
63
63
  */
64
64
  export declare function parseClaudeResultRejection(text: string): ProviderRejection | null;
65
65
  /**