@indigoai-us/hq-cli 5.249.1 → 5.250.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 +36 -0
- package/dist/commands/agent-enroll.js +7 -3
- package/dist/commands/agent-probe.d.ts +2 -0
- package/dist/commands/agent-probe.js +11 -1
- package/dist/commands/dm.js +7 -0
- package/dist/lib/agent-kit/adopt-identity.js +9 -0
- package/dist/lib/agent-kit/creds.d.ts +28 -0
- package/dist/lib/agent-kit/creds.js +31 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +1 -0
- package/dist/lib/agent-kit/inbox-state.js +3 -0
- package/dist/lib/agent-kit/mcp/tools.js +17 -4
- package/dist/lib/agent-kit/skills.js +8 -2
- package/dist/lib/agent-kit/wake.js +9 -3
- package/dist/main.js +13 -0
- package/dist/utils/cognito-session.d.ts +12 -0
- package/dist/utils/cognito-session.js +36 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.250.0] — 2026-09-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- An enrolled agent that sets `HQ_AGENT_DIR` now acts as that agent in every
|
|
10
|
+
command, not only under `hq agent`. `hq dm` and `hq secrets` used to look
|
|
11
|
+
for a person's session instead, find none, and open a browser sign-in. On
|
|
12
|
+
the Runneth pilot that hung the first reply for four minutes. Only the
|
|
13
|
+
explicit variable counts, so a bot living beside its owner on the owner's
|
|
14
|
+
computer never takes over the owner's commands.
|
|
15
|
+
- On an agent host (Linux, no terminal, no display), a missing session now
|
|
16
|
+
fails at once with the fix instead of opening a browser sign-in that
|
|
17
|
+
nobody can complete.
|
|
18
|
+
- `hq agent enroll --name` no longer overrides `HQ_AGENT_DIR`. A bot that
|
|
19
|
+
pointed its identity at persistent storage had it written under `$HOME`
|
|
20
|
+
instead, where a rebuild erases it, with a message claiming a person was
|
|
21
|
+
signed in when nobody was.
|
|
22
|
+
- `wake.log` no longer records the wake command's standard output. A wake
|
|
23
|
+
command's normal output (a routine record, an echoed prompt) is not
|
|
24
|
+
diagnostic, and on a share without permissions the log is readable by
|
|
25
|
+
anything with access to the share. Errors are still logged.
|
|
26
|
+
- Bots now reply in the group or channel a message came from. Their inbox
|
|
27
|
+
shows the channel id, `hq dm <channelId>` and the `hq_dm_send` tool post by
|
|
28
|
+
that id (including unnamed group DMs), and the wake and inbox instructions
|
|
29
|
+
say to answer there. Before, a bot answered a group message with a private
|
|
30
|
+
DM to the sender, so the group never heard back. The instructions also tell
|
|
31
|
+
bots not to answer another bot's greetings or sign-offs, so two bots stop
|
|
32
|
+
acknowledging each other in a loop.
|
|
33
|
+
- An agent whose identity files were moved or copied no longer goes silent an
|
|
34
|
+
hour later. The credentials file records where the host key lives; when that
|
|
35
|
+
path is gone and the key sits beside the credentials file, the CLI now points
|
|
36
|
+
at it before running any agent command. `hq agent probe` also fails when the
|
|
37
|
+
host key is missing, instead of passing on a cached token and leaving the
|
|
38
|
+
agent to fail at its next refresh.
|
|
39
|
+
|
|
40
|
+
|
|
5
41
|
## [5.249.1] — 2026-09-27
|
|
6
42
|
|
|
7
43
|
## [5.249.0] — 2026-09-27
|
|
@@ -204,14 +204,18 @@ function parseEnrollResponse(raw) {
|
|
|
204
204
|
*/
|
|
205
205
|
export function resolveEnrollTarget(opts, env = process.env, home = os.homedir()) {
|
|
206
206
|
const pinned = Boolean(env.HQ_AGENT_DIR?.trim() || env.HQ_MACHINE_CREDS_FILE?.trim());
|
|
207
|
+
// A pinned tree is the environment's explicit choice and wins over --name:
|
|
208
|
+
// a bot that set HQ_AGENT_DIR to its persistent storage must never be
|
|
209
|
+
// redirected to a directory under $HOME, which may be erased on rebuild.
|
|
210
|
+
if (pinned)
|
|
211
|
+
return { paths: agentKitPaths(home, env), local: false };
|
|
207
212
|
if (opts.name) {
|
|
208
213
|
const dir = localAgentDir(validateLocalAgentName(opts.name), home);
|
|
209
214
|
return { paths: agentKitPaths(home, { ...env, HQ_AGENT_DIR: dir }), local: true };
|
|
210
215
|
}
|
|
211
216
|
const base = agentKitPaths(home, env);
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
if (pinned || opts.replace)
|
|
217
|
+
// --replace is an explicit choice too; it is not silently redirected.
|
|
218
|
+
if (opts.replace)
|
|
215
219
|
return { paths: base, local: false };
|
|
216
220
|
if (detectExistingIdentity(base, env).kind !== "human") {
|
|
217
221
|
return { paths: base, local: false };
|
|
@@ -54,6 +54,8 @@ export interface ProbeDeps {
|
|
|
54
54
|
listSecrets: (token: string, companyUid: string) => Promise<number>;
|
|
55
55
|
postResult: (token: string, result: ProbeResult) => Promise<number>;
|
|
56
56
|
now?: () => Date;
|
|
57
|
+
/** Test seam; defaults to fs.existsSync. */
|
|
58
|
+
hostKeyExists?: (p: string) => boolean;
|
|
57
59
|
sleep?: (ms: number) => Promise<void>;
|
|
58
60
|
nonce?: () => string;
|
|
59
61
|
dmTimeoutMs?: number;
|
|
@@ -63,7 +63,17 @@ export async function runProbe(deps) {
|
|
|
63
63
|
const claims = peekIdToken(token);
|
|
64
64
|
const uid = claims["custom:entityUid"];
|
|
65
65
|
const type = claims["custom:entityType"];
|
|
66
|
-
if (type === "agent" && uid === self) {
|
|
66
|
+
if (type === "agent" && uid === self && !(deps.hostKeyExists ?? fs.existsSync)(deps.creds.hostKeyPath)) {
|
|
67
|
+
// A cached token hides a missing host key for up to an hour: the probe
|
|
68
|
+
// used to pass and the agent then went silent at its next refresh.
|
|
69
|
+
checks.push({
|
|
70
|
+
name: "whoami",
|
|
71
|
+
ok: false,
|
|
72
|
+
detail: `token minted from cache, but there is no host key at ${deps.creds.hostKeyPath}, so the next refresh will fail. Keep host-key beside machine-creds.json, or re-enroll`,
|
|
73
|
+
});
|
|
74
|
+
token = null;
|
|
75
|
+
}
|
|
76
|
+
else if (type === "agent" && uid === self) {
|
|
67
77
|
checks.push({ name: "whoami", ok: true, detail: `${self} (external, ${deps.creds.companySlug})` });
|
|
68
78
|
}
|
|
69
79
|
else {
|
package/dist/commands/dm.js
CHANGED
|
@@ -84,6 +84,13 @@ export function parseChannelName(recipient) {
|
|
|
84
84
|
* unit-testable.
|
|
85
85
|
*/
|
|
86
86
|
export function matchChannelsByName(channels, name) {
|
|
87
|
+
// A channel id is exact and names nothing else, so it may target any scope,
|
|
88
|
+
// including an unnamed group DM. Bots reply this way: their inbox carries
|
|
89
|
+
// the id of the conversation a message arrived in, not its name.
|
|
90
|
+
const trimmed = name.trim().replace(/^#/, "");
|
|
91
|
+
if (/^chn_[A-Za-z0-9]+$/.test(trimmed)) {
|
|
92
|
+
return channels.filter((c) => c.channelId === trimmed);
|
|
93
|
+
}
|
|
87
94
|
const targetSlug = channelSlug(name);
|
|
88
95
|
const targetName = name.trim().toLowerCase();
|
|
89
96
|
if (!targetSlug && !targetName)
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
+
import { ensureHostKeyPath } from "./creds.js";
|
|
21
22
|
export const MACHINE_CREDS_FILE_ENV = "HQ_MACHINE_CREDS_FILE";
|
|
22
23
|
export const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
|
|
23
24
|
/**
|
|
@@ -37,6 +38,14 @@ export function adoptAgentIdentityEnv(paths, env = process.env) {
|
|
|
37
38
|
catch {
|
|
38
39
|
return applied;
|
|
39
40
|
}
|
|
41
|
+
// A moved or copied identity keeps a stale host-key path until the next
|
|
42
|
+
// token refresh fails; repoint it at the key beside the creds file first.
|
|
43
|
+
try {
|
|
44
|
+
ensureHostKeyPath(paths);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* never block a command on the repair; probe reports a missing key */
|
|
48
|
+
}
|
|
40
49
|
applied[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
|
|
41
50
|
env[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
|
|
42
51
|
// Per-agent token cache. Two identities on one computer must never share a
|
|
@@ -75,4 +75,32 @@ export interface ComponentReading {
|
|
|
75
75
|
* older than `staleAfterMs` reads as `error` — silence is never health.
|
|
76
76
|
*/
|
|
77
77
|
export declare function readComponentStatus(paths: Pick<AgentKitPaths, "stateDir">, component: KitComponent, staleAfterMs: number, now?: () => Date): ComponentReading;
|
|
78
|
+
export type HostKeyPathCheck = {
|
|
79
|
+
status: "ok";
|
|
80
|
+
hostKeyPath: string;
|
|
81
|
+
} | {
|
|
82
|
+
status: "healed";
|
|
83
|
+
hostKeyPath: string;
|
|
84
|
+
previous: string;
|
|
85
|
+
} | {
|
|
86
|
+
status: "missing";
|
|
87
|
+
hostKeyPath: string;
|
|
88
|
+
} | {
|
|
89
|
+
status: "no-creds";
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Make the recorded host-key path point at a key that exists.
|
|
93
|
+
*
|
|
94
|
+
* The creds file records the key's absolute path, and hq-cloud signs every
|
|
95
|
+
* token mint with the key at that path. When the identity is moved or copied —
|
|
96
|
+
* the Runneth pilot copied its files from a disposable $HOME to persistent
|
|
97
|
+
* storage — the recorded path goes stale, and nothing fails until the cached
|
|
98
|
+
* token expires up to an hour later. Then every mint fails and the agent goes
|
|
99
|
+
* silent until someone re-enrolls it.
|
|
100
|
+
*
|
|
101
|
+
* The key always travels with the creds file, so when the recorded path is
|
|
102
|
+
* gone and a key sits beside the creds file, point at that one. The key
|
|
103
|
+
* itself is never read or copied here; only the recorded path changes.
|
|
104
|
+
*/
|
|
105
|
+
export declare function ensureHostKeyPath(paths: Pick<AgentKitPaths, "machineCredsPath">): HostKeyPathCheck;
|
|
78
106
|
//# sourceMappingURL=creds.d.ts.map
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
28
28
|
import * as fs from "node:fs";
|
|
29
|
-
import
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { componentStatePath, HOST_KEY_NAME } from "./paths.js";
|
|
30
31
|
import { writePrivateFile, } from "./private-file.js";
|
|
31
32
|
/** Contract fingerprint: sha256(DER SPKI public key) hex, first 16 chars. */
|
|
32
33
|
export function hostFingerprint(publicPem) {
|
|
@@ -130,4 +131,33 @@ export function readComponentStatus(paths, component, staleAfterMs, now = () =>
|
|
|
130
131
|
}
|
|
131
132
|
return { status: "ok", at };
|
|
132
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Make the recorded host-key path point at a key that exists.
|
|
136
|
+
*
|
|
137
|
+
* The creds file records the key's absolute path, and hq-cloud signs every
|
|
138
|
+
* token mint with the key at that path. When the identity is moved or copied —
|
|
139
|
+
* the Runneth pilot copied its files from a disposable $HOME to persistent
|
|
140
|
+
* storage — the recorded path goes stale, and nothing fails until the cached
|
|
141
|
+
* token expires up to an hour later. Then every mint fails and the agent goes
|
|
142
|
+
* silent until someone re-enrolls it.
|
|
143
|
+
*
|
|
144
|
+
* The key always travels with the creds file, so when the recorded path is
|
|
145
|
+
* gone and a key sits beside the creds file, point at that one. The key
|
|
146
|
+
* itself is never read or copied here; only the recorded path changes.
|
|
147
|
+
*/
|
|
148
|
+
export function ensureHostKeyPath(paths) {
|
|
149
|
+
const creds = readExternalMachineCreds(paths);
|
|
150
|
+
if (!creds)
|
|
151
|
+
return { status: "no-creds" };
|
|
152
|
+
if (fs.existsSync(creds.hostKeyPath))
|
|
153
|
+
return { status: "ok", hostKeyPath: creds.hostKeyPath };
|
|
154
|
+
const sibling = path.join(path.dirname(paths.machineCredsPath), HOST_KEY_NAME);
|
|
155
|
+
if (sibling === creds.hostKeyPath || !fs.existsSync(sibling)) {
|
|
156
|
+
return { status: "missing", hostKeyPath: creds.hostKeyPath };
|
|
157
|
+
}
|
|
158
|
+
const raw = JSON.parse(fs.readFileSync(paths.machineCredsPath, "utf8"));
|
|
159
|
+
raw.hostKeyPath = sibling;
|
|
160
|
+
writePrivateFile(paths.machineCredsPath, `${JSON.stringify(raw, null, 2)}\n`, 0o600);
|
|
161
|
+
return { status: "healed", hostKeyPath: sibling, previous: creds.hostKeyPath };
|
|
162
|
+
}
|
|
133
163
|
//# sourceMappingURL=creds.js.map
|
|
@@ -50,6 +50,7 @@ export declare function summarizeInboxEntry(e: MirroredInboxEntry, done: boolean
|
|
|
50
50
|
attachments?: InboxAttachment[] | undefined;
|
|
51
51
|
id: string;
|
|
52
52
|
channel: string | undefined;
|
|
53
|
+
channelId: string | undefined;
|
|
53
54
|
from: string;
|
|
54
55
|
fromUid: string | undefined;
|
|
55
56
|
at: string | undefined;
|
|
@@ -118,6 +118,9 @@ export function summarizeInboxEntry(e, done) {
|
|
|
118
118
|
return {
|
|
119
119
|
id: e.id,
|
|
120
120
|
channel: pick("channel"),
|
|
121
|
+
// Set when the message arrived in a channel or group DM. Reply there
|
|
122
|
+
// (`hq dm <channelId> "…"`), not to the sender privately.
|
|
123
|
+
channelId: pick("channelId"),
|
|
121
124
|
from: pick("fromDisplayName", "fromEmail", "fromPersonUid") ?? "unknown",
|
|
122
125
|
fromUid: pick("fromPersonUid"),
|
|
123
126
|
at: pick("receivedAt", "createdAt", "mirroredAt"),
|
|
@@ -213,11 +213,11 @@ export function buildAgentMcpTools(clients) {
|
|
|
213
213
|
},
|
|
214
214
|
{
|
|
215
215
|
name: "hq_dm_send",
|
|
216
|
-
description: "Send an HQ
|
|
216
|
+
description: "Send an HQ message as this agent to a person (email or prs_…), an agent (agt_…), or a channel or group DM (chn_…). When replying to an inbox item that has a channelId, pass that channelId as `to` so the answer lands in the conversation it came from. Use audience='agent' for progress/status messages that only agents need to see; audience='human' for messages a person must read; audience='both' when unsure.",
|
|
217
217
|
inputSchema: {
|
|
218
218
|
type: "object",
|
|
219
219
|
properties: {
|
|
220
|
-
to: { type: "string", description: "Email, prs_… or agt_… uid" },
|
|
220
|
+
to: { type: "string", description: "Email, prs_… or agt_… uid, or a chn_… channel id" },
|
|
221
221
|
message: { type: "string" },
|
|
222
222
|
audience: { type: "string", enum: ["human", "agent", "both"], description: "Who this message is for (default: human)" },
|
|
223
223
|
},
|
|
@@ -231,12 +231,25 @@ export function buildAgentMcpTools(clients) {
|
|
|
231
231
|
if (audience !== undefined && !["human", "agent", "both"].includes(audience)) {
|
|
232
232
|
throw new McpToolInputError('"audience" must be human, agent, or both');
|
|
233
233
|
}
|
|
234
|
+
if (/^chn_[A-Za-z0-9]+$/.test(to)) {
|
|
235
|
+
// A channel or group DM: post into it, so a reply lands where the
|
|
236
|
+
// message arrived instead of in a private DM to the sender.
|
|
237
|
+
const token = await clients.getToken();
|
|
238
|
+
const res = await clients.apiJson(token, `/v1/notify/channels/${encodeURIComponent(to)}/messages`, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
body: { body: message },
|
|
241
|
+
});
|
|
242
|
+
if (res.status < 200 || res.status >= 300) {
|
|
243
|
+
return errorResult(`Channel send failed (${res.status}): ${describeError(res.body)}`);
|
|
244
|
+
}
|
|
245
|
+
return textResult(`posted in ${to}`);
|
|
246
|
+
}
|
|
234
247
|
const body = /^[^\s]+@[^\s]+$/.test(to)
|
|
235
248
|
? { toEmail: to.toLowerCase(), body: message }
|
|
236
249
|
: isRecipientUid(to)
|
|
237
250
|
? { toPersonUid: to, body: message }
|
|
238
251
|
: (() => {
|
|
239
|
-
throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid');
|
|
252
|
+
throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid, or a chn_… channel id');
|
|
240
253
|
})();
|
|
241
254
|
if (audience)
|
|
242
255
|
body.audience = audience;
|
|
@@ -252,7 +265,7 @@ export function buildAgentMcpTools(clients) {
|
|
|
252
265
|
name: "hq_inbox_read",
|
|
253
266
|
description: "Read this agent's HQ inbox (DMs, channel mentions, jobs), oldest first. By default only items not yet " +
|
|
254
267
|
"marked handled. Items with `attachments` carry files: download each with hq_attachment_fetch (pass its vaultPath " +
|
|
255
|
-
"and companyUid) when its vaultPath is under chat/attachments/ before answering. Reply with hq_dm_send,
|
|
268
|
+
"and companyUid) when its vaultPath is under chat/attachments/ before answering. Reply with hq_dm_send, passing the item's channelId as `to` when it has one so the reply lands in that channel or group; do not reply to another bot's greeting or sign-off that asks for nothing. Then mark each one handled with hq_inbox_done so it is never answered twice.",
|
|
256
269
|
inputSchema: {
|
|
257
270
|
type: "object",
|
|
258
271
|
properties: {
|
|
@@ -31,11 +31,17 @@ The kit's inbox poller mirrors every message addressed to you (DMs, channel
|
|
|
31
31
|
mentions, jobs). Work through them like this:
|
|
32
32
|
|
|
33
33
|
\`\`\`bash
|
|
34
|
-
hq agent inbox # pending messages, oldest first (id, sender uid, text)
|
|
35
|
-
hq dm <
|
|
34
|
+
hq agent inbox # pending messages, oldest first (id, sender uid, channelId, text)
|
|
35
|
+
hq dm <channelId> "reply" # message came from a channel or group: answer in it
|
|
36
|
+
hq dm <sender-uid> "reply" # otherwise: answer the sender privately
|
|
36
37
|
hq agent inbox done <id> [id…] # mark handled so it is never answered twice
|
|
37
38
|
\`\`\`
|
|
38
39
|
|
|
40
|
+
Reply where the message arrived. A message with a \`channelId\` was posted in a
|
|
41
|
+
channel or group DM, and the answer belongs there, not in a private DM to the
|
|
42
|
+
sender. Do not reply to another bot's greeting, thanks or sign-off that asks
|
|
43
|
+
for nothing: two bots acknowledging each other never stop.
|
|
44
|
+
|
|
39
45
|
The MCP equivalents are \`hq_inbox_read\`, \`hq_dm_send\` and \`hq_inbox_done\`.
|
|
40
46
|
If the kit has a wake configured (\`hq agent kit wake show\`), HQ pokes your
|
|
41
47
|
framework whenever something is pending, so always drain the whole list and
|
|
@@ -27,8 +27,10 @@ export const WAKE_STATE_NAME = "wake-state.json";
|
|
|
27
27
|
export const DEFAULT_WAKE_RETRY_MS = 10 * 60_000;
|
|
28
28
|
export const WAKE_URL_TIMEOUT_MS = 15_000;
|
|
29
29
|
export const DEFAULT_WAKE_COMMAND_TIMEOUT_MS = 10 * 60_000;
|
|
30
|
-
export const WAKE_INSTRUCTIONS = "New HQ messages are waiting. Run `hq agent inbox` (or the hq_inbox_read tool)
|
|
31
|
-
"
|
|
30
|
+
export const WAKE_INSTRUCTIONS = "New HQ messages are waiting. Run `hq agent inbox` (or the hq_inbox_read tool). Reply where each message " +
|
|
31
|
+
"arrived: if it has a channelId, post in that channel with `hq dm <channelId> \"…\"`; otherwise reply to the " +
|
|
32
|
+
"sender with `hq dm <sender-uid> \"…\"` (or hq_dm_send). Do not reply to another bot's greeting, thanks or " +
|
|
33
|
+
"sign-off that asks for nothing. Then mark each handled with `hq agent inbox done <id>` (or hq_inbox_done).";
|
|
32
34
|
export function wakePath(paths) {
|
|
33
35
|
return path.join(paths.agentDir, WAKE_FILE_NAME);
|
|
34
36
|
}
|
|
@@ -197,7 +199,11 @@ export async function fireWake(config, notice, deps) {
|
|
|
197
199
|
let child;
|
|
198
200
|
try {
|
|
199
201
|
child = doSpawn(config.command[0], config.command.slice(1), {
|
|
200
|
-
|
|
202
|
+
// stdout is discarded: a wake command's normal output (a routine record,
|
|
203
|
+
// a prompt echo) has no diagnostic value and would land in a log that is
|
|
204
|
+
// world-readable on a share without permissions. stderr is kept so a
|
|
205
|
+
// failing wake can still be diagnosed.
|
|
206
|
+
stdio: ["pipe", "ignore", logFd],
|
|
201
207
|
env: {
|
|
202
208
|
...process.env,
|
|
203
209
|
HQ_WAKE_EVENT: notice.event,
|
package/dist/main.js
CHANGED
|
@@ -54,6 +54,9 @@ import { installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTree
|
|
|
54
54
|
import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
|
|
55
55
|
import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
56
56
|
import { runGlobalNetworkLifecycle } from "./utils/cli-network-lifecycle.js";
|
|
57
|
+
import * as os from "node:os";
|
|
58
|
+
import { adoptAgentIdentityEnv } from "./lib/agent-kit/adopt-identity.js";
|
|
59
|
+
import { agentKitPaths } from "./lib/agent-kit/paths.js";
|
|
57
60
|
/**
|
|
58
61
|
* Total time one CLI invocation may await foreground network work. This spans
|
|
59
62
|
* the hard version decision, pre-action observability, and release-health
|
|
@@ -160,6 +163,16 @@ program
|
|
|
160
163
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
161
164
|
.version(CLI_VERSION);
|
|
162
165
|
program.hook("preAction", async () => {
|
|
166
|
+
// A host that pinned HQ_AGENT_DIR has declared that it IS that agent, so
|
|
167
|
+
// every command — `hq dm`, `hq secrets`, not only `hq agent …` — must
|
|
168
|
+
// mint as the agent. Without this, those commands looked for a person
|
|
169
|
+
// session, found none, and opened a browser sign-in that a headless bot can
|
|
170
|
+
// never complete. Only the explicit variable counts: a bot that merely lives
|
|
171
|
+
// beside its owner on the owner's computer must not take over the owner's
|
|
172
|
+
// commands.
|
|
173
|
+
if (process.env.HQ_AGENT_DIR?.trim()) {
|
|
174
|
+
adoptAgentIdentityEnv(agentKitPaths(os.homedir(), process.env), process.env);
|
|
175
|
+
}
|
|
163
176
|
// Both are best-effort and fully swallowed: neither can change the command's
|
|
164
177
|
// result or exit code. They receive only the shared pre-finalization
|
|
165
178
|
// allowance (up to 1.2s), and that bound is a TIMER, so it only preempts
|
|
@@ -290,4 +290,16 @@ export declare function refreshCachedSession(options?: Pick<EnsureCognitoTokenOp
|
|
|
290
290
|
refreshed: boolean;
|
|
291
291
|
reason?: string;
|
|
292
292
|
}>;
|
|
293
|
+
/**
|
|
294
|
+
* True on a Linux host with no terminal and no display — a container or
|
|
295
|
+
* server, where a browser sign-in can never be completed. macOS and Windows
|
|
296
|
+
* always have a way to open a browser, and a terminal means a person is there.
|
|
297
|
+
*/
|
|
298
|
+
export declare function isHeadlessHost(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: boolean): boolean;
|
|
299
|
+
/**
|
|
300
|
+
* True when the environment explicitly names an HQ agent identity. Only the
|
|
301
|
+
* explicit variables count: guessing from the filesystem is unreliable, and a
|
|
302
|
+
* wrong guess would take an ordinary headless run off its sign-in path.
|
|
303
|
+
*/
|
|
304
|
+
export declare function hostRunsAnAgent(env?: NodeJS.ProcessEnv): boolean;
|
|
293
305
|
//# sourceMappingURL=cognito-session.d.ts.map
|
|
@@ -943,6 +943,15 @@ async function ensureCognitoTokenUnserialized(options) {
|
|
|
943
943
|
if (!interactive) {
|
|
944
944
|
throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
|
|
945
945
|
}
|
|
946
|
+
if (isHeadlessHost() && hostRunsAnAgent()) {
|
|
947
|
+
// An agent host with no display has no one to complete a browser sign-in,
|
|
948
|
+
// which then blocks until something kills the process. Fail with the fix
|
|
949
|
+
// instead. Scoped to agent hosts so an ordinary headless run keeps the
|
|
950
|
+
// existing sign-in and its remedies.
|
|
951
|
+
throw new Error("No valid HQ session, and this host has no display for a browser sign-in. " +
|
|
952
|
+
"If this is an enrolled agent, set HQ_AGENT_DIR to its agent directory " +
|
|
953
|
+
"(or HQ_MACHINE_CREDS_FILE to its machine-creds.json). Otherwise run `hq login` on a computer with a browser.");
|
|
954
|
+
}
|
|
946
955
|
console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
|
|
947
956
|
const tokens = await browserLogin(DEFAULT_COGNITO);
|
|
948
957
|
return tokens.accessToken;
|
|
@@ -1010,6 +1019,13 @@ async function ensureCognitoIdTokenUnserialized(options) {
|
|
|
1010
1019
|
if (!interactive) {
|
|
1011
1020
|
throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
|
|
1012
1021
|
}
|
|
1022
|
+
if (isHeadlessHost() && hostRunsAnAgent()) {
|
|
1023
|
+
// A browser sign-in on a server has no one to complete it and blocks
|
|
1024
|
+
// until something kills the process. Fail with the fix instead.
|
|
1025
|
+
throw new Error("No valid HQ session, and this host has no display for a browser sign-in. " +
|
|
1026
|
+
"If this is an enrolled agent, set HQ_AGENT_DIR to its agent directory " +
|
|
1027
|
+
"(or HQ_MACHINE_CREDS_FILE to its machine-creds.json). Otherwise run `hq login` on a computer with a browser.");
|
|
1028
|
+
}
|
|
1013
1029
|
console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
|
|
1014
1030
|
const tokens = await browserLogin(DEFAULT_COGNITO);
|
|
1015
1031
|
return tokens.idToken;
|
|
@@ -1121,4 +1137,24 @@ async function refreshCachedSessionUnserialized(options) {
|
|
|
1121
1137
|
};
|
|
1122
1138
|
}
|
|
1123
1139
|
}
|
|
1140
|
+
/**
|
|
1141
|
+
* True on a Linux host with no terminal and no display — a container or
|
|
1142
|
+
* server, where a browser sign-in can never be completed. macOS and Windows
|
|
1143
|
+
* always have a way to open a browser, and a terminal means a person is there.
|
|
1144
|
+
*/
|
|
1145
|
+
export function isHeadlessHost(env = process.env, platform = process.platform, tty = Boolean(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY)) {
|
|
1146
|
+
if (platform !== "linux")
|
|
1147
|
+
return false;
|
|
1148
|
+
if (tty)
|
|
1149
|
+
return false;
|
|
1150
|
+
return !env.DISPLAY?.trim() && !env.WAYLAND_DISPLAY?.trim();
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* True when the environment explicitly names an HQ agent identity. Only the
|
|
1154
|
+
* explicit variables count: guessing from the filesystem is unreliable, and a
|
|
1155
|
+
* wrong guess would take an ordinary headless run off its sign-in path.
|
|
1156
|
+
*/
|
|
1157
|
+
export function hostRunsAnAgent(env = process.env) {
|
|
1158
|
+
return Boolean(env.HQ_AGENT_DIR?.trim() || env.HQ_MACHINE_CREDS_FILE?.trim());
|
|
1159
|
+
}
|
|
1124
1160
|
//# sourceMappingURL=cognito-session.js.map
|