@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.
- package/CHANGELOG.md +38 -0
- package/dist/commands/agent-mcp.d.ts +4 -1
- package/dist/commands/agent-mcp.js +43 -2
- package/dist/commands/files-browse.d.ts +6 -0
- package/dist/commands/files-browse.js +3 -2
- package/dist/commands/lanes.d.ts +3 -11
- package/dist/commands/lanes.js +68 -37
- package/dist/lib/agent-kit/mcp/attachments.d.ts +99 -0
- package/dist/lib/agent-kit/mcp/attachments.js +188 -0
- package/dist/lib/agent-kit/mcp/tools.d.ts +13 -2
- package/dist/lib/agent-kit/mcp/tools.js +66 -3
- package/dist/lib/agent-kit/paths.d.ts +3 -0
- package/dist/lib/agent-kit/paths.js +2 -0
- package/dist/lib/lanes/accounts.d.ts +16 -11
- package/dist/lib/lanes/accounts.js +50 -8
- package/dist/lib/lanes/admission-host.d.ts +19 -0
- package/dist/lib/lanes/admission-host.js +33 -0
- package/dist/lib/lanes/config.js +25 -0
- package/dist/lib/lanes/provider-rejection.d.ts +4 -4
- package/dist/lib/lanes/provider-rejection.js +24 -6
- package/dist/lib/lanes/reconcile.js +30 -1
- package/dist/lib/lanes/spawn.d.ts +9 -1
- package/dist/lib/lanes/spawn.js +420 -14
- package/dist/lib/lanes/types.d.ts +7 -3
- package/dist/lib/lanes/types.js +3 -2
- package/dist/lib/lanes/watcher.d.ts +8 -1
- package/dist/lib/lanes/watcher.js +39 -3
- package/dist/utils/vault-api.d.ts +7 -1
- package/dist/utils/vault-api.js +22 -12
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
29
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
8
|
-
* never reads, prints, copies, or hashes
|
|
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
|
|
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
|
|
8
|
-
* never reads, prints, copies, or hashes
|
|
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
|
|
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
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
/**
|