@indigoai-us/hq-cli 5.39.3 → 5.41.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.41.0]
6
+
7
+ ### Added
8
+
9
+ - **`hq dm` accepts agent recipients (`agt_…`).** Agents are first-class DM
10
+ participants server-side (hq-pro #468): an `agt_*` recipient rides the same
11
+ `toPersonUid` wire field, is gated by the unchanged membership-overlap rule
12
+ (you can only DM an agent you share an active company with), and is
13
+ delivered into the agent's durable box inbox — the agent replies by DM as
14
+ its own `agt_*` identity. Group DMs stay person-only (they're channels);
15
+ an `agt_*` in a comma-separated recipient list gets a clear error pointing
16
+ at the 1:1 form.
17
+
5
18
  ## [5.39.3]
6
19
 
7
20
  ### Fixed
@@ -14,9 +14,9 @@
14
14
  * by the deploy + sync skills.
15
15
  */
16
16
 
17
- !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]="e8f8cd77-2f24-5e71-8cb6-518095482b22")}catch(e){}}();
17
+ !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]="b9690c95-1d23-5c58-8cb3-0e3bdc8b35e8")}catch(e){}}();
18
18
  import chalk from "chalk";
19
- import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, CognitoAuthError, } from "@indigoai-us/hq-cloud";
19
+ import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
20
20
  import { refreshCachedSession, } from "../utils/cognito-session.js";
21
21
  import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
22
22
  /**
@@ -32,12 +32,22 @@ function peekIdToken(idToken) {
32
32
  const pad = payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
33
33
  const normalized = payload.replace(/-/g, "+").replace(/_/g, "/") + pad;
34
34
  const decoded = JSON.parse(Buffer.from(normalized, "base64").toString("utf-8"));
35
- return { email: decoded.email, sub: decoded.sub };
35
+ return {
36
+ email: decoded.email,
37
+ sub: decoded.sub,
38
+ entityType: decoded["custom:entityType"],
39
+ entityUid: decoded["custom:entityUid"],
40
+ };
36
41
  }
37
42
  catch {
38
43
  return {};
39
44
  }
40
45
  }
46
+ /** Display label for the local machine identity (company agents). */
47
+ function machineIdentityLabel() {
48
+ const creds = loadMachineCreds();
49
+ return creds ? `machine identity ${creds.username}` : "machine identity";
50
+ }
41
51
  export function registerAuthCommands(program) {
42
52
  const authCmd = program
43
53
  .command("auth")
@@ -47,6 +57,10 @@ export function registerAuthCommands(program) {
47
57
  .description("Sign in to HQ — opens the Cognito Hosted UI and caches tokens locally")
48
58
  .option("--provider <provider>", "OAuth provider to use: google, microsoft, or picker")
49
59
  .action(async (options) => {
60
+ if (isMachineIdentity()) {
61
+ console.log(chalk.green(`Running as ${machineIdentityLabel()} — sessions mint automatically; no browser login needed.`));
62
+ return;
63
+ }
50
64
  const existing = loadCachedTokens();
51
65
  if (existing && !isExpiring(existing, 120)) {
52
66
  const who = peekIdToken(existing.idToken).email ?? "cached session";
@@ -98,12 +112,22 @@ export function registerAuthCommands(program) {
98
112
  .command("status")
99
113
  .description("Show whether a valid HQ session is cached")
100
114
  .action(() => {
115
+ const machine = isMachineIdentity();
101
116
  const cached = loadCachedTokens();
102
117
  if (!cached) {
118
+ if (machine) {
119
+ // No cached session yet, but machine creds mint one on demand —
120
+ // report ready, not signed-out.
121
+ console.log(chalk.green(`${machineIdentityLabel()} — no cached session yet (mints automatically on first use)`));
122
+ return;
123
+ }
103
124
  console.log(chalk.yellow("No cached HQ session — run `hq auth login`"));
104
125
  process.exit(1);
105
126
  }
106
- const who = peekIdToken(cached.idToken).email;
127
+ const claims = peekIdToken(cached.idToken);
128
+ const who = machine
129
+ ? `${machineIdentityLabel()}${claims.entityUid ? ` (${claims.entityUid})` : ""}`
130
+ : claims.email;
107
131
  const expiring = isExpiring(cached);
108
132
  const label = who ? `${who} — ` : "";
109
133
  console.log(expiring
@@ -112,4 +136,4 @@ export function registerAuthCommands(program) {
112
136
  });
113
137
  }
114
138
  //# sourceMappingURL=auth.js.map
115
- //# debugId=e8f8cd77-2f24-5e71-8cb6-518095482b22
139
+ //# debugId=b9690c95-1d23-5c58-8cb3-0e3bdc8b35e8
@@ -4,10 +4,18 @@ export interface DmRecipient {
4
4
  toPersonUid?: string;
5
5
  }
6
6
  /**
7
- * Classify a recipient arg as an email or a personUid. Mirrors the
8
- * email/prs_ heuristic used by `hq members`. Returns null for neither.
7
+ * Classify a recipient arg as an email or a person/agent uid. Mirrors the
8
+ * email/uid heuristic used by `hq members`. Returns null for neither.
9
+ * Agent uids (agt_*) ride the same `toPersonUid` wire field — that is the
10
+ * server contract (POST /v1/notify/dm accepts prs_* or agt_*).
9
11
  */
10
12
  export declare function detectRecipient(recipient: string): DmRecipient | null;
13
+ /**
14
+ * A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
15
+ * recipient tokens (emails or personUids). Returns null when there's no comma
16
+ * (the normal 1:1 path). Pure → unit-testable.
17
+ */
18
+ export declare function parseGroupRecipients(recipient: string): string[] | null;
11
19
  /**
12
20
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
13
21
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -1,23 +1,42 @@
1
1
 
2
- !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]="19c5d8e0-ec0a-5b57-b3dc-e2209b60cf2f")}catch(e){}}();
2
+ !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]="f4bc9146-4033-5a73-a246-6f80a5e0f7ef")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { ensureCognitoToken } from "../utils/cognito-session.js";
6
6
  import { vaultApiFetch } from "../utils/vault-api.js";
7
7
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
8
- const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
+ // People (prs_) and agents (agt_) are both first-class DM participants; the
9
+ // server applies the same membership-overlap gate to either and delivers
10
+ // agt_* recipients into the agent's durable box inbox.
11
+ const RECIPIENT_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
9
12
  /**
10
- * Classify a recipient arg as an email or a personUid. Mirrors the
11
- * email/prs_ heuristic used by `hq members`. Returns null for neither.
13
+ * Classify a recipient arg as an email or a person/agent uid. Mirrors the
14
+ * email/uid heuristic used by `hq members`. Returns null for neither.
15
+ * Agent uids (agt_*) ride the same `toPersonUid` wire field — that is the
16
+ * server contract (POST /v1/notify/dm accepts prs_* or agt_*).
12
17
  */
13
18
  export function detectRecipient(recipient) {
14
19
  const r = recipient.trim();
15
20
  if (EMAIL_PATTERN.test(r))
16
21
  return { toEmail: r.toLowerCase() };
17
- if (PERSON_UID_PATTERN.test(r))
22
+ if (RECIPIENT_UID_PATTERN.test(r))
18
23
  return { toPersonUid: r };
19
24
  return null;
20
25
  }
26
+ /**
27
+ * A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
28
+ * recipient tokens (emails or personUids). Returns null when there's no comma
29
+ * (the normal 1:1 path). Pure → unit-testable.
30
+ */
31
+ export function parseGroupRecipients(recipient) {
32
+ if (!recipient.includes(","))
33
+ return null;
34
+ const parts = recipient
35
+ .split(",")
36
+ .map((s) => s.trim())
37
+ .filter(Boolean);
38
+ return [...new Set(parts)];
39
+ }
21
40
  /**
22
41
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
23
42
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -45,7 +64,7 @@ export function parseDuration(input) {
45
64
  export function buildDmBody(args) {
46
65
  const rcpt = detectRecipient(args.recipient);
47
66
  if (!rcpt) {
48
- throw new Error(`Invalid recipient '${args.recipient}': must be an email address or a personUid (prs_…).`);
67
+ throw new Error(`Invalid recipient '${args.recipient}': must be an email address, a personUid (prs_…), or an agentUid (agt_…).`);
49
68
  }
50
69
  const body = (args.message ?? "").trim();
51
70
  if (!body) {
@@ -178,7 +197,82 @@ async function runConnectionAction(action, identifier) {
178
197
  process.exit(1);
179
198
  }
180
199
  }
200
+ /**
201
+ * Group DM path: `hq dm send "a@x.com,b@y.com" "msg"`. Creates (or reopens, via
202
+ * the server's idempotent participant-key dedupe) a group channel, then posts
203
+ * the message into it. Reuses the same vault API client + auth as the 1:1 path.
204
+ */
205
+ async function runGroupSend(recipients, message) {
206
+ try {
207
+ const participants = [];
208
+ for (const r of recipients) {
209
+ const rc = detectRecipient(r);
210
+ if (!rc) {
211
+ console.error(chalk.red(`Invalid recipient '${r}': each must be an email address or a personUid (prs_…).`));
212
+ process.exit(1);
213
+ }
214
+ // Group DMs are channels — agents don't participate in channels (their
215
+ // DM surface is 1:1 via the durable box inbox). DM an agent directly.
216
+ if (rc.toPersonUid?.startsWith("agt_")) {
217
+ console.error(chalk.red(`Agents can't join group DMs yet — DM '${r}' directly: hq dm ${r} "<message>".`));
218
+ process.exit(1);
219
+ }
220
+ participants.push(rc.toEmail ?? rc.toPersonUid);
221
+ }
222
+ if (participants.length < 2) {
223
+ console.error(chalk.red('A group DM needs at least 2 other people — list them comma-separated, e.g. hq dm send "a@x.com,b@y.com" "hi".'));
224
+ process.exit(1);
225
+ }
226
+ const body = (message ?? "").trim();
227
+ if (!body) {
228
+ console.error(chalk.red('A message body is required: hq dm send "a@x,b@y" <message>'));
229
+ process.exit(1);
230
+ }
231
+ const token = await ensureCognitoToken();
232
+ // 1) Create or reopen the group (idempotent on the participant set).
233
+ const createRes = await vaultApiFetch({
234
+ token,
235
+ path: "/v1/notify/channels",
236
+ method: "POST",
237
+ body: { scope: "group", participants },
238
+ });
239
+ if (!createRes.ok) {
240
+ const err = (await createRes.json().catch(() => ({})));
241
+ console.error(chalk.red(friendlyDmError(createRes.status, err.code, err.error ?? err.message ?? createRes.statusText)));
242
+ process.exit(1);
243
+ }
244
+ const createData = (await createRes.json());
245
+ const channelId = createData.channel?.channelId;
246
+ if (!channelId) {
247
+ console.error(chalk.red("Group create returned no channel id."));
248
+ process.exit(1);
249
+ }
250
+ // 2) Post the message into the group.
251
+ const sendRes = await vaultApiFetch({
252
+ token,
253
+ path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
254
+ method: "POST",
255
+ body: { body },
256
+ });
257
+ if (!sendRes.ok) {
258
+ const err = (await sendRes.json().catch(() => ({})));
259
+ console.error(chalk.red(friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText)));
260
+ process.exit(1);
261
+ }
262
+ console.log(chalk.green(`Group DM sent to ${participants.length} people${createData.created ? " (new group)" : ""}.`));
263
+ }
264
+ catch (err) {
265
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
266
+ process.exit(1);
267
+ }
268
+ }
181
269
  async function runDmSend(recipient, message, opts) {
270
+ // A comma in the recipient means a group DM — fan into the channel path.
271
+ const group = parseGroupRecipients(recipient);
272
+ if (group) {
273
+ await runGroupSend(group, message);
274
+ return;
275
+ }
182
276
  try {
183
277
  // Resolve prompt/details from inline text or a file.
184
278
  let prompt = opts.prompt;
@@ -261,7 +355,7 @@ export function registerDmCommand(program) {
261
355
  .description("Send a direct message and manage connection requests.");
262
356
  dm
263
357
  .command("send <recipient> [message]", { isDefault: true, hidden: true })
264
- .description("Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren't connected yet, it sends a connection request that holds your message.")
358
+ .description('Send a direct message to someone (email, personUid, or agentUid). A person receives it as an HQ Sync notification; an agent receives it in its durable box inbox and replies by DM. If you aren\'t connected yet, it sends a connection request that holds your message. For a GROUP DM, pass a comma-separated recipient: hq dm send "a@x.com,b@y.com" "hi".')
265
359
  .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent")
266
360
  .option("--prompt-file <path>", "Read the agent prompt from a file")
267
361
  .option("--details <text>", "Longer detail shown in the recipient's DM detail window")
@@ -297,4 +391,4 @@ export function registerDmCommand(program) {
297
391
  });
298
392
  }
299
393
  //# sourceMappingURL=dm.js.map
300
- //# debugId=19c5d8e0-ec0a-5b57-b3dc-e2209b60cf2f
394
+ //# debugId=f4bc9146-4033-5a73-a246-6f80a5e0f7ef
@@ -2,9 +2,9 @@
2
2
  * hq whoami — displays current user or 'not logged in'
3
3
  */
4
4
 
5
- !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]="7d12196e-3212-5692-9383-5431e2b6a97f")}catch(e){}}();
5
+ !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]="1603fcc9-75f6-5855-b7b2-8333ffda9389")}catch(e){}}();
6
6
  import chalk from 'chalk';
7
- import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
7
+ import { loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, } from '@indigoai-us/hq-cloud';
8
8
  function peekIdToken(idToken) {
9
9
  try {
10
10
  const payload = idToken.split('.')[1];
@@ -13,7 +13,12 @@ function peekIdToken(idToken) {
13
13
  const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
14
14
  const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
15
  const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
16
- return { email: decoded.email, sub: decoded.sub };
16
+ return {
17
+ email: decoded.email,
18
+ sub: decoded.sub,
19
+ entityType: decoded['custom:entityType'],
20
+ entityUid: decoded['custom:entityUid'],
21
+ };
17
22
  }
18
23
  catch {
19
24
  return {};
@@ -25,7 +30,19 @@ export function registerWhoamiCommand(program) {
25
30
  .description('Show the currently authenticated user')
26
31
  .action(async () => {
27
32
  try {
33
+ const machine = isMachineIdentity();
28
34
  const cached = loadCachedTokens();
35
+ if (machine) {
36
+ // Machine identities (company agents) mint sessions on demand from
37
+ // long-lived creds — report the machine identity honestly even when
38
+ // no session is cached yet.
39
+ const username = loadMachineCreds()?.username ?? 'unknown';
40
+ const entityUid = cached
41
+ ? peekIdToken(cached.idToken).entityUid
42
+ : undefined;
43
+ console.log(`Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`);
44
+ return;
45
+ }
29
46
  if (!cached) {
30
47
  console.log("Not logged in. Run 'hq login' to authenticate.");
31
48
  return;
@@ -45,4 +62,4 @@ export function registerWhoamiCommand(program) {
45
62
  });
46
63
  }
47
64
  //# sourceMappingURL=whoami.js.map
48
- //# debugId=7d12196e-3212-5692-9383-5431e2b6a97f
65
+ //# debugId=1603fcc9-75f6-5855-b7b2-8333ffda9389
@@ -19,13 +19,13 @@
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
21
 
22
- !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]="984bafe3-242a-5322-a2fe-65261b24ee0b")}catch(e){}}();
22
+ !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]="783fe478-9e12-55a7-a3d8-241ea2cf5886")}catch(e){}}();
23
23
  import * as fs from "fs";
24
24
  import * as os from "os";
25
25
  import * as path from "path";
26
26
  import * as yaml from "js-yaml";
27
27
  import chalk from "chalk";
28
- import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, } from "@indigoai-us/hq-cloud";
28
+ import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, mintMachineTokens, } from "@indigoai-us/hq-cloud";
29
29
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
30
30
  export const DEFAULT_COGNITO = {
31
31
  region: process.env.AWS_REGION ?? "us-east-1",
@@ -232,6 +232,18 @@ export const DEFAULT_HQ_ROOT = resolveDefaultHqRoot();
232
232
  */
233
233
  export async function ensureCognitoToken(options = {}) {
234
234
  const interactive = options.interactive ?? true;
235
+ // Machine identities (company agents) mint sessions on demand via
236
+ // USER_PASSWORD_AUTH — no refresh-token dance, no browser. The vault API's
237
+ // JWT authorizer accepts ID tokens, and the agent's identity claims
238
+ // (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token ONLY,
239
+ // so vault-API calls from a machine identity send the ID token. The cached
240
+ // token file keeps correct field semantics (real access token in
241
+ // accessToken) for consumers that need token_use=access, e.g. the deploy
242
+ // API via the deploy skill.
243
+ if (isMachineIdentity()) {
244
+ const machine = await getValidMachineTokens(DEFAULT_COGNITO);
245
+ return machine.idToken;
246
+ }
235
247
  const cached = loadCachedTokens();
236
248
  if (cached && !isExpiring(cached, 120)) {
237
249
  return cached.accessToken;
@@ -286,6 +298,20 @@ export function buildVaultConfig(authToken) {
286
298
  * with a reason string so the caller can decide what to do.
287
299
  */
288
300
  export async function refreshCachedSession() {
301
+ // Machine identities have no refresh token — a "refresh" is a fresh mint
302
+ // from the long-lived machine creds.
303
+ if (isMachineIdentity()) {
304
+ try {
305
+ await mintMachineTokens(DEFAULT_COGNITO);
306
+ return { refreshed: true };
307
+ }
308
+ catch (err) {
309
+ return {
310
+ refreshed: false,
311
+ reason: err instanceof Error ? err.message : String(err),
312
+ };
313
+ }
314
+ }
289
315
  const cached = loadCachedTokens();
290
316
  if (!cached) {
291
317
  return { refreshed: false, reason: "no cached session" };
@@ -302,4 +328,4 @@ export async function refreshCachedSession() {
302
328
  }
303
329
  }
304
330
  //# sourceMappingURL=cognito-session.js.map
305
- //# debugId=984bafe3-242a-5322-a2fe-65261b24ee0b
331
+ //# debugId=783fe478-9e12-55a7-a3d8-241ea2cf5886
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.39.3",
4
- "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
3
+ "version": "5.41.0",
4
+ "description": "HQ by Indigo management CLI modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "hq": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.6.0",
18
+ "@indigoai-us/hq-cloud": "^6.7.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -21,6 +21,8 @@ import {
21
21
  clearCachedTokens,
22
22
  loadCachedTokens,
23
23
  isExpiring,
24
+ isMachineIdentity,
25
+ loadMachineCreds,
24
26
  CognitoAuthError,
25
27
  } from "@indigoai-us/hq-cloud";
26
28
  import {
@@ -36,7 +38,7 @@ import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
36
38
  */
37
39
  function peekIdToken(
38
40
  idToken: string,
39
- ): { email?: string; sub?: string } {
41
+ ): { email?: string; sub?: string; entityType?: string; entityUid?: string } {
40
42
  try {
41
43
  const payload = idToken.split(".")[1];
42
44
  if (!payload) return {};
@@ -47,12 +49,23 @@ function peekIdToken(
47
49
  const decoded = JSON.parse(
48
50
  Buffer.from(normalized, "base64").toString("utf-8"),
49
51
  );
50
- return { email: decoded.email, sub: decoded.sub };
52
+ return {
53
+ email: decoded.email,
54
+ sub: decoded.sub,
55
+ entityType: decoded["custom:entityType"],
56
+ entityUid: decoded["custom:entityUid"],
57
+ };
51
58
  } catch {
52
59
  return {};
53
60
  }
54
61
  }
55
62
 
63
+ /** Display label for the local machine identity (company agents). */
64
+ function machineIdentityLabel(): string {
65
+ const creds = loadMachineCreds();
66
+ return creds ? `machine identity ${creds.username}` : "machine identity";
67
+ }
68
+
56
69
  export function registerAuthCommands(program: Command): void {
57
70
  const authCmd = program
58
71
  .command("auth")
@@ -68,6 +81,14 @@ export function registerAuthCommands(program: Command): void {
68
81
  "OAuth provider to use: google, microsoft, or picker",
69
82
  )
70
83
  .action(async (options: { provider?: string }) => {
84
+ if (isMachineIdentity()) {
85
+ console.log(
86
+ chalk.green(
87
+ `Running as ${machineIdentityLabel()} — sessions mint automatically; no browser login needed.`,
88
+ ),
89
+ );
90
+ return;
91
+ }
71
92
  const existing = loadCachedTokens();
72
93
  if (existing && !isExpiring(existing, 120)) {
73
94
  const who = peekIdToken(existing.idToken).email ?? "cached session";
@@ -134,12 +155,26 @@ export function registerAuthCommands(program: Command): void {
134
155
  .command("status")
135
156
  .description("Show whether a valid HQ session is cached")
136
157
  .action(() => {
158
+ const machine = isMachineIdentity();
137
159
  const cached = loadCachedTokens();
138
160
  if (!cached) {
161
+ if (machine) {
162
+ // No cached session yet, but machine creds mint one on demand —
163
+ // report ready, not signed-out.
164
+ console.log(
165
+ chalk.green(
166
+ `${machineIdentityLabel()} — no cached session yet (mints automatically on first use)`,
167
+ ),
168
+ );
169
+ return;
170
+ }
139
171
  console.log(chalk.yellow("No cached HQ session — run `hq auth login`"));
140
172
  process.exit(1);
141
173
  }
142
- const who = peekIdToken(cached.idToken).email;
174
+ const claims = peekIdToken(cached.idToken);
175
+ const who = machine
176
+ ? `${machineIdentityLabel()}${claims.entityUid ? ` (${claims.entityUid})` : ""}`
177
+ : claims.email;
143
178
  const expiring = isExpiring(cached);
144
179
  const label = who ? `${who} — ` : "";
145
180
  console.log(
@@ -21,6 +21,7 @@ import { Command } from "commander";
21
21
  import {
22
22
  detectRecipient,
23
23
  parseDuration,
24
+ parseGroupRecipients,
24
25
  buildDmBody,
25
26
  matchRequest,
26
27
  buildConnectionActionBody,
@@ -28,6 +29,19 @@ import {
28
29
  type ConnectionRequest,
29
30
  } from "./dm.js";
30
31
 
32
+ describe("parseGroupRecipients", () => {
33
+ it("returns null with no comma (the 1:1 path)", () => {
34
+ expect(parseGroupRecipients("a@x.com")).toBeNull();
35
+ expect(parseGroupRecipients("prs_1")).toBeNull();
36
+ });
37
+ it("splits, trims, and de-dupes a comma list", () => {
38
+ expect(parseGroupRecipients("a@x.com, b@y.com ,a@x.com")).toEqual([
39
+ "a@x.com",
40
+ "b@y.com",
41
+ ]);
42
+ });
43
+ });
44
+
31
45
  describe("detectRecipient", () => {
32
46
  it("classifies an email", () => {
33
47
  expect(detectRecipient("Stefan@Getindigo.ai")).toEqual({
@@ -37,9 +51,16 @@ describe("detectRecipient", () => {
37
51
  it("classifies a personUid", () => {
38
52
  expect(detectRecipient("prs_01ABC")).toEqual({ toPersonUid: "prs_01ABC" });
39
53
  });
54
+ it("classifies an agentUid on the same wire field (server accepts agt_*)", () => {
55
+ expect(detectRecipient("agt_01KTT1RJJ6KQRHNHHST0K4VRGT")).toEqual({
56
+ toPersonUid: "agt_01KTT1RJJ6KQRHNHHST0K4VRGT",
57
+ });
58
+ });
40
59
  it("rejects anything else", () => {
41
60
  expect(detectRecipient("not-an-email")).toBeNull();
42
61
  expect(detectRecipient("")).toBeNull();
62
+ expect(detectRecipient("cmp_01ABC")).toBeNull();
63
+ expect(detectRecipient("agt")).toBeNull();
43
64
  });
44
65
  });
45
66
 
@@ -79,6 +100,12 @@ describe("buildDmBody", () => {
79
100
  ).toEqual({ toPersonUid: "prs_x", body: "m", prompt: "do the thing" });
80
101
  });
81
102
 
103
+ it("builds an agent DM (agt_* rides toPersonUid)", () => {
104
+ expect(
105
+ buildDmBody({ recipient: "agt_01ABC", message: "status?", now }),
106
+ ).toEqual({ toPersonUid: "agt_01ABC", body: "status?" });
107
+ });
108
+
82
109
  it("resolves --in to a future deliverAt", () => {
83
110
  const out = buildDmBody({ recipient: "a@b.com", message: "m", inDelay: "10m", now });
84
111
  expect(out.deliverAt).toBe("2026-05-29T00:10:00.000Z");
@@ -220,6 +247,36 @@ describe("dm command actions", () => {
220
247
  expect(errSpy).not.toHaveBeenCalled();
221
248
  });
222
249
 
250
+ it("group send: a comma recipient creates a group then posts the message", async () => {
251
+ fetchSpy
252
+ .mockResolvedValueOnce(
253
+ jsonResponse(200, { channel: { channelId: "chn_grp1" }, created: true }),
254
+ )
255
+ .mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_g" }));
256
+ await program.parseAsync(
257
+ ["dm", "alice@example.com,bob@example.com", "hey team"],
258
+ { from: "user" },
259
+ );
260
+ // 1st call creates the group channel...
261
+ const createCall = fetchSpy.mock.calls[0];
262
+ expect(String(createCall[0])).toContain("/v1/notify/channels");
263
+ const createBody = JSON.parse((createCall[1]?.body as string) ?? "{}");
264
+ expect(createBody.scope).toBe("group");
265
+ expect(createBody.participants).toEqual(["alice@example.com", "bob@example.com"]);
266
+ // ...2nd posts the message into it.
267
+ const sendCall = fetchSpy.mock.calls[1];
268
+ expect(String(sendCall[0])).toContain("/v1/notify/channels/chn_grp1/messages");
269
+ expect(JSON.parse((sendCall[1]?.body as string) ?? "{}").body).toBe("hey team");
270
+ expect(logged()).toMatch(/Group DM sent to 2 people \(new group\)\./);
271
+ expect(errSpy).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it("group send: a single recipient still uses the 1:1 dm path", async () => {
275
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_1" }));
276
+ await program.parseAsync(["dm", "alice@example.com", "hi"], { from: "user" });
277
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/dm");
278
+ });
279
+
223
280
  it("send: prints pending request on 202 connection_requested (not an error)", async () => {
224
281
  fetchSpy.mockResolvedValueOnce(
225
282
  jsonResponse(202, { state: "connection_requested" }),
@@ -5,7 +5,10 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch } from "../utils/vault-api.js";
6
6
 
7
7
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
8
- const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
+ // People (prs_) and agents (agt_) are both first-class DM participants; the
9
+ // server applies the same membership-overlap gate to either and delivers
10
+ // agt_* recipients into the agent's durable box inbox.
11
+ const RECIPIENT_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
9
12
 
10
13
  export interface DmRecipient {
11
14
  toEmail?: string;
@@ -13,16 +16,32 @@ export interface DmRecipient {
13
16
  }
14
17
 
15
18
  /**
16
- * Classify a recipient arg as an email or a personUid. Mirrors the
17
- * email/prs_ heuristic used by `hq members`. Returns null for neither.
19
+ * Classify a recipient arg as an email or a person/agent uid. Mirrors the
20
+ * email/uid heuristic used by `hq members`. Returns null for neither.
21
+ * Agent uids (agt_*) ride the same `toPersonUid` wire field — that is the
22
+ * server contract (POST /v1/notify/dm accepts prs_* or agt_*).
18
23
  */
19
24
  export function detectRecipient(recipient: string): DmRecipient | null {
20
25
  const r = recipient.trim();
21
26
  if (EMAIL_PATTERN.test(r)) return { toEmail: r.toLowerCase() };
22
- if (PERSON_UID_PATTERN.test(r)) return { toPersonUid: r };
27
+ if (RECIPIENT_UID_PATTERN.test(r)) return { toPersonUid: r };
23
28
  return null;
24
29
  }
25
30
 
31
+ /**
32
+ * A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
33
+ * recipient tokens (emails or personUids). Returns null when there's no comma
34
+ * (the normal 1:1 path). Pure → unit-testable.
35
+ */
36
+ export function parseGroupRecipients(recipient: string): string[] | null {
37
+ if (!recipient.includes(",")) return null;
38
+ const parts = recipient
39
+ .split(",")
40
+ .map((s) => s.trim())
41
+ .filter(Boolean);
42
+ return [...new Set(parts)];
43
+ }
44
+
26
45
  /**
27
46
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
28
47
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -68,7 +87,7 @@ export function buildDmBody(args: {
68
87
  const rcpt = detectRecipient(args.recipient);
69
88
  if (!rcpt) {
70
89
  throw new Error(
71
- `Invalid recipient '${args.recipient}': must be an email address or a personUid (prs_…).`,
90
+ `Invalid recipient '${args.recipient}': must be an email address, a personUid (prs_…), or an agentUid (agt_…).`,
72
91
  );
73
92
  }
74
93
  const body = (args.message ?? "").trim();
@@ -248,11 +267,134 @@ interface DmSendOpts {
248
267
  in?: string;
249
268
  }
250
269
 
270
+ /**
271
+ * Group DM path: `hq dm send "a@x.com,b@y.com" "msg"`. Creates (or reopens, via
272
+ * the server's idempotent participant-key dedupe) a group channel, then posts
273
+ * the message into it. Reuses the same vault API client + auth as the 1:1 path.
274
+ */
275
+ async function runGroupSend(
276
+ recipients: string[],
277
+ message: string | undefined,
278
+ ): Promise<void> {
279
+ try {
280
+ const participants: string[] = [];
281
+ for (const r of recipients) {
282
+ const rc = detectRecipient(r);
283
+ if (!rc) {
284
+ console.error(
285
+ chalk.red(
286
+ `Invalid recipient '${r}': each must be an email address or a personUid (prs_…).`,
287
+ ),
288
+ );
289
+ process.exit(1);
290
+ }
291
+ // Group DMs are channels — agents don't participate in channels (their
292
+ // DM surface is 1:1 via the durable box inbox). DM an agent directly.
293
+ if (rc.toPersonUid?.startsWith("agt_")) {
294
+ console.error(
295
+ chalk.red(
296
+ `Agents can't join group DMs yet — DM '${r}' directly: hq dm ${r} "<message>".`,
297
+ ),
298
+ );
299
+ process.exit(1);
300
+ }
301
+ participants.push(rc.toEmail ?? rc.toPersonUid!);
302
+ }
303
+ if (participants.length < 2) {
304
+ console.error(
305
+ chalk.red(
306
+ 'A group DM needs at least 2 other people — list them comma-separated, e.g. hq dm send "a@x.com,b@y.com" "hi".',
307
+ ),
308
+ );
309
+ process.exit(1);
310
+ }
311
+ const body = (message ?? "").trim();
312
+ if (!body) {
313
+ console.error(
314
+ chalk.red('A message body is required: hq dm send "a@x,b@y" <message>'),
315
+ );
316
+ process.exit(1);
317
+ }
318
+
319
+ const token = await ensureCognitoToken();
320
+
321
+ // 1) Create or reopen the group (idempotent on the participant set).
322
+ const createRes = await vaultApiFetch({
323
+ token,
324
+ path: "/v1/notify/channels",
325
+ method: "POST",
326
+ body: { scope: "group", participants },
327
+ });
328
+ if (!createRes.ok) {
329
+ const err = (await createRes.json().catch(() => ({}))) as Record<string, string>;
330
+ console.error(
331
+ chalk.red(
332
+ friendlyDmError(
333
+ createRes.status,
334
+ err.code,
335
+ err.error ?? err.message ?? createRes.statusText,
336
+ ),
337
+ ),
338
+ );
339
+ process.exit(1);
340
+ }
341
+ const createData = (await createRes.json()) as {
342
+ channel?: { channelId?: string };
343
+ created?: boolean;
344
+ };
345
+ const channelId = createData.channel?.channelId;
346
+ if (!channelId) {
347
+ console.error(chalk.red("Group create returned no channel id."));
348
+ process.exit(1);
349
+ }
350
+
351
+ // 2) Post the message into the group.
352
+ const sendRes = await vaultApiFetch({
353
+ token,
354
+ path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
355
+ method: "POST",
356
+ body: { body },
357
+ });
358
+ if (!sendRes.ok) {
359
+ const err = (await sendRes.json().catch(() => ({}))) as Record<string, string>;
360
+ console.error(
361
+ chalk.red(
362
+ friendlyDmError(
363
+ sendRes.status,
364
+ err.code,
365
+ err.error ?? err.message ?? sendRes.statusText,
366
+ ),
367
+ ),
368
+ );
369
+ process.exit(1);
370
+ }
371
+ console.log(
372
+ chalk.green(
373
+ `Group DM sent to ${participants.length} people${
374
+ createData.created ? " (new group)" : ""
375
+ }.`,
376
+ ),
377
+ );
378
+ } catch (err) {
379
+ console.error(
380
+ chalk.red("Error:"),
381
+ err instanceof Error ? err.message : String(err),
382
+ );
383
+ process.exit(1);
384
+ }
385
+ }
386
+
251
387
  async function runDmSend(
252
388
  recipient: string,
253
389
  message: string | undefined,
254
390
  opts: DmSendOpts,
255
391
  ): Promise<void> {
392
+ // A comma in the recipient means a group DM — fan into the channel path.
393
+ const group = parseGroupRecipients(recipient);
394
+ if (group) {
395
+ await runGroupSend(group, message);
396
+ return;
397
+ }
256
398
  try {
257
399
  // Resolve prompt/details from inline text or a file.
258
400
  let prompt = opts.prompt;
@@ -376,7 +518,7 @@ export function registerDmCommand(program: Command): void {
376
518
  dm
377
519
  .command("send <recipient> [message]", { isDefault: true, hidden: true })
378
520
  .description(
379
- "Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren't connected yet, it sends a connection request that holds your message.",
521
+ 'Send a direct message to someone (email, personUid, or agentUid). A person receives it as an HQ Sync notification; an agent receives it in its durable box inbox and replies by DM. If you aren\'t connected yet, it sends a connection request that holds your message. For a GROUP DM, pass a comma-separated recipient: hq dm send "a@x.com,b@y.com" "hi".',
380
522
  )
381
523
  .option(
382
524
  "--prompt <text>",
@@ -4,16 +4,31 @@
4
4
 
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
- import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
7
+ import {
8
+ loadCachedTokens,
9
+ isExpiring,
10
+ isMachineIdentity,
11
+ loadMachineCreds,
12
+ } from '@indigoai-us/hq-cloud';
8
13
 
9
- function peekIdToken(idToken: string): { email?: string; sub?: string } {
14
+ function peekIdToken(idToken: string): {
15
+ email?: string;
16
+ sub?: string;
17
+ entityType?: string;
18
+ entityUid?: string;
19
+ } {
10
20
  try {
11
21
  const payload = idToken.split('.')[1];
12
22
  if (!payload) return {};
13
23
  const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
14
24
  const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
25
  const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
16
- return { email: decoded.email, sub: decoded.sub };
26
+ return {
27
+ email: decoded.email,
28
+ sub: decoded.sub,
29
+ entityType: decoded['custom:entityType'],
30
+ entityUid: decoded['custom:entityUid'],
31
+ };
17
32
  } catch {
18
33
  return {};
19
34
  }
@@ -25,8 +40,23 @@ export function registerWhoamiCommand(program: Command): void {
25
40
  .description('Show the currently authenticated user')
26
41
  .action(async () => {
27
42
  try {
43
+ const machine = isMachineIdentity();
28
44
  const cached = loadCachedTokens();
29
45
 
46
+ if (machine) {
47
+ // Machine identities (company agents) mint sessions on demand from
48
+ // long-lived creds — report the machine identity honestly even when
49
+ // no session is cached yet.
50
+ const username = loadMachineCreds()?.username ?? 'unknown';
51
+ const entityUid = cached
52
+ ? peekIdToken(cached.idToken).entityUid
53
+ : undefined;
54
+ console.log(
55
+ `Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`
56
+ );
57
+ return;
58
+ }
59
+
30
60
  if (!cached) {
31
61
  console.log("Not logged in. Run 'hq login' to authenticate.");
32
62
  return;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Machine-identity behavior of the shared Cognito session helpers.
3
+ *
4
+ * When machine creds are present (company agent boxes), the CLI must:
5
+ * - return the ID token from ensureCognitoToken() — the vault API's JWT
6
+ * authorizer accepts ID tokens and the agent claims
7
+ * (custom:entityType/custom:entityUid) ride the ID token only
8
+ * - never refresh or open a browser — sessions re-mint on demand
9
+ * - treat `hq auth refresh` as a fresh mint instead of "no cached session"
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, vi } from "vitest";
13
+
14
+ const mocks = vi.hoisted(() => ({
15
+ isMachineIdentity: vi.fn(),
16
+ getValidMachineTokens: vi.fn(),
17
+ mintMachineTokens: vi.fn(),
18
+ loadCachedTokens: vi.fn(),
19
+ refreshTokens: vi.fn(),
20
+ browserLogin: vi.fn(),
21
+ isExpiring: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import("@indigoai-us/hq-cloud")>();
26
+ return { ...actual, ...mocks };
27
+ });
28
+
29
+ import {
30
+ ensureCognitoToken,
31
+ refreshCachedSession,
32
+ } from "./cognito-session.js";
33
+
34
+ const MACHINE_TOKENS = {
35
+ accessToken: "machine-access-token",
36
+ idToken: "machine-id-token",
37
+ refreshToken: "",
38
+ expiresAt: Date.now() + 3600_000,
39
+ tokenType: "Bearer" as const,
40
+ };
41
+
42
+ beforeEach(() => {
43
+ vi.resetAllMocks();
44
+ });
45
+
46
+ describe("ensureCognitoToken in machine mode", () => {
47
+ it("returns the ID token (agent claims ride the ID token only)", async () => {
48
+ mocks.isMachineIdentity.mockReturnValue(true);
49
+ mocks.getValidMachineTokens.mockResolvedValue(MACHINE_TOKENS);
50
+
51
+ const token = await ensureCognitoToken({ interactive: false });
52
+
53
+ expect(token).toBe("machine-id-token");
54
+ expect(mocks.getValidMachineTokens).toHaveBeenCalledTimes(1);
55
+ expect(mocks.refreshTokens).not.toHaveBeenCalled();
56
+ expect(mocks.browserLogin).not.toHaveBeenCalled();
57
+ expect(mocks.loadCachedTokens).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("falls through to the browser-session path for human identities", async () => {
61
+ mocks.isMachineIdentity.mockReturnValue(false);
62
+ mocks.loadCachedTokens.mockReturnValue({
63
+ ...MACHINE_TOKENS,
64
+ accessToken: "human-access-token",
65
+ });
66
+ mocks.isExpiring.mockReturnValue(false);
67
+
68
+ const token = await ensureCognitoToken({ interactive: false });
69
+
70
+ expect(token).toBe("human-access-token");
71
+ expect(mocks.getValidMachineTokens).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("propagates mint failures instead of opening a browser", async () => {
75
+ mocks.isMachineIdentity.mockReturnValue(true);
76
+ mocks.getValidMachineTokens.mockRejectedValue(new Error("mint failed"));
77
+
78
+ await expect(ensureCognitoToken()).rejects.toThrow("mint failed");
79
+ expect(mocks.browserLogin).not.toHaveBeenCalled();
80
+ });
81
+ });
82
+
83
+ describe("refreshCachedSession in machine mode", () => {
84
+ it("re-mints instead of answering 'no cached session'", async () => {
85
+ mocks.isMachineIdentity.mockReturnValue(true);
86
+ mocks.mintMachineTokens.mockResolvedValue(MACHINE_TOKENS);
87
+ mocks.loadCachedTokens.mockReturnValue(null);
88
+
89
+ const result = await refreshCachedSession();
90
+
91
+ expect(result).toEqual({ refreshed: true });
92
+ expect(mocks.mintMachineTokens).toHaveBeenCalledTimes(1);
93
+ expect(mocks.refreshTokens).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("surfaces the mint error as the reason on failure", async () => {
97
+ mocks.isMachineIdentity.mockReturnValue(true);
98
+ mocks.mintMachineTokens.mockRejectedValue(new Error("NotAuthorized"));
99
+
100
+ const result = await refreshCachedSession();
101
+
102
+ expect(result.refreshed).toBe(false);
103
+ expect(result.reason).toContain("NotAuthorized");
104
+ });
105
+
106
+ it("keeps the human refresh path unchanged", async () => {
107
+ mocks.isMachineIdentity.mockReturnValue(false);
108
+ mocks.loadCachedTokens.mockReturnValue(null);
109
+
110
+ const result = await refreshCachedSession();
111
+
112
+ expect(result).toEqual({ refreshed: false, reason: "no cached session" });
113
+ });
114
+ });
@@ -30,6 +30,9 @@ import {
30
30
  refreshTokens,
31
31
  browserLogin,
32
32
  detectHqCoreVersion,
33
+ isMachineIdentity,
34
+ getValidMachineTokens,
35
+ mintMachineTokens,
33
36
  type CognitoAuthConfig,
34
37
  type ClientInfo,
35
38
  type VaultServiceConfig,
@@ -266,6 +269,20 @@ export async function ensureCognitoToken(options: {
266
269
  interactive?: boolean;
267
270
  } = {}): Promise<string> {
268
271
  const interactive = options.interactive ?? true;
272
+
273
+ // Machine identities (company agents) mint sessions on demand via
274
+ // USER_PASSWORD_AUTH — no refresh-token dance, no browser. The vault API's
275
+ // JWT authorizer accepts ID tokens, and the agent's identity claims
276
+ // (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token ONLY,
277
+ // so vault-API calls from a machine identity send the ID token. The cached
278
+ // token file keeps correct field semantics (real access token in
279
+ // accessToken) for consumers that need token_use=access, e.g. the deploy
280
+ // API via the deploy skill.
281
+ if (isMachineIdentity()) {
282
+ const machine = await getValidMachineTokens(DEFAULT_COGNITO);
283
+ return machine.idToken;
284
+ }
285
+
269
286
  const cached = loadCachedTokens();
270
287
 
271
288
  if (cached && !isExpiring(cached, 120)) {
@@ -335,6 +352,20 @@ export async function refreshCachedSession(): Promise<{
335
352
  refreshed: boolean;
336
353
  reason?: string;
337
354
  }> {
355
+ // Machine identities have no refresh token — a "refresh" is a fresh mint
356
+ // from the long-lived machine creds.
357
+ if (isMachineIdentity()) {
358
+ try {
359
+ await mintMachineTokens(DEFAULT_COGNITO);
360
+ return { refreshed: true };
361
+ } catch (err) {
362
+ return {
363
+ refreshed: false,
364
+ reason: err instanceof Error ? err.message : String(err),
365
+ };
366
+ }
367
+ }
368
+
338
369
  const cached = loadCachedTokens();
339
370
  if (!cached) {
340
371
  return { refreshed: false, reason: "no cached session" };