@indigoai-us/hq-cli 5.65.0 → 5.67.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/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.js +147 -2
- package/dist/commands/outposts.d.ts +29 -0
- package/dist/commands/outposts.js +124 -20
- package/package.json +1 -1
- package/src/commands/agents.test.ts +105 -0
- package/src/commands/agents.ts +202 -0
- package/src/commands/outposts.test.ts +120 -0
- package/src/commands/outposts.ts +149 -17
|
@@ -126,5 +126,29 @@ export declare function deprovisionAgent(token: string, agentUid: string): Promi
|
|
|
126
126
|
}>;
|
|
127
127
|
/** Human-readable "hot-applied to the running box" vs "saved for next launch". */
|
|
128
128
|
export declare function appliedHint(applied: boolean): string;
|
|
129
|
+
/** One message in a DM thread, as returned by `GET /v1/notify/thread`. */
|
|
130
|
+
export interface DmThreadMessage {
|
|
131
|
+
eventId: string;
|
|
132
|
+
fromPersonUid: string;
|
|
133
|
+
fromDisplayName?: string;
|
|
134
|
+
fromEmail?: string;
|
|
135
|
+
body: string;
|
|
136
|
+
createdAt: string;
|
|
137
|
+
/** "out" = sent by the caller; "in" = received (the agent's reply). */
|
|
138
|
+
direction: "in" | "out";
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Resolve an agent reference to its `agt_` uid. An `agt_…` value is used
|
|
142
|
+
* directly; anything else is treated as a slug or display name and resolved
|
|
143
|
+
* against the company's roster (requires `--company` or a single active
|
|
144
|
+
* membership).
|
|
145
|
+
*/
|
|
146
|
+
export declare function resolveAgentUid(token: string, ref: string, companySlug: string | undefined): Promise<string>;
|
|
147
|
+
/** Send a DM to an agent. Returns nothing meaningful beyond success. */
|
|
148
|
+
export declare function sendAgentDm(token: string, agentUid: string, message: string): Promise<void>;
|
|
149
|
+
/** Read the two-way DM conversation with an agent (most recent `limit`). */
|
|
150
|
+
export declare function readAgentThread(token: string, agentUid: string, limit?: number): Promise<DmThreadMessage[]>;
|
|
151
|
+
/** Render one thread message as a labeled line for the terminal. */
|
|
152
|
+
export declare function formatThreadMessage(m: DmThreadMessage, agentLabel: string): string;
|
|
129
153
|
export declare function registerAgentsCommand(program: Command): void;
|
|
130
154
|
//# sourceMappingURL=agents.d.ts.map
|
package/dist/commands/agents.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* the caller's single active membership (same as `members.ts`).
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
25
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="26009666-ef42-5f7d-88eb-6452eca4b47a")}catch(e){}}();
|
|
26
26
|
import chalk from "chalk";
|
|
27
27
|
import { randomUUID } from "node:crypto";
|
|
28
28
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
@@ -144,6 +144,69 @@ export function appliedHint(applied) {
|
|
|
144
144
|
: "saved — takes effect on the agent's next launch";
|
|
145
145
|
}
|
|
146
146
|
// ---------------------------------------------------------------------------
|
|
147
|
+
// Talking to agents (DM). Agents are members addressable over HQ's DM system:
|
|
148
|
+
// a message rides `POST /v1/notify/dm` (the wire contract accepts `agt_*`), the
|
|
149
|
+
// agent's box picks it up and replies over DM (a first-class agent reply
|
|
150
|
+
// channel), and the two-way conversation is read back via `GET /v1/notify/thread`.
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
/** Agent uid shape (`agt_…`). */
|
|
153
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
|
|
154
|
+
/**
|
|
155
|
+
* Resolve an agent reference to its `agt_` uid. An `agt_…` value is used
|
|
156
|
+
* directly; anything else is treated as a slug or display name and resolved
|
|
157
|
+
* against the company's roster (requires `--company` or a single active
|
|
158
|
+
* membership).
|
|
159
|
+
*/
|
|
160
|
+
export async function resolveAgentUid(token, ref, companySlug) {
|
|
161
|
+
if (AGENT_UID_PATTERN.test(ref.trim()))
|
|
162
|
+
return ref.trim();
|
|
163
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
164
|
+
const roster = await listAgents(token, companyUid);
|
|
165
|
+
const needle = ref.trim().toLowerCase();
|
|
166
|
+
const match = roster.find((a) => a.slug?.toLowerCase() === needle ||
|
|
167
|
+
a.name?.toLowerCase() === needle ||
|
|
168
|
+
a.uid === ref.trim());
|
|
169
|
+
if (!match) {
|
|
170
|
+
throw new AgentsHttpError(404, `No agent named "${ref}" in this company. Run \`hq agents list\` to see slugs/uids, ` +
|
|
171
|
+
`or pass the agt_ uid directly.`);
|
|
172
|
+
}
|
|
173
|
+
return match.uid;
|
|
174
|
+
}
|
|
175
|
+
/** Send a DM to an agent. Returns nothing meaningful beyond success. */
|
|
176
|
+
export async function sendAgentDm(token, agentUid, message) {
|
|
177
|
+
await agentsRequest({
|
|
178
|
+
token,
|
|
179
|
+
path: "/v1/notify/dm",
|
|
180
|
+
method: "POST",
|
|
181
|
+
body: { toPersonUid: agentUid, body: message },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Read the two-way DM conversation with an agent (most recent `limit`). */
|
|
185
|
+
export async function readAgentThread(token, agentUid, limit = 20) {
|
|
186
|
+
const data = await agentsRequest({
|
|
187
|
+
token,
|
|
188
|
+
path: "/v1/notify/thread",
|
|
189
|
+
query: { withPersonUid: agentUid, limit: String(limit) },
|
|
190
|
+
});
|
|
191
|
+
return data.messages ?? [];
|
|
192
|
+
}
|
|
193
|
+
/** Render one thread message as a labeled line for the terminal. */
|
|
194
|
+
export function formatThreadMessage(m, agentLabel) {
|
|
195
|
+
const who = m.direction === "out"
|
|
196
|
+
? chalk.cyan("you")
|
|
197
|
+
: chalk.magenta(m.fromDisplayName || agentLabel);
|
|
198
|
+
const when = chalk.dim(shortTime(m.createdAt));
|
|
199
|
+
return `${who} ${when}\n ${m.body.replace(/\n/g, "\n ")}`;
|
|
200
|
+
}
|
|
201
|
+
function shortTime(iso) {
|
|
202
|
+
// Keep it dependency-free and stable: trim the ISO string to `MM-DD HH:MM`.
|
|
203
|
+
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
|
|
204
|
+
return m ? `${m[2]}-${m[3]} ${m[4]}` : iso;
|
|
205
|
+
}
|
|
206
|
+
function sleep(ms) {
|
|
207
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
208
|
+
}
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
147
210
|
// Command registration
|
|
148
211
|
// ---------------------------------------------------------------------------
|
|
149
212
|
function fail(err) {
|
|
@@ -165,6 +228,88 @@ export function registerAgentsCommand(program) {
|
|
|
165
228
|
// `hq agents --company acme list`.
|
|
166
229
|
const companyOf = (sub) => sub.opts().company ??
|
|
167
230
|
agents.opts().company;
|
|
231
|
+
agents
|
|
232
|
+
.command("message <agent> <message...>")
|
|
233
|
+
.alias("msg")
|
|
234
|
+
.description("Send a message to an agent and wait for its reply")
|
|
235
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
236
|
+
.option("--no-wait", "Send without waiting for the agent's reply")
|
|
237
|
+
.option("--timeout <seconds>", "How long to wait for a reply", "90")
|
|
238
|
+
.option("--json", "Emit raw JSON")
|
|
239
|
+
.action(async function (agent, messageParts, opts) {
|
|
240
|
+
try {
|
|
241
|
+
const message = messageParts.join(" ").trim();
|
|
242
|
+
if (!message) {
|
|
243
|
+
console.error(chalk.red("No message given. Usage: hq agents message <agent> <text>"));
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const token = await ensureCognitoToken();
|
|
247
|
+
const agentUid = await resolveAgentUid(token, agent, companyOf(this));
|
|
248
|
+
// Baseline the conversation so we can detect the agent's NEW reply.
|
|
249
|
+
const before = opts.wait === false ? [] : await readAgentThread(token, agentUid, 1);
|
|
250
|
+
const lastBeforeAt = before.length ? before[before.length - 1].createdAt : "";
|
|
251
|
+
await sendAgentDm(token, agentUid, message);
|
|
252
|
+
if (opts.wait === false) {
|
|
253
|
+
console.log(chalk.green(`Sent to ${agent}.`));
|
|
254
|
+
console.log(chalk.dim(`Read replies later: hq agents thread ${agent}`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Poll the thread until a new inbound (agent) message appears or timeout.
|
|
258
|
+
const timeoutMs = Math.max(5, Number(opts.timeout) || 90) * 1000;
|
|
259
|
+
const deadline = Date.now() + timeoutMs;
|
|
260
|
+
if (!opts.json)
|
|
261
|
+
console.log(chalk.dim(`Sent — waiting for ${agent} to reply…`));
|
|
262
|
+
let reply;
|
|
263
|
+
while (Date.now() < deadline) {
|
|
264
|
+
await sleep(3000);
|
|
265
|
+
const msgs = await readAgentThread(token, agentUid, 10);
|
|
266
|
+
reply = msgs.find((m) => m.direction === "in" && m.createdAt > lastBeforeAt);
|
|
267
|
+
if (reply)
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
if (opts.json) {
|
|
271
|
+
process.stdout.write(JSON.stringify({ agentUid, sent: message, reply: reply ?? null }, null, 2) + "\n");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (reply) {
|
|
275
|
+
console.log(formatThreadMessage(reply, agent));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
console.log(chalk.yellow(`No reply within ${Math.round(timeoutMs / 1000)}s. The agent may still respond — ` +
|
|
279
|
+
`check with: hq agents thread ${agent}`));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
fail(err);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
agents
|
|
287
|
+
.command("thread <agent>")
|
|
288
|
+
.alias("history")
|
|
289
|
+
.description("Show your recent conversation with an agent")
|
|
290
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
291
|
+
.option("--limit <n>", "How many recent messages to show", "20")
|
|
292
|
+
.option("--json", "Emit raw JSON")
|
|
293
|
+
.action(async function (agent, opts) {
|
|
294
|
+
try {
|
|
295
|
+
const token = await ensureCognitoToken();
|
|
296
|
+
const agentUid = await resolveAgentUid(token, agent, companyOf(this));
|
|
297
|
+
const limit = Math.max(1, Number(opts.limit) || 20);
|
|
298
|
+
const msgs = await readAgentThread(token, agentUid, limit);
|
|
299
|
+
if (opts.json) {
|
|
300
|
+
process.stdout.write(JSON.stringify(msgs, null, 2) + "\n");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (msgs.length === 0) {
|
|
304
|
+
console.log(chalk.gray(`No conversation yet with ${agent}. Start one: hq agents message ${agent} "<text>"`));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
console.log(msgs.map((m) => formatThreadMessage(m, agent)).join("\n\n"));
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
fail(err);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
168
313
|
agents
|
|
169
314
|
.command("provision <name>")
|
|
170
315
|
.alias("new")
|
|
@@ -476,4 +621,4 @@ export function registerAgentsCommand(program) {
|
|
|
476
621
|
});
|
|
477
622
|
}
|
|
478
623
|
//# sourceMappingURL=agents.js.map
|
|
479
|
-
//# debugId=
|
|
624
|
+
//# debugId=26009666-ef42-5f7d-88eb-6452eca4b47a
|
|
@@ -94,5 +94,34 @@ export interface OutpostExecResult {
|
|
|
94
94
|
* `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
|
|
95
95
|
*/
|
|
96
96
|
export declare function execOutpost(token: string, command: string, outpostId?: string): Promise<OutpostExecResult>;
|
|
97
|
+
/** SSH connection details vended by `POST /outpost/ssh-access`. */
|
|
98
|
+
export interface OutpostSshAccess {
|
|
99
|
+
outpostId: string;
|
|
100
|
+
platform: "ec2" | "lightsail";
|
|
101
|
+
host: string;
|
|
102
|
+
port: number;
|
|
103
|
+
username: string;
|
|
104
|
+
/** PEM private key for the box. SENSITIVE — never printed or logged. */
|
|
105
|
+
privateKey: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fetch SSH connection info + key for the caller's Outpost and open the caller's
|
|
109
|
+
* IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
|
|
110
|
+
* `OutpostHttpError` on a non-2xx.
|
|
111
|
+
*/
|
|
112
|
+
export declare function getOutpostSshAccess(token: string, outpostId?: string): Promise<OutpostSshAccess>;
|
|
113
|
+
/**
|
|
114
|
+
* Run `command` on the box over SSH using vended access details. Writes the
|
|
115
|
+
* private key to a locked-down temp file, runs a non-interactive `ssh`, and
|
|
116
|
+
* returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
|
|
117
|
+
* are always cleaned up; the key is never printed. `exitCode` is `null` only
|
|
118
|
+
* when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
|
|
119
|
+
*/
|
|
120
|
+
export declare function execViaSsh(access: OutpostSshAccess, command: string): {
|
|
121
|
+
stdout: string;
|
|
122
|
+
stderr: string;
|
|
123
|
+
exitCode: number | null;
|
|
124
|
+
error?: string;
|
|
125
|
+
};
|
|
97
126
|
export declare function registerOutpostsCommand(program: Command): void;
|
|
98
127
|
//# sourceMappingURL=outposts.d.ts.map
|
|
@@ -21,8 +21,13 @@
|
|
|
21
21
|
* and status routes that exist. Renaming an Outpost is not a backend capability.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
24
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="951a0bc6-7dc3-5b10-b8f6-728df1074817")}catch(e){}}();
|
|
25
25
|
import chalk from "chalk";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
26
31
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
27
32
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
28
33
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
@@ -133,6 +138,72 @@ export async function execOutpost(token, command, outpostId) {
|
|
|
133
138
|
query: outpostId ? { outpostId } : undefined,
|
|
134
139
|
});
|
|
135
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Fetch SSH connection info + key for the caller's Outpost and open the caller's
|
|
143
|
+
* IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
|
|
144
|
+
* `OutpostHttpError` on a non-2xx.
|
|
145
|
+
*/
|
|
146
|
+
export async function getOutpostSshAccess(token, outpostId) {
|
|
147
|
+
return outpostRequest({
|
|
148
|
+
token,
|
|
149
|
+
path: "/outpost/ssh-access",
|
|
150
|
+
method: "POST",
|
|
151
|
+
body: {},
|
|
152
|
+
query: outpostId ? { outpostId } : undefined,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run `command` on the box over SSH using vended access details. Writes the
|
|
157
|
+
* private key to a locked-down temp file, runs a non-interactive `ssh`, and
|
|
158
|
+
* returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
|
|
159
|
+
* are always cleaned up; the key is never printed. `exitCode` is `null` only
|
|
160
|
+
* when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
|
|
161
|
+
*/
|
|
162
|
+
export function execViaSsh(access, command) {
|
|
163
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
|
|
164
|
+
const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
|
|
165
|
+
const knownHosts = path.join(dir, "known_hosts");
|
|
166
|
+
try {
|
|
167
|
+
fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
|
|
168
|
+
mode: 0o600,
|
|
169
|
+
});
|
|
170
|
+
const result = spawnSync("ssh", [
|
|
171
|
+
"-i",
|
|
172
|
+
keyPath,
|
|
173
|
+
"-p",
|
|
174
|
+
String(access.port),
|
|
175
|
+
"-o",
|
|
176
|
+
"BatchMode=yes",
|
|
177
|
+
"-o",
|
|
178
|
+
"StrictHostKeyChecking=accept-new",
|
|
179
|
+
"-o",
|
|
180
|
+
`UserKnownHostsFile=${knownHosts}`,
|
|
181
|
+
"-o",
|
|
182
|
+
"ConnectTimeout=15",
|
|
183
|
+
"-o",
|
|
184
|
+
"LogLevel=ERROR",
|
|
185
|
+
`${access.username}@${access.host}`,
|
|
186
|
+
command,
|
|
187
|
+
], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
188
|
+
if (result.error) {
|
|
189
|
+
const hint = result.error.code === "ENOENT"
|
|
190
|
+
? "the `ssh` client is not installed or not on PATH"
|
|
191
|
+
: result.error.message;
|
|
192
|
+
return { stdout: "", stderr: "", exitCode: null, error: hint };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
stdout: result.stdout ?? "",
|
|
196
|
+
stderr: result.stderr ?? "",
|
|
197
|
+
exitCode: result.status,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function ensureTrailingNewline(s) {
|
|
205
|
+
return s.endsWith("\n") ? s : s + "\n";
|
|
206
|
+
}
|
|
136
207
|
// ---------------------------------------------------------------------------
|
|
137
208
|
// Command registration
|
|
138
209
|
// ---------------------------------------------------------------------------
|
|
@@ -294,27 +365,60 @@ export function registerOutpostsCommand(program) {
|
|
|
294
365
|
process.exit(1);
|
|
295
366
|
}
|
|
296
367
|
const token = await ensureCognitoToken();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
368
|
+
try {
|
|
369
|
+
const result = await execOutpost(token, command, opts.id);
|
|
370
|
+
if (opts.json) {
|
|
371
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// Stream the remote streams to ours so the command feels local.
|
|
375
|
+
if (result.stdout)
|
|
376
|
+
process.stdout.write(result.stdout);
|
|
377
|
+
if (result.stderr)
|
|
378
|
+
process.stderr.write(result.stderr);
|
|
379
|
+
if (result.truncated) {
|
|
380
|
+
console.error(chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"));
|
|
381
|
+
}
|
|
382
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
383
|
+
console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
|
|
384
|
+
}
|
|
310
385
|
}
|
|
311
|
-
|
|
312
|
-
|
|
386
|
+
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
387
|
+
process.exitCode =
|
|
388
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
// Lightsail boxes have no SSM agent — transparently fall back to SSH.
|
|
392
|
+
if (err instanceof OutpostHttpError &&
|
|
393
|
+
err.step === "platform-unsupported") {
|
|
394
|
+
const access = await getOutpostSshAccess(token, opts.id);
|
|
395
|
+
const ssh = execViaSsh(access, command);
|
|
396
|
+
if (ssh.error) {
|
|
397
|
+
console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
if (opts.json) {
|
|
401
|
+
process.stdout.write(JSON.stringify({
|
|
402
|
+
via: "ssh",
|
|
403
|
+
platform: access.platform,
|
|
404
|
+
host: access.host,
|
|
405
|
+
exitCode: ssh.exitCode,
|
|
406
|
+
stdout: ssh.stdout,
|
|
407
|
+
stderr: ssh.stderr,
|
|
408
|
+
}, null, 2) + "\n");
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
if (ssh.stdout)
|
|
412
|
+
process.stdout.write(ssh.stdout);
|
|
413
|
+
if (ssh.stderr)
|
|
414
|
+
process.stderr.write(ssh.stderr);
|
|
415
|
+
}
|
|
416
|
+
process.exitCode =
|
|
417
|
+
typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
|
|
418
|
+
return;
|
|
313
419
|
}
|
|
420
|
+
throw err;
|
|
314
421
|
}
|
|
315
|
-
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
316
|
-
process.exitCode =
|
|
317
|
-
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
318
422
|
}
|
|
319
423
|
catch (err) {
|
|
320
424
|
fail(err);
|
|
@@ -392,4 +496,4 @@ export function registerOutpostsCommand(program) {
|
|
|
392
496
|
});
|
|
393
497
|
}
|
|
394
498
|
//# sourceMappingURL=outposts.js.map
|
|
395
|
-
//# debugId=
|
|
499
|
+
//# debugId=951a0bc6-7dc3-5b10-b8f6-728df1074817
|
package/package.json
CHANGED
|
@@ -379,3 +379,108 @@ describe("hq agents provision (billing gate)", () => {
|
|
|
379
379
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
380
380
|
});
|
|
381
381
|
});
|
|
382
|
+
|
|
383
|
+
describe("hq agents talk (message / thread)", () => {
|
|
384
|
+
function agentRow(uid: string, slug: string, name: string) {
|
|
385
|
+
return { uid, slug, name, companyUid: "cmp_acme" };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
it("resolves an agent by slug and reads the thread", async () => {
|
|
389
|
+
// 1) roster lookup for slug→uid, 2) thread read
|
|
390
|
+
fetchSpy
|
|
391
|
+
.mockResolvedValueOnce(
|
|
392
|
+
jsonResponse(200, { agents: [agentRow("agt_deacon", "deacon", "Deacon")] }),
|
|
393
|
+
)
|
|
394
|
+
.mockResolvedValueOnce(
|
|
395
|
+
jsonResponse(200, {
|
|
396
|
+
messages: [
|
|
397
|
+
{
|
|
398
|
+
eventId: "e1",
|
|
399
|
+
fromPersonUid: "prs_me",
|
|
400
|
+
body: "hi",
|
|
401
|
+
createdAt: "2026-07-13T10:00",
|
|
402
|
+
direction: "out",
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
eventId: "e2",
|
|
406
|
+
fromPersonUid: "agt_deacon",
|
|
407
|
+
fromDisplayName: "Deacon",
|
|
408
|
+
body: "hello back",
|
|
409
|
+
createdAt: "2026-07-13T10:01",
|
|
410
|
+
direction: "in",
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const logs: string[] = [];
|
|
417
|
+
vi.spyOn(console, "log").mockImplementation((...a) => {
|
|
418
|
+
logs.push(a.map(String).join(" "));
|
|
419
|
+
});
|
|
420
|
+
await run(["agents", "--company", "acme", "thread", "deacon"]);
|
|
421
|
+
|
|
422
|
+
// roster call then thread call
|
|
423
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/agents");
|
|
424
|
+
const threadUrl = String(fetchSpy.mock.calls[1][0]);
|
|
425
|
+
expect(threadUrl).toContain("/v1/notify/thread");
|
|
426
|
+
expect(threadUrl).toContain("withPersonUid=agt_deacon");
|
|
427
|
+
const out = logs.join("\n");
|
|
428
|
+
expect(out).toContain("hello back");
|
|
429
|
+
expect(out).toContain("hi");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("passes an agt_ uid straight through (no roster lookup)", async () => {
|
|
433
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { messages: [] }));
|
|
434
|
+
await run(["agents", "thread", "agt_xyz"]);
|
|
435
|
+
// Only the thread call — no /v1/agents roster fetch.
|
|
436
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
437
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("withPersonUid=agt_xyz");
|
|
438
|
+
expect(mockGetCompanyUid).not.toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("message --no-wait POSTs the DM and does not poll", async () => {
|
|
442
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true })); // POST /v1/notify/dm
|
|
443
|
+
await run(["agents", "message", "agt_xyz", "--no-wait", "hello", "there"]);
|
|
444
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
445
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
446
|
+
expect(String(url)).toContain("/v1/notify/dm");
|
|
447
|
+
expect(init?.method).toBe("POST");
|
|
448
|
+
expect(JSON.parse(init?.body as string)).toEqual({
|
|
449
|
+
toPersonUid: "agt_xyz",
|
|
450
|
+
body: "hello there",
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("message waits for and prints the agent's reply", async () => {
|
|
455
|
+
fetchSpy
|
|
456
|
+
.mockResolvedValueOnce(jsonResponse(200, { messages: [] })) // baseline read
|
|
457
|
+
.mockResolvedValueOnce(jsonResponse(200, { ok: true })) // POST dm
|
|
458
|
+
.mockResolvedValueOnce(
|
|
459
|
+
jsonResponse(200, {
|
|
460
|
+
messages: [
|
|
461
|
+
{
|
|
462
|
+
eventId: "r1",
|
|
463
|
+
fromPersonUid: "agt_xyz",
|
|
464
|
+
fromDisplayName: "Xyz",
|
|
465
|
+
body: "on it",
|
|
466
|
+
createdAt: "2026-07-13T11:00",
|
|
467
|
+
direction: "in",
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
}),
|
|
471
|
+
); // first poll returns the reply
|
|
472
|
+
|
|
473
|
+
const logs: string[] = [];
|
|
474
|
+
vi.spyOn(console, "log").mockImplementation((...a) => {
|
|
475
|
+
logs.push(a.map(String).join(" "));
|
|
476
|
+
});
|
|
477
|
+
await run(["agents", "message", "agt_xyz", "--timeout", "10", "ping"]);
|
|
478
|
+
expect(logs.join("\n")).toContain("on it");
|
|
479
|
+
}, 10000);
|
|
480
|
+
|
|
481
|
+
it("requires a non-empty message", async () => {
|
|
482
|
+
await expect(
|
|
483
|
+
run(["agents", "message", "agt_xyz", " "]),
|
|
484
|
+
).rejects.toThrow("process.exit(1)");
|
|
485
|
+
});
|
|
486
|
+
});
|
package/src/commands/agents.ts
CHANGED
|
@@ -265,6 +265,107 @@ export function appliedHint(applied: boolean): string {
|
|
|
265
265
|
: "saved — takes effect on the agent's next launch";
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Talking to agents (DM). Agents are members addressable over HQ's DM system:
|
|
270
|
+
// a message rides `POST /v1/notify/dm` (the wire contract accepts `agt_*`), the
|
|
271
|
+
// agent's box picks it up and replies over DM (a first-class agent reply
|
|
272
|
+
// channel), and the two-way conversation is read back via `GET /v1/notify/thread`.
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/** Agent uid shape (`agt_…`). */
|
|
276
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
|
|
277
|
+
|
|
278
|
+
/** One message in a DM thread, as returned by `GET /v1/notify/thread`. */
|
|
279
|
+
export interface DmThreadMessage {
|
|
280
|
+
eventId: string;
|
|
281
|
+
fromPersonUid: string;
|
|
282
|
+
fromDisplayName?: string;
|
|
283
|
+
fromEmail?: string;
|
|
284
|
+
body: string;
|
|
285
|
+
createdAt: string;
|
|
286
|
+
/** "out" = sent by the caller; "in" = received (the agent's reply). */
|
|
287
|
+
direction: "in" | "out";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolve an agent reference to its `agt_` uid. An `agt_…` value is used
|
|
292
|
+
* directly; anything else is treated as a slug or display name and resolved
|
|
293
|
+
* against the company's roster (requires `--company` or a single active
|
|
294
|
+
* membership).
|
|
295
|
+
*/
|
|
296
|
+
export async function resolveAgentUid(
|
|
297
|
+
token: string,
|
|
298
|
+
ref: string,
|
|
299
|
+
companySlug: string | undefined,
|
|
300
|
+
): Promise<string> {
|
|
301
|
+
if (AGENT_UID_PATTERN.test(ref.trim())) return ref.trim();
|
|
302
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
303
|
+
const roster = await listAgents(token, companyUid);
|
|
304
|
+
const needle = ref.trim().toLowerCase();
|
|
305
|
+
const match = roster.find(
|
|
306
|
+
(a) =>
|
|
307
|
+
a.slug?.toLowerCase() === needle ||
|
|
308
|
+
a.name?.toLowerCase() === needle ||
|
|
309
|
+
a.uid === ref.trim(),
|
|
310
|
+
);
|
|
311
|
+
if (!match) {
|
|
312
|
+
throw new AgentsHttpError(
|
|
313
|
+
404,
|
|
314
|
+
`No agent named "${ref}" in this company. Run \`hq agents list\` to see slugs/uids, ` +
|
|
315
|
+
`or pass the agt_ uid directly.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return match.uid;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Send a DM to an agent. Returns nothing meaningful beyond success. */
|
|
322
|
+
export async function sendAgentDm(
|
|
323
|
+
token: string,
|
|
324
|
+
agentUid: string,
|
|
325
|
+
message: string,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
await agentsRequest({
|
|
328
|
+
token,
|
|
329
|
+
path: "/v1/notify/dm",
|
|
330
|
+
method: "POST",
|
|
331
|
+
body: { toPersonUid: agentUid, body: message },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Read the two-way DM conversation with an agent (most recent `limit`). */
|
|
336
|
+
export async function readAgentThread(
|
|
337
|
+
token: string,
|
|
338
|
+
agentUid: string,
|
|
339
|
+
limit = 20,
|
|
340
|
+
): Promise<DmThreadMessage[]> {
|
|
341
|
+
const data = await agentsRequest<{ messages: DmThreadMessage[] }>({
|
|
342
|
+
token,
|
|
343
|
+
path: "/v1/notify/thread",
|
|
344
|
+
query: { withPersonUid: agentUid, limit: String(limit) },
|
|
345
|
+
});
|
|
346
|
+
return data.messages ?? [];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Render one thread message as a labeled line for the terminal. */
|
|
350
|
+
export function formatThreadMessage(m: DmThreadMessage, agentLabel: string): string {
|
|
351
|
+
const who =
|
|
352
|
+
m.direction === "out"
|
|
353
|
+
? chalk.cyan("you")
|
|
354
|
+
: chalk.magenta(m.fromDisplayName || agentLabel);
|
|
355
|
+
const when = chalk.dim(shortTime(m.createdAt));
|
|
356
|
+
return `${who} ${when}\n ${m.body.replace(/\n/g, "\n ")}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function shortTime(iso: string): string {
|
|
360
|
+
// Keep it dependency-free and stable: trim the ISO string to `MM-DD HH:MM`.
|
|
361
|
+
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
|
|
362
|
+
return m ? `${m[2]}-${m[3]} ${m[4]}` : iso;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function sleep(ms: number): Promise<void> {
|
|
366
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
367
|
+
}
|
|
368
|
+
|
|
268
369
|
// ---------------------------------------------------------------------------
|
|
269
370
|
// Command registration
|
|
270
371
|
// ---------------------------------------------------------------------------
|
|
@@ -294,6 +395,107 @@ export function registerAgentsCommand(program: Command): void {
|
|
|
294
395
|
(sub.opts().company as string | undefined) ??
|
|
295
396
|
(agents.opts().company as string | undefined);
|
|
296
397
|
|
|
398
|
+
agents
|
|
399
|
+
.command("message <agent> <message...>")
|
|
400
|
+
.alias("msg")
|
|
401
|
+
.description("Send a message to an agent and wait for its reply")
|
|
402
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
403
|
+
.option("--no-wait", "Send without waiting for the agent's reply")
|
|
404
|
+
.option("--timeout <seconds>", "How long to wait for a reply", "90")
|
|
405
|
+
.option("--json", "Emit raw JSON")
|
|
406
|
+
.action(async function (
|
|
407
|
+
this: Command,
|
|
408
|
+
agent: string,
|
|
409
|
+
messageParts: string[],
|
|
410
|
+
opts: { company?: string; wait?: boolean; timeout?: string; json?: boolean },
|
|
411
|
+
) {
|
|
412
|
+
try {
|
|
413
|
+
const message = messageParts.join(" ").trim();
|
|
414
|
+
if (!message) {
|
|
415
|
+
console.error(chalk.red("No message given. Usage: hq agents message <agent> <text>"));
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
const token = await ensureCognitoToken();
|
|
419
|
+
const agentUid = await resolveAgentUid(token, agent, companyOf(this));
|
|
420
|
+
|
|
421
|
+
// Baseline the conversation so we can detect the agent's NEW reply.
|
|
422
|
+
const before = opts.wait === false ? [] : await readAgentThread(token, agentUid, 1);
|
|
423
|
+
const lastBeforeAt = before.length ? before[before.length - 1].createdAt : "";
|
|
424
|
+
|
|
425
|
+
await sendAgentDm(token, agentUid, message);
|
|
426
|
+
|
|
427
|
+
if (opts.wait === false) {
|
|
428
|
+
console.log(chalk.green(`Sent to ${agent}.`));
|
|
429
|
+
console.log(chalk.dim(`Read replies later: hq agents thread ${agent}`));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Poll the thread until a new inbound (agent) message appears or timeout.
|
|
434
|
+
const timeoutMs = Math.max(5, Number(opts.timeout) || 90) * 1000;
|
|
435
|
+
const deadline = Date.now() + timeoutMs;
|
|
436
|
+
if (!opts.json) console.log(chalk.dim(`Sent — waiting for ${agent} to reply…`));
|
|
437
|
+
let reply: DmThreadMessage | undefined;
|
|
438
|
+
while (Date.now() < deadline) {
|
|
439
|
+
await sleep(3000);
|
|
440
|
+
const msgs = await readAgentThread(token, agentUid, 10);
|
|
441
|
+
reply = msgs.find(
|
|
442
|
+
(m) => m.direction === "in" && m.createdAt > lastBeforeAt,
|
|
443
|
+
);
|
|
444
|
+
if (reply) break;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (opts.json) {
|
|
448
|
+
process.stdout.write(
|
|
449
|
+
JSON.stringify({ agentUid, sent: message, reply: reply ?? null }, null, 2) + "\n",
|
|
450
|
+
);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (reply) {
|
|
454
|
+
console.log(formatThreadMessage(reply, agent));
|
|
455
|
+
} else {
|
|
456
|
+
console.log(
|
|
457
|
+
chalk.yellow(
|
|
458
|
+
`No reply within ${Math.round(timeoutMs / 1000)}s. The agent may still respond — ` +
|
|
459
|
+
`check with: hq agents thread ${agent}`,
|
|
460
|
+
),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
} catch (err) {
|
|
464
|
+
fail(err);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
agents
|
|
469
|
+
.command("thread <agent>")
|
|
470
|
+
.alias("history")
|
|
471
|
+
.description("Show your recent conversation with an agent")
|
|
472
|
+
.option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
|
|
473
|
+
.option("--limit <n>", "How many recent messages to show", "20")
|
|
474
|
+
.option("--json", "Emit raw JSON")
|
|
475
|
+
.action(async function (
|
|
476
|
+
this: Command,
|
|
477
|
+
agent: string,
|
|
478
|
+
opts: { company?: string; limit?: string; json?: boolean },
|
|
479
|
+
) {
|
|
480
|
+
try {
|
|
481
|
+
const token = await ensureCognitoToken();
|
|
482
|
+
const agentUid = await resolveAgentUid(token, agent, companyOf(this));
|
|
483
|
+
const limit = Math.max(1, Number(opts.limit) || 20);
|
|
484
|
+
const msgs = await readAgentThread(token, agentUid, limit);
|
|
485
|
+
if (opts.json) {
|
|
486
|
+
process.stdout.write(JSON.stringify(msgs, null, 2) + "\n");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (msgs.length === 0) {
|
|
490
|
+
console.log(chalk.gray(`No conversation yet with ${agent}. Start one: hq agents message ${agent} "<text>"`));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
console.log(msgs.map((m) => formatThreadMessage(m, agent)).join("\n\n"));
|
|
494
|
+
} catch (err) {
|
|
495
|
+
fail(err);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
297
499
|
agents
|
|
298
500
|
.command("provision <name>")
|
|
299
501
|
.alias("new")
|
|
@@ -42,9 +42,20 @@ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
|
|
|
42
42
|
};
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
// The SSH fallback shells out to `ssh` via spawnSync — stub it so tests don't
|
|
46
|
+
// touch a real box or need the ssh binary.
|
|
47
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
48
|
+
const original =
|
|
49
|
+
await importOriginal<typeof import("node:child_process")>();
|
|
50
|
+
return { ...original, spawnSync: vi.fn() };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
import { spawnSync } from "node:child_process";
|
|
45
54
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
46
55
|
import { registerOutpostsCommand } from "./outposts.js";
|
|
47
56
|
|
|
57
|
+
const mockSpawnSync = vi.mocked(spawnSync);
|
|
58
|
+
|
|
48
59
|
function jsonResponse(status: number, body: unknown): Response {
|
|
49
60
|
return new Response(JSON.stringify(body), {
|
|
50
61
|
status,
|
|
@@ -354,3 +365,112 @@ describe("hq outposts exec", () => {
|
|
|
354
365
|
expect(printed).toContain('"commandId": "cmd-3"');
|
|
355
366
|
});
|
|
356
367
|
});
|
|
368
|
+
|
|
369
|
+
describe("hq outposts exec — Lightsail SSH fallback", () => {
|
|
370
|
+
afterEach(() => {
|
|
371
|
+
process.exitCode = undefined;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
function platformUnsupported(): Response {
|
|
375
|
+
return jsonResponse(409, {
|
|
376
|
+
error: true,
|
|
377
|
+
step: "platform-unsupported",
|
|
378
|
+
message: "remote exec requires the EC2 platform",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sshAccess(): Response {
|
|
383
|
+
return jsonResponse(200, {
|
|
384
|
+
ok: true,
|
|
385
|
+
outpostId: "primary",
|
|
386
|
+
platform: "lightsail",
|
|
387
|
+
host: "52.2.2.2",
|
|
388
|
+
port: 22,
|
|
389
|
+
username: "ec2-user",
|
|
390
|
+
privateKey: "-----BEGIN KEY-----\nx\n-----END KEY-----\n",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
it("falls back to SSH when the box is Lightsail, streams stdout, propagates exit", async () => {
|
|
395
|
+
const stdoutSpy = vi
|
|
396
|
+
.spyOn(process.stdout, "write")
|
|
397
|
+
.mockImplementation(() => true);
|
|
398
|
+
fetchSpy
|
|
399
|
+
.mockResolvedValueOnce(platformUnsupported()) // /outpost/exec (SSM) → 409
|
|
400
|
+
.mockResolvedValueOnce(sshAccess()); // /outpost/ssh-access → 200
|
|
401
|
+
mockSpawnSync.mockReturnValue({
|
|
402
|
+
status: 0,
|
|
403
|
+
stdout: "lightsail-out\n",
|
|
404
|
+
stderr: "",
|
|
405
|
+
signal: null,
|
|
406
|
+
output: [],
|
|
407
|
+
pid: 1,
|
|
408
|
+
} as unknown as ReturnType<typeof spawnSync>);
|
|
409
|
+
|
|
410
|
+
// Flags meant for the remote command go after `--` (same as the SSM path).
|
|
411
|
+
await run(["outposts", "exec", "--", "uname", "-a"]);
|
|
412
|
+
|
|
413
|
+
// Second HTTP call is the ssh-access mint.
|
|
414
|
+
expect(String(fetchSpy.mock.calls[1][0])).toContain("/outpost/ssh-access");
|
|
415
|
+
// ssh was invoked with the box coordinates and the command as the last arg.
|
|
416
|
+
const args = mockSpawnSync.mock.calls[0][1] as string[];
|
|
417
|
+
expect(mockSpawnSync.mock.calls[0][0]).toBe("ssh");
|
|
418
|
+
expect(args).toContain("ec2-user@52.2.2.2");
|
|
419
|
+
expect(args[args.length - 1]).toBe("uname -a");
|
|
420
|
+
expect(args).toContain("BatchMode=yes");
|
|
421
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
422
|
+
expect(printed).toContain("lightsail-out\n");
|
|
423
|
+
expect(process.exitCode).toBe(0);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("propagates a non-zero SSH exit code", async () => {
|
|
427
|
+
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
428
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
429
|
+
fetchSpy
|
|
430
|
+
.mockResolvedValueOnce(platformUnsupported())
|
|
431
|
+
.mockResolvedValueOnce(sshAccess());
|
|
432
|
+
mockSpawnSync.mockReturnValue({
|
|
433
|
+
status: 7,
|
|
434
|
+
stdout: "",
|
|
435
|
+
stderr: "boom\n",
|
|
436
|
+
signal: null,
|
|
437
|
+
output: [],
|
|
438
|
+
pid: 1,
|
|
439
|
+
} as unknown as ReturnType<typeof spawnSync>);
|
|
440
|
+
await run(["outposts", "exec", "false"]);
|
|
441
|
+
expect(process.exitCode).toBe(7);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("exits 1 with a clear message when the ssh client is missing", async () => {
|
|
445
|
+
fetchSpy
|
|
446
|
+
.mockResolvedValueOnce(platformUnsupported())
|
|
447
|
+
.mockResolvedValueOnce(sshAccess());
|
|
448
|
+
const enoent = Object.assign(new Error("spawnSync ssh ENOENT"), {
|
|
449
|
+
code: "ENOENT",
|
|
450
|
+
});
|
|
451
|
+
mockSpawnSync.mockReturnValue({
|
|
452
|
+
error: enoent,
|
|
453
|
+
status: null,
|
|
454
|
+
stdout: "",
|
|
455
|
+
stderr: "",
|
|
456
|
+
signal: null,
|
|
457
|
+
output: [],
|
|
458
|
+
pid: 0,
|
|
459
|
+
} as unknown as ReturnType<typeof spawnSync>);
|
|
460
|
+
await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
|
|
461
|
+
"process.exit(1)",
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("does NOT fall back for a non-platform error (e.g. not-ready)", async () => {
|
|
466
|
+
fetchSpy.mockResolvedValueOnce(
|
|
467
|
+
jsonResponse(409, { error: true, step: "not-ready", message: "not ready" }),
|
|
468
|
+
);
|
|
469
|
+
await expect(
|
|
470
|
+
run(["outposts", "exec", "echo", "hi"]),
|
|
471
|
+
).rejects.toThrow("process.exit(1)");
|
|
472
|
+
// Only the SSM call happened; no ssh-access mint, no ssh.
|
|
473
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
474
|
+
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
475
|
+
});
|
|
476
|
+
});
|
package/src/commands/outposts.ts
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
import { Command } from "commander";
|
|
25
25
|
import chalk from "chalk";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
26
31
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
27
32
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
28
33
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
@@ -222,6 +227,96 @@ export async function execOutpost(
|
|
|
222
227
|
});
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
/** SSH connection details vended by `POST /outpost/ssh-access`. */
|
|
231
|
+
export interface OutpostSshAccess {
|
|
232
|
+
outpostId: string;
|
|
233
|
+
platform: "ec2" | "lightsail";
|
|
234
|
+
host: string;
|
|
235
|
+
port: number;
|
|
236
|
+
username: string;
|
|
237
|
+
/** PEM private key for the box. SENSITIVE — never printed or logged. */
|
|
238
|
+
privateKey: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Fetch SSH connection info + key for the caller's Outpost and open the caller's
|
|
243
|
+
* IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
|
|
244
|
+
* `OutpostHttpError` on a non-2xx.
|
|
245
|
+
*/
|
|
246
|
+
export async function getOutpostSshAccess(
|
|
247
|
+
token: string,
|
|
248
|
+
outpostId?: string,
|
|
249
|
+
): Promise<OutpostSshAccess> {
|
|
250
|
+
return outpostRequest({
|
|
251
|
+
token,
|
|
252
|
+
path: "/outpost/ssh-access",
|
|
253
|
+
method: "POST",
|
|
254
|
+
body: {},
|
|
255
|
+
query: outpostId ? { outpostId } : undefined,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run `command` on the box over SSH using vended access details. Writes the
|
|
261
|
+
* private key to a locked-down temp file, runs a non-interactive `ssh`, and
|
|
262
|
+
* returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
|
|
263
|
+
* are always cleaned up; the key is never printed. `exitCode` is `null` only
|
|
264
|
+
* when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
|
|
265
|
+
*/
|
|
266
|
+
export function execViaSsh(
|
|
267
|
+
access: OutpostSshAccess,
|
|
268
|
+
command: string,
|
|
269
|
+
): { stdout: string; stderr: string; exitCode: number | null; error?: string } {
|
|
270
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
|
|
271
|
+
const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
|
|
272
|
+
const knownHosts = path.join(dir, "known_hosts");
|
|
273
|
+
try {
|
|
274
|
+
fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
|
|
275
|
+
mode: 0o600,
|
|
276
|
+
});
|
|
277
|
+
const result = spawnSync(
|
|
278
|
+
"ssh",
|
|
279
|
+
[
|
|
280
|
+
"-i",
|
|
281
|
+
keyPath,
|
|
282
|
+
"-p",
|
|
283
|
+
String(access.port),
|
|
284
|
+
"-o",
|
|
285
|
+
"BatchMode=yes",
|
|
286
|
+
"-o",
|
|
287
|
+
"StrictHostKeyChecking=accept-new",
|
|
288
|
+
"-o",
|
|
289
|
+
`UserKnownHostsFile=${knownHosts}`,
|
|
290
|
+
"-o",
|
|
291
|
+
"ConnectTimeout=15",
|
|
292
|
+
"-o",
|
|
293
|
+
"LogLevel=ERROR",
|
|
294
|
+
`${access.username}@${access.host}`,
|
|
295
|
+
command,
|
|
296
|
+
],
|
|
297
|
+
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
|
298
|
+
);
|
|
299
|
+
if (result.error) {
|
|
300
|
+
const hint =
|
|
301
|
+
(result.error as NodeJS.ErrnoException).code === "ENOENT"
|
|
302
|
+
? "the `ssh` client is not installed or not on PATH"
|
|
303
|
+
: result.error.message;
|
|
304
|
+
return { stdout: "", stderr: "", exitCode: null, error: hint };
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
stdout: result.stdout ?? "",
|
|
308
|
+
stderr: result.stderr ?? "",
|
|
309
|
+
exitCode: result.status,
|
|
310
|
+
};
|
|
311
|
+
} finally {
|
|
312
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function ensureTrailingNewline(s: string): string {
|
|
317
|
+
return s.endsWith("\n") ? s : s + "\n";
|
|
318
|
+
}
|
|
319
|
+
|
|
225
320
|
// ---------------------------------------------------------------------------
|
|
226
321
|
// Command registration
|
|
227
322
|
// ---------------------------------------------------------------------------
|
|
@@ -423,27 +518,64 @@ export function registerOutpostsCommand(program: Command): void {
|
|
|
423
518
|
process.exit(1);
|
|
424
519
|
}
|
|
425
520
|
const token = await ensureCognitoToken();
|
|
426
|
-
const result = await execOutpost(token, command, opts.id);
|
|
427
521
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
522
|
+
try {
|
|
523
|
+
const result = await execOutpost(token, command, opts.id);
|
|
524
|
+
if (opts.json) {
|
|
525
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
526
|
+
} else {
|
|
527
|
+
// Stream the remote streams to ours so the command feels local.
|
|
528
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
529
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
530
|
+
if (result.truncated) {
|
|
531
|
+
console.error(
|
|
532
|
+
chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
536
|
+
console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
|
|
537
|
+
}
|
|
439
538
|
}
|
|
440
|
-
|
|
441
|
-
|
|
539
|
+
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
540
|
+
process.exitCode =
|
|
541
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
// Lightsail boxes have no SSM agent — transparently fall back to SSH.
|
|
544
|
+
if (
|
|
545
|
+
err instanceof OutpostHttpError &&
|
|
546
|
+
err.step === "platform-unsupported"
|
|
547
|
+
) {
|
|
548
|
+
const access = await getOutpostSshAccess(token, opts.id);
|
|
549
|
+
const ssh = execViaSsh(access, command);
|
|
550
|
+
if (ssh.error) {
|
|
551
|
+
console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
if (opts.json) {
|
|
555
|
+
process.stdout.write(
|
|
556
|
+
JSON.stringify(
|
|
557
|
+
{
|
|
558
|
+
via: "ssh",
|
|
559
|
+
platform: access.platform,
|
|
560
|
+
host: access.host,
|
|
561
|
+
exitCode: ssh.exitCode,
|
|
562
|
+
stdout: ssh.stdout,
|
|
563
|
+
stderr: ssh.stderr,
|
|
564
|
+
},
|
|
565
|
+
null,
|
|
566
|
+
2,
|
|
567
|
+
) + "\n",
|
|
568
|
+
);
|
|
569
|
+
} else {
|
|
570
|
+
if (ssh.stdout) process.stdout.write(ssh.stdout);
|
|
571
|
+
if (ssh.stderr) process.stderr.write(ssh.stderr);
|
|
572
|
+
}
|
|
573
|
+
process.exitCode =
|
|
574
|
+
typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
|
|
575
|
+
return;
|
|
442
576
|
}
|
|
577
|
+
throw err;
|
|
443
578
|
}
|
|
444
|
-
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
445
|
-
process.exitCode =
|
|
446
|
-
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
447
579
|
} catch (err) {
|
|
448
580
|
fail(err);
|
|
449
581
|
}
|