@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 +13 -0
- package/dist/commands/auth.js +29 -5
- package/dist/commands/dm.d.ts +10 -2
- package/dist/commands/dm.js +102 -8
- package/dist/commands/whoami.js +21 -4
- package/dist/utils/cognito-session.js +29 -3
- package/package.json +3 -3
- package/src/commands/auth.ts +38 -3
- package/src/commands/dm.test.ts +57 -0
- package/src/commands/dm.ts +148 -6
- package/src/commands/whoami.ts +33 -3
- package/src/utils/cognito-session.machine.test.ts +114 -0
- package/src/utils/cognito-session.ts +31 -0
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
|
package/dist/commands/auth.js
CHANGED
|
@@ -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]="
|
|
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 {
|
|
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
|
|
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=
|
|
139
|
+
//# debugId=b9690c95-1d23-5c58-8cb3-0e3bdc8b35e8
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
* email/
|
|
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.
|
package/dist/commands/dm.js
CHANGED
|
@@ -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]="
|
|
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
|
-
|
|
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
|
|
11
|
-
* email/
|
|
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 (
|
|
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
|
|
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(
|
|
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=
|
|
394
|
+
//# debugId=f4bc9146-4033-5a73-a246-6f80a5e0f7ef
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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]="
|
|
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 {
|
|
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=
|
|
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]="
|
|
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=
|
|
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.
|
|
4
|
-
"description": "HQ by Indigo management CLI
|
|
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.
|
|
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",
|
package/src/commands/auth.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
package/src/commands/dm.test.ts
CHANGED
|
@@ -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" }),
|
package/src/commands/dm.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
* email/
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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>",
|
package/src/commands/whoami.ts
CHANGED
|
@@ -4,16 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
import { Command } from 'commander';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
loadCachedTokens,
|
|
9
|
+
isExpiring,
|
|
10
|
+
isMachineIdentity,
|
|
11
|
+
loadMachineCreds,
|
|
12
|
+
} from '@indigoai-us/hq-cloud';
|
|
8
13
|
|
|
9
|
-
function peekIdToken(idToken: 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 {
|
|
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" };
|