@indigoai-us/hq-cli 5.153.0 → 5.154.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 +13 -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/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/utils/vault-api.d.ts +7 -1
- package/dist/utils/vault-api.js +22 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.154.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Local bots can now open files attached in HQ chat. The new `hq_attachment_fetch` agent tool downloads a chat or DM attachment into a private folder under the bot's agent directory and returns its local path, content type, and size (text files also come back inline). It accepts only the scope-relative `chat/attachments/...` paths that `hq_inbox_read` reports, fetches only from the bot's own company or from a DM the bot is part of, refuses files over 26 MiB, and uses the API the bot enrolled against. Because the attachment reference comes from the sender, any other vault path (for example `knowledge/...`, or a folder) is refused and the bot is pointed at `hq_files_read`. Previously `hq_files_read` rejected those paths and could not read binary files such as .xlsx.
|
|
10
|
+
|
|
5
11
|
## [5.153.0] — 2026-09-23
|
|
6
12
|
|
|
7
13
|
### Fixed
|
|
8
14
|
|
|
9
15
|
- `hq mesh context prd-sync` rebinds a session to the PRD's project when that project is in the same company, then emits `task_status` against the new project so the Board and channel line land there. A PRD in another company is still skipped and does not rebind.
|
|
10
16
|
|
|
17
|
+
### Tests
|
|
18
|
+
|
|
19
|
+
- Cover Outpost admission when account a is at its Codex cap and account b
|
|
20
|
+
has queued work with available capacity.
|
|
21
|
+
- Cover fresh pending resumes and answered-lane resumes against provider
|
|
22
|
+
capacity.
|
|
23
|
+
|
|
11
24
|
## [5.152.1] — 2026-09-23
|
|
12
25
|
|
|
13
26
|
### Fixed
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* hq binary with the machine-identity env pinned.
|
|
10
10
|
*/
|
|
11
11
|
import { Command } from "commander";
|
|
12
|
+
import type { Readable } from "node:stream";
|
|
12
13
|
import { McpServer } from "../lib/agent-kit/mcp/jsonrpc.js";
|
|
13
14
|
import { type HqRunResult, type McpToolClients } from "../lib/agent-kit/mcp/tools.js";
|
|
14
15
|
import { type AgentKitPaths } from "../lib/agent-kit/paths.js";
|
|
@@ -17,7 +18,9 @@ export declare const AGENT_MCP_SERVER_NAME = "hq-agent";
|
|
|
17
18
|
export declare const AGENT_MCP_INSTRUCTIONS: string;
|
|
18
19
|
export declare const HQ_SUBPROCESS_TIMEOUT_MS = 120000;
|
|
19
20
|
export declare function defaultRunHq(nodeBinary: string, hqBinary: string, env: NodeJS.ProcessEnv): (args: string[]) => Promise<HqRunResult>;
|
|
20
|
-
export declare function defaultMcpClients(creds: ExternalMachineCreds, credsPath: string, paths?: Pick<AgentKitPaths, "inboxDir">): McpToolClients;
|
|
21
|
+
export declare function defaultMcpClients(creds: ExternalMachineCreds, credsPath: string, paths?: Pick<AgentKitPaths, "inboxDir" | "attachmentsDir">): McpToolClients;
|
|
22
|
+
/** Drain a stream into memory, refusing past `max` bytes. */
|
|
23
|
+
export declare function readBounded(body: Readable, max: number): Promise<Buffer>;
|
|
21
24
|
export declare function createAgentMcpServer(clients: McpToolClients): McpServer;
|
|
22
25
|
export declare function registerAgentMcpCommand(agent: Command): void;
|
|
23
26
|
//# sourceMappingURL=agent-mcp.d.ts.map
|
|
@@ -12,7 +12,10 @@ import { spawn } from "node:child_process";
|
|
|
12
12
|
import * as os from "node:os";
|
|
13
13
|
import { CLI_VERSION } from "../cli-version.js";
|
|
14
14
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
15
|
-
import {
|
|
15
|
+
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
16
|
+
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
17
|
+
import { createCompanyPresignClient } from "./files-browse.js";
|
|
18
|
+
import { MAX_ATTACHMENT_BYTES } from "../lib/agent-kit/mcp/attachments.js";
|
|
16
19
|
import { McpServer } from "../lib/agent-kit/mcp/jsonrpc.js";
|
|
17
20
|
import { buildAgentMcpTools } from "../lib/agent-kit/mcp/tools.js";
|
|
18
21
|
import { readKitConfig } from "../lib/agent-kit/kit-config.js";
|
|
@@ -21,6 +24,7 @@ import { requireExternalCreds } from "./agent-kit.js";
|
|
|
21
24
|
export const AGENT_MCP_SERVER_NAME = "hq-agent";
|
|
22
25
|
export const AGENT_MCP_INSTRUCTIONS = "You are acting inside HQ as an enrolled external agent. Tools run as that machine identity: " +
|
|
23
26
|
"hq_whoami (identity), hq_search / hq_files_list / hq_files_read (company vault), " +
|
|
27
|
+
"hq_attachment_fetch (download a chat attachment — a chat/attachments/… vaultPath on an inbox message — any file type), " +
|
|
24
28
|
"hq_secrets_list / hq_secrets_exec (secrets by NAME only — values are never returned), " +
|
|
25
29
|
"hq_inbox_read / hq_dm_send / hq_inbox_done (read pending HQ messages, reply, then mark each handled), hq_work_mesh_status (what the team is doing). " +
|
|
26
30
|
"Never ask a user for a token or secret value; if a secret is missing, name it and ask an admin to add it.";
|
|
@@ -54,12 +58,34 @@ export function defaultMcpClients(creds, credsPath, paths = agentKitPaths(os.hom
|
|
|
54
58
|
HQ_VAULT_API_URL: creds.apiBaseUrl,
|
|
55
59
|
NO_COLOR: "1",
|
|
56
60
|
};
|
|
61
|
+
const getToken = () => ensureCognitoToken({ tokenSource: "machine", interactive: false });
|
|
62
|
+
let companyUid;
|
|
57
63
|
return {
|
|
58
64
|
creds,
|
|
59
65
|
paths,
|
|
60
66
|
syncFiles: readKitConfig(agentKitPaths(os.homedir(), process.env), CLI_VERSION).syncFiles,
|
|
61
67
|
runHq: defaultRunHq(process.execPath, process.argv[1], env),
|
|
62
|
-
getToken
|
|
68
|
+
getToken,
|
|
69
|
+
resolveCompanyUid: async () => {
|
|
70
|
+
// Pin the enrolled API: DEFAULT_VAULT_API_URL was read from env at module
|
|
71
|
+
// load, before `hq agent mcp` sets HQ_VAULT_API_URL from the creds.
|
|
72
|
+
companyUid ??= getCompanyUid(await getToken(), creds.companySlug, creds.apiBaseUrl);
|
|
73
|
+
try {
|
|
74
|
+
return await companyUid;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
companyUid = undefined;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
downloadVaultObject: async (scopeUid, key) => {
|
|
82
|
+
// Same presign path `hq files cat` uses; the vault API's per-key ACL decides.
|
|
83
|
+
const client = createCompanyPresignClient({ token: await getToken(), companyUid: scopeUid, baseUrl: creds.apiBaseUrl });
|
|
84
|
+
const res = await client.send(new GetObjectCommand({ Bucket: scopeUid, Key: key }));
|
|
85
|
+
if (!res.Body)
|
|
86
|
+
throw new Error(`no body returned for '${key}'`);
|
|
87
|
+
return readBounded(res.Body, MAX_ATTACHMENT_BYTES);
|
|
88
|
+
},
|
|
63
89
|
apiJson: async (token, path, init) => {
|
|
64
90
|
const res = await vaultApiFetch({
|
|
65
91
|
token,
|
|
@@ -74,6 +100,21 @@ export function defaultMcpClients(creds, credsPath, paths = agentKitPaths(os.hom
|
|
|
74
100
|
},
|
|
75
101
|
};
|
|
76
102
|
}
|
|
103
|
+
/** Drain a stream into memory, refusing past `max` bytes. */
|
|
104
|
+
export async function readBounded(body, max) {
|
|
105
|
+
const chunks = [];
|
|
106
|
+
let total = 0;
|
|
107
|
+
for await (const chunk of body) {
|
|
108
|
+
const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
109
|
+
total += b.length;
|
|
110
|
+
if (total > max) {
|
|
111
|
+
body.destroy();
|
|
112
|
+
throw new Error(`object exceeds the ${max}-byte attachment limit`);
|
|
113
|
+
}
|
|
114
|
+
chunks.push(b);
|
|
115
|
+
}
|
|
116
|
+
return Buffer.concat(chunks);
|
|
117
|
+
}
|
|
77
118
|
export function createAgentMcpServer(clients) {
|
|
78
119
|
return new McpServer({
|
|
79
120
|
name: AGENT_MCP_SERVER_NAME,
|
|
@@ -450,6 +450,12 @@ export declare function createCompanyPresignClient(input: {
|
|
|
450
450
|
* (keys on these errors are bucket-relative and carry no slug).
|
|
451
451
|
*/
|
|
452
452
|
companySlug?: string;
|
|
453
|
+
/**
|
|
454
|
+
* Control-plane origin for list/presign calls. Defaults to
|
|
455
|
+
* DEFAULT_VAULT_API_URL (via vaultApiFetch); an enrolled agent passes its
|
|
456
|
+
* own `apiBaseUrl` so its token only goes to the API it enrolled against.
|
|
457
|
+
*/
|
|
458
|
+
baseUrl?: string;
|
|
453
459
|
}): FilesBrowseS3Client;
|
|
454
460
|
/**
|
|
455
461
|
* Production company-mode client factory — closes over the caller's token and
|
|
@@ -678,7 +678,7 @@ export const PRESIGN_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
|
|
|
678
678
|
* `send(...)` as if it held an S3 client.
|
|
679
679
|
*/
|
|
680
680
|
export function createCompanyPresignClient(input) {
|
|
681
|
-
const { token, companyUid, companySlug } = input;
|
|
681
|
+
const { token, companyUid, companySlug, baseUrl } = input;
|
|
682
682
|
const denied = (message, key, extra) => Object.assign(new Error(message), {
|
|
683
683
|
status: 403,
|
|
684
684
|
key,
|
|
@@ -692,7 +692,7 @@ export function createCompanyPresignClient(input) {
|
|
|
692
692
|
query.prefix = prefix;
|
|
693
693
|
if (cmd.input.ContinuationToken)
|
|
694
694
|
query.cursor = cmd.input.ContinuationToken;
|
|
695
|
-
const res = await vaultApiFetch({ token, path: "/v1/files/list", query });
|
|
695
|
+
const res = await vaultApiFetch({ token, baseUrl, path: "/v1/files/list", query });
|
|
696
696
|
if (!res.ok) {
|
|
697
697
|
const body = (await res.json().catch(() => ({})));
|
|
698
698
|
const message = body.message ?? body.error ?? `files list failed (${res.status})`;
|
|
@@ -723,6 +723,7 @@ export function createCompanyPresignClient(input) {
|
|
|
723
723
|
const key = cmd.input.Key;
|
|
724
724
|
const res = await vaultApiFetch({
|
|
725
725
|
token,
|
|
726
|
+
baseUrl,
|
|
726
727
|
path: "/v1/files/presign",
|
|
727
728
|
method: "POST",
|
|
728
729
|
body: { company: companyUid, key, op: "get" },
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
/**
|
|
35
|
+
* Refuse anything larger than this. Chat uploads are capped at 25 MB
|
|
36
|
+
* (MAX_CHAT_ATTACHMENT_BYTES in hq-desktop-app); 26 MiB leaves a little
|
|
37
|
+
* headroom over that without letting a crafted key pull a large vault object.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
40
|
+
/** Upper bound on the inline text rendering returned alongside the path. */
|
|
41
|
+
export declare const MAX_ATTACHMENT_TEXT_CHARS = 20000;
|
|
42
|
+
/** Every chat upload lands under this prefix in its scope. */
|
|
43
|
+
export declare const CHAT_ATTACHMENT_PREFIX = "chat/attachments/";
|
|
44
|
+
/**
|
|
45
|
+
* Validate a scope-relative attachment key. Accepts only `chat/attachments/…`
|
|
46
|
+
* file keys exactly as inbox items carry them. Folder / share refs (`/`,
|
|
47
|
+
* `…/`) get a "not a chat attachment" error pointing at hq_files_read.
|
|
48
|
+
* Rejects absolute paths, traversal, empty / dot segments, backslashes and
|
|
49
|
+
* control characters. Returns the key with any leading slashes stripped.
|
|
50
|
+
*/
|
|
51
|
+
export declare function assertAttachmentKey(p: string): string;
|
|
52
|
+
/** Throws unless `scopeUid` is a well-formed cmp_/prs_ uid. No network. */
|
|
53
|
+
export declare function assertAttachmentScopeUid(scopeUid: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* The checks that need no network: uid format, DM-pair membership for any
|
|
56
|
+
* `chat/attachments/dm/…` key, and the prs_ scope rule. Run before resolving
|
|
57
|
+
* the bot's company so a refused ref never costs (or leaks) a request.
|
|
58
|
+
*/
|
|
59
|
+
export declare function assertAttachmentRef(input: {
|
|
60
|
+
scopeUid: string;
|
|
61
|
+
key: string;
|
|
62
|
+
botEntityUid: string;
|
|
63
|
+
}): void;
|
|
64
|
+
/**
|
|
65
|
+
* Tenant gate. Throws unless the scope is this bot's own company, or a
|
|
66
|
+
* person vault holding a DM attachment between that person and this bot.
|
|
67
|
+
*/
|
|
68
|
+
export declare function assertAttachmentScope(input: {
|
|
69
|
+
scopeUid: string;
|
|
70
|
+
key: string;
|
|
71
|
+
botCompanyUid: string;
|
|
72
|
+
botEntityUid: string;
|
|
73
|
+
}): void;
|
|
74
|
+
/** Reduce an untrusted name to a safe single path component. */
|
|
75
|
+
export declare function sanitizeAttachmentFileName(raw: string): string;
|
|
76
|
+
export interface FetchAttachmentResult {
|
|
77
|
+
localPath: string;
|
|
78
|
+
name: string;
|
|
79
|
+
contentType?: string;
|
|
80
|
+
sizeBytes: number;
|
|
81
|
+
vaultPath: string;
|
|
82
|
+
companyUid: string;
|
|
83
|
+
/** Inline rendering for text-like files (csv, txt, json, …), bounded. */
|
|
84
|
+
text?: string;
|
|
85
|
+
note?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Download `key` from `scopeUid` and write it privately under `attachmentsDir`.
|
|
89
|
+
* `download` is the presign-backed reader; the caller has already gated scope.
|
|
90
|
+
*/
|
|
91
|
+
export declare function saveAttachment(input: {
|
|
92
|
+
attachmentsDir: string;
|
|
93
|
+
scopeUid: string;
|
|
94
|
+
key: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
contentType?: string;
|
|
97
|
+
download: (scopeUid: string, key: string) => Promise<Buffer>;
|
|
98
|
+
}): Promise<FetchAttachmentResult>;
|
|
99
|
+
//# sourceMappingURL=attachments.d.ts.map
|
|
@@ -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),
|
|
@@ -61,7 +61,13 @@ export declare function vaultApiFetchPublic(opts: {
|
|
|
61
61
|
}): Promise<Response>;
|
|
62
62
|
export declare function looksLikeCompanyUid(ref: string): boolean;
|
|
63
63
|
export declare function looksLikePersonVaultUid(ref: string): boolean;
|
|
64
|
-
export declare function getCompanyUid(token: string, companySlug: string | undefined
|
|
64
|
+
export declare function getCompanyUid(token: string, companySlug: string | undefined,
|
|
65
|
+
/**
|
|
66
|
+
* Control-plane origin for the lookups. Defaults to DEFAULT_VAULT_API_URL;
|
|
67
|
+
* pass it when the caller is bound to another API (an enrolled agent's
|
|
68
|
+
* `apiBaseUrl`) so its token never goes to the default host.
|
|
69
|
+
*/
|
|
70
|
+
baseUrl?: string): Promise<string>;
|
|
65
71
|
export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
|
|
66
72
|
export declare function getEntityUid(token: string, opts: {
|
|
67
73
|
personal?: boolean;
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -314,10 +314,11 @@ function raiseIfUnauthorized(res) {
|
|
|
314
314
|
if (res.status === 401)
|
|
315
315
|
throw new AuthError();
|
|
316
316
|
}
|
|
317
|
-
async function resolveCompanyByUid(token, uid) {
|
|
317
|
+
async function resolveCompanyByUid(token, uid, baseUrl) {
|
|
318
318
|
const res = await vaultApiFetch({
|
|
319
319
|
token,
|
|
320
320
|
path: `/entity/${encodeURIComponent(uid)}`,
|
|
321
|
+
baseUrl,
|
|
321
322
|
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
322
323
|
});
|
|
323
324
|
if (!res.ok) {
|
|
@@ -370,11 +371,12 @@ function isTransientSlugLookupStatus(status) {
|
|
|
370
371
|
* stranger's same-slug company is correctly invisible to resolution. This is
|
|
371
372
|
* the same primitive hq-cloud's `context.ts` already resolves slugs through.
|
|
372
373
|
*/
|
|
373
|
-
async function resolveSlugInCallerNamespace(token, slug) {
|
|
374
|
+
async function resolveSlugInCallerNamespace(token, slug, baseUrl) {
|
|
374
375
|
const attempt = async () => {
|
|
375
376
|
const res = await vaultApiFetch({
|
|
376
377
|
token,
|
|
377
378
|
path: "/entity/check-slug/me",
|
|
379
|
+
baseUrl,
|
|
378
380
|
query: { type: "company", slug },
|
|
379
381
|
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
380
382
|
});
|
|
@@ -411,10 +413,11 @@ async function resolveSlugInCallerNamespace(token, slug) {
|
|
|
411
413
|
return first;
|
|
412
414
|
return run();
|
|
413
415
|
}
|
|
414
|
-
async function fetchGlobalCompanyBySlug(token, ref) {
|
|
416
|
+
async function fetchGlobalCompanyBySlug(token, ref, baseUrl) {
|
|
415
417
|
const attempt = () => vaultApiFetch({
|
|
416
418
|
token,
|
|
417
419
|
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
420
|
+
baseUrl,
|
|
418
421
|
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
419
422
|
});
|
|
420
423
|
try {
|
|
@@ -429,10 +432,10 @@ async function fetchGlobalCompanyBySlug(token, ref) {
|
|
|
429
432
|
}
|
|
430
433
|
return attempt();
|
|
431
434
|
}
|
|
432
|
-
async function resolveCompanyUid(token, ref) {
|
|
435
|
+
async function resolveCompanyUid(token, ref, baseUrl) {
|
|
433
436
|
// Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
|
|
434
437
|
if (looksLikeCompanyUid(ref)) {
|
|
435
|
-
return resolveCompanyByUid(token, ref);
|
|
438
|
+
return resolveCompanyByUid(token, ref, baseUrl);
|
|
436
439
|
}
|
|
437
440
|
// A `prs_` ref names a person's vault (DM attachments). There is no slug to
|
|
438
441
|
// resolve and no company lookup that could succeed; pass it through as the
|
|
@@ -453,7 +456,7 @@ async function resolveCompanyUid(token, ref) {
|
|
|
453
456
|
// hq-cli defect still throws, and the 401 AuthError that
|
|
454
457
|
// `raiseIfUnauthorized` throws still short-circuits before any fallback
|
|
455
458
|
// (Sentry HQ-CLI-1P / issue 7716090679).
|
|
456
|
-
const mine = await resolveSlugInCallerNamespace(token, ref);
|
|
459
|
+
const mine = await resolveSlugInCallerNamespace(token, ref, baseUrl);
|
|
457
460
|
if (mine.kind === "hit") {
|
|
458
461
|
return mine.uid;
|
|
459
462
|
}
|
|
@@ -462,7 +465,7 @@ async function resolveCompanyUid(token, ref) {
|
|
|
462
465
|
// cross-company / admin resolutions that intentionally target a company the
|
|
463
466
|
// caller isn't a member of (e.g. a group-grant target). A single global
|
|
464
467
|
// match resolves cleanly.
|
|
465
|
-
const res = await fetchGlobalCompanyBySlug(token, ref);
|
|
468
|
+
const res = await fetchGlobalCompanyBySlug(token, ref, baseUrl);
|
|
466
469
|
if (!res.ok) {
|
|
467
470
|
raiseIfUnauthorized(res);
|
|
468
471
|
const body = (await res.json().catch(() => ({})));
|
|
@@ -494,10 +497,11 @@ async function resolveCompanyUid(token, ref) {
|
|
|
494
497
|
const data = (await res.json());
|
|
495
498
|
return data.entity.uid;
|
|
496
499
|
}
|
|
497
|
-
async function resolveCompanyFromMemberships(token) {
|
|
500
|
+
async function resolveCompanyFromMemberships(token, baseUrl) {
|
|
498
501
|
const res = await vaultApiFetch({
|
|
499
502
|
token,
|
|
500
503
|
path: '/membership/me',
|
|
504
|
+
baseUrl,
|
|
501
505
|
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
502
506
|
});
|
|
503
507
|
if (!res.ok) {
|
|
@@ -519,7 +523,13 @@ async function resolveCompanyFromMemberships(token) {
|
|
|
519
523
|
`pick one:\n` +
|
|
520
524
|
picks.join('\n'));
|
|
521
525
|
}
|
|
522
|
-
export async function getCompanyUid(token, companySlug
|
|
526
|
+
export async function getCompanyUid(token, companySlug,
|
|
527
|
+
/**
|
|
528
|
+
* Control-plane origin for the lookups. Defaults to DEFAULT_VAULT_API_URL;
|
|
529
|
+
* pass it when the caller is bound to another API (an enrolled agent's
|
|
530
|
+
* `apiBaseUrl`) so its token never goes to the default host.
|
|
531
|
+
*/
|
|
532
|
+
baseUrl) {
|
|
523
533
|
// Vault API keys are company-bound. Entity slug resolution has no keyed
|
|
524
534
|
// route, so under hqk_ we accept an explicit cmp_ uid (or HQ_COMPANY_UID)
|
|
525
535
|
// and otherwise fall through to keyed GET /v1/keys/membership/me.
|
|
@@ -532,12 +542,12 @@ export async function getCompanyUid(token, companySlug) {
|
|
|
532
542
|
}
|
|
533
543
|
return ref;
|
|
534
544
|
}
|
|
535
|
-
return resolveCompanyFromMemberships(token);
|
|
545
|
+
return resolveCompanyFromMemberships(token, baseUrl);
|
|
536
546
|
}
|
|
537
547
|
if (companySlug) {
|
|
538
|
-
return resolveCompanyUid(token, companySlug);
|
|
548
|
+
return resolveCompanyUid(token, companySlug, baseUrl);
|
|
539
549
|
}
|
|
540
|
-
return resolveCompanyFromMemberships(token);
|
|
550
|
+
return resolveCompanyFromMemberships(token, baseUrl);
|
|
541
551
|
}
|
|
542
552
|
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
543
553
|
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|