@indigoai-us/hq-cli 5.108.11 → 5.108.13
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 +48 -0
- package/dist/commands/dm.d.ts +64 -0
- package/dist/commands/dm.js +192 -3
- package/dist/commands/skill.d.ts +24 -8
- package/dist/commands/skill.js +85 -8
- package/dist/main.js +22 -0
- package/dist/utils/client-health-contract.d.ts +10 -1
- package/dist/utils/client-health-contract.js +2 -0
- package/dist/utils/client-health.js +22 -18
- package/dist/utils/vault-access-denied-error.d.ts +59 -0
- package/dist/utils/vault-access-denied-error.js +122 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.13] — 2026-09-06
|
|
6
|
+
|
|
7
|
+
## [5.108.12] - 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `hq dm send` into a channel now turns `@name` in the message into a REAL
|
|
12
|
+
mention. HQ Rooms mentions are structured-only — the server never reads
|
|
13
|
+
`@name` out of the body — so until now a CLI post like
|
|
14
|
+
`hq dm send '#hq-dev' "@Izzy please look"` went in with no `mentions` array
|
|
15
|
+
and mentioned nobody: no notification, no agent wake. During an incident that
|
|
16
|
+
was a silent false negative, and it made agent-in-channel workflows
|
|
17
|
+
undrivable from the CLI. The send path now reads the channel roster
|
|
18
|
+
(`GET /v1/notify/channels/{id}/members`), resolves each `@name`
|
|
19
|
+
case-insensitively against display name, slug/handle, email local part, and
|
|
20
|
+
first name, and posts the structured `mentions` array with `participantType`
|
|
21
|
+
derived from the uid prefix (`agt_*` → agent, otherwise human). Quote a
|
|
22
|
+
multi-word name as `@"Jacob Posel"`. A name that is unknown or matches more
|
|
23
|
+
than one member FAILS the send and lists the roster or the candidates rather
|
|
24
|
+
than posting something that looks addressed but isn't; a bare `@` or an email
|
|
25
|
+
address in prose is never a mention. `--no-mentions` posts the text literally.
|
|
26
|
+
|
|
5
27
|
## [5.108.11] — 2026-09-05
|
|
6
28
|
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- `hq doctor` agents-v2 (hermes) self-attestation now keys hook-adapter wiring
|
|
32
|
+
on the **runtime config**, not `.claude/settings.json` (#512). The 5.108.10
|
|
33
|
+
fix required `.claude/settings.json` to name `hq-agents-v2-hook-adapter.sh`,
|
|
34
|
+
but on a real hermes box it never does: the v2 runtime's shell-hook dispatcher
|
|
35
|
+
reads a `hooks:` block from `~/.hermes/config.yaml` that invokes the adapter,
|
|
36
|
+
and the adapter merely *reads* `.claude/settings.json` to fan out to the
|
|
37
|
+
classic `hook-gate.sh` hooks. On the v2.17 canary `settings.json` had 0
|
|
38
|
+
adapter references / 94 `hook-gate.sh` references while `~/.hermes/config.yaml`
|
|
39
|
+
wired the adapter across its lifecycle events, so the old check could never
|
|
40
|
+
attest. The runtime probe now requires the on-box adapter installed under the
|
|
41
|
+
tree at `.agents-v2-hooks/hq-agents-v2-hook-adapter.sh` **and** the runtime
|
|
42
|
+
config at `${HQ_HERMES_CONFIG_FILE:-~/.hermes/config.yaml}` invoking it. All
|
|
43
|
+
three attestation signals are still required, and no non-agents-v2 host's
|
|
44
|
+
verdict changes. TypeScript twin of `hq_runtime_config_wires_v2_adapter` in
|
|
45
|
+
`check-hq-hooks.sh`.
|
|
46
|
+
- `hq` self-update and the version-gate now name a non-writable npm prefix
|
|
47
|
+
plainly instead of failing opaquely (#511). When a global update fails because
|
|
48
|
+
the npm prefix is root-owned and the running user cannot write it — the
|
|
49
|
+
agent-box case, where the CLI is a `/usr` global install and the runtime is
|
|
50
|
+
unprivileged — both update surfaces now print that the prefix is not writable
|
|
51
|
+
and point at the paths that *can* update it (the box's `hq-cli-update` timer or
|
|
52
|
+
`sudo npm install -g @indigoai-us/hq-cli@latest`), rather than leaving
|
|
53
|
+
`exit 75` to read as a generic error.
|
|
54
|
+
|
|
7
55
|
## [5.108.10] — 2026-09-05
|
|
8
56
|
|
|
9
57
|
### Fixed
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -57,6 +57,70 @@ export interface ChannelSummary {
|
|
|
57
57
|
* unit-testable.
|
|
58
58
|
*/
|
|
59
59
|
export declare function matchChannelsByName(channels: ChannelSummary[], name: string): ChannelSummary[];
|
|
60
|
+
/** Server-side cap on one post's mentions (hq-pro MAX_CHANNEL_MENTIONS). */
|
|
61
|
+
export declare const MAX_CHANNEL_MENTIONS = 25;
|
|
62
|
+
/**
|
|
63
|
+
* One roster row from GET /v1/notify/channels/{id}/members. The server enriches
|
|
64
|
+
* each CHAN_MEMBER row with live display identity (displayName, email, …).
|
|
65
|
+
*/
|
|
66
|
+
export interface ChannelMember {
|
|
67
|
+
personUid: string;
|
|
68
|
+
displayName?: string;
|
|
69
|
+
email?: string;
|
|
70
|
+
slug?: string;
|
|
71
|
+
handle?: string;
|
|
72
|
+
}
|
|
73
|
+
/** A structured mention as the POST messages route expects it. */
|
|
74
|
+
export interface ChannelMention {
|
|
75
|
+
participantUid: string;
|
|
76
|
+
participantType: "agent" | "human";
|
|
77
|
+
displayName: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `agt_*` uids are agents, everything else (`prs_*`) is a human. The server
|
|
81
|
+
* 400s INVALID_MENTIONS when the type and the uid prefix disagree, so this is
|
|
82
|
+
* derived, never guessed.
|
|
83
|
+
*/
|
|
84
|
+
export declare function mentionParticipantType(uid: string): "agent" | "human";
|
|
85
|
+
/**
|
|
86
|
+
* Extract `@name` tokens from a message body — leading or inline. A token is an
|
|
87
|
+
* `@` at a WORD BOUNDARY (start of body, or after whitespace or an opening
|
|
88
|
+
* bracket/quote) followed by a name run. Quote the `@` for a multi-word display
|
|
89
|
+
* name: `@"Jacob Posel"`. Pure → unit-testable.
|
|
90
|
+
*
|
|
91
|
+
* 'hi @Izzy please look' → ["Izzy"]
|
|
92
|
+
* '@"Jacob Posel" ping' → ["Jacob Posel"]
|
|
93
|
+
* 'email a@b.com' → [] (not a word boundary)
|
|
94
|
+
* 'cost is @ the top' → [] (bare @)
|
|
95
|
+
* '@izzy and @Izzy' → ["izzy"] (deduped case-insensitively)
|
|
96
|
+
*
|
|
97
|
+
* Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
|
|
98
|
+
* polluted by the sentence around it.
|
|
99
|
+
*/
|
|
100
|
+
export declare function parseMentionTokens(body: string): string[];
|
|
101
|
+
/**
|
|
102
|
+
* The names one roster row answers to, lowercased: its display name, the slug of
|
|
103
|
+
* that display name, any server-supplied slug/handle, the email local part, and
|
|
104
|
+
* the first word of a multi-word display name (so `@Izzy` reaches "Izzy
|
|
105
|
+
* Rivera"). A key shared by two rows makes the token AMBIGUOUS, which fails the
|
|
106
|
+
* send — never a silent pick. Pure → unit-testable.
|
|
107
|
+
*/
|
|
108
|
+
export declare function memberMatchKeys(member: ChannelMember): string[];
|
|
109
|
+
export type MentionResolution = {
|
|
110
|
+
ok: true;
|
|
111
|
+
mentions: ChannelMention[];
|
|
112
|
+
} | {
|
|
113
|
+
ok: false;
|
|
114
|
+
error: string;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Resolve `@name` tokens against the channel roster into the structured
|
|
118
|
+
* `mentions` array. Case-insensitive on display name and slug/handle. Anything
|
|
119
|
+
* ambiguous (several roster rows answer to the name) or unresolvable (none do)
|
|
120
|
+
* FAILS with a message naming the candidates or the roster — the caller must
|
|
121
|
+
* never post a message whose "@" text mentions nobody. Pure → unit-testable.
|
|
122
|
+
*/
|
|
123
|
+
export declare function resolveMentions(tokens: string[], members: ChannelMember[]): MentionResolution;
|
|
60
124
|
/**
|
|
61
125
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
62
126
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
package/dist/commands/dm.js
CHANGED
|
@@ -101,6 +101,157 @@ export function matchChannelsByName(channels, name) {
|
|
|
101
101
|
return false;
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
|
+
// ─── Channel @mentions ───────────────────────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// HQ Rooms mentions are STRUCTURED ONLY: the server never parses `@name` out of
|
|
107
|
+
// the message body (see hq-pro src/vault-service/lib/room-agent-delivery.ts and
|
|
108
|
+
// parseChannelMentions / resolveChannelMentions in
|
|
109
|
+
// src/vault-service/handlers/notify-dm.ts). A post whose body says "@Izzy please
|
|
110
|
+
// look" but carries no `mentions` array mentions NOBODY — no notification, no
|
|
111
|
+
// agent wake. That is a silent false negative during an incident, so the CLI
|
|
112
|
+
// resolves the names itself and sends the structured array. Resolution failures
|
|
113
|
+
// FAIL the send rather than posting a message that looks addressed but isn't.
|
|
114
|
+
/** Server-side cap on one post's mentions (hq-pro MAX_CHANNEL_MENTIONS). */
|
|
115
|
+
export const MAX_CHANNEL_MENTIONS = 25;
|
|
116
|
+
/**
|
|
117
|
+
* `agt_*` uids are agents, everything else (`prs_*`) is a human. The server
|
|
118
|
+
* 400s INVALID_MENTIONS when the type and the uid prefix disagree, so this is
|
|
119
|
+
* derived, never guessed.
|
|
120
|
+
*/
|
|
121
|
+
export function mentionParticipantType(uid) {
|
|
122
|
+
return uid.startsWith("agt_") ? "agent" : "human";
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Extract `@name` tokens from a message body — leading or inline. A token is an
|
|
126
|
+
* `@` at a WORD BOUNDARY (start of body, or after whitespace or an opening
|
|
127
|
+
* bracket/quote) followed by a name run. Quote the `@` for a multi-word display
|
|
128
|
+
* name: `@"Jacob Posel"`. Pure → unit-testable.
|
|
129
|
+
*
|
|
130
|
+
* 'hi @Izzy please look' → ["Izzy"]
|
|
131
|
+
* '@"Jacob Posel" ping' → ["Jacob Posel"]
|
|
132
|
+
* 'email a@b.com' → [] (not a word boundary)
|
|
133
|
+
* 'cost is @ the top' → [] (bare @)
|
|
134
|
+
* '@izzy and @Izzy' → ["izzy"] (deduped case-insensitively)
|
|
135
|
+
*
|
|
136
|
+
* Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
|
|
137
|
+
* polluted by the sentence around it.
|
|
138
|
+
*/
|
|
139
|
+
export function parseMentionTokens(body) {
|
|
140
|
+
const pattern = /(^|[\s([{<])@(?:"([^"\n]{1,80})"|([A-Za-z0-9][A-Za-z0-9._-]*))/g;
|
|
141
|
+
const names = [];
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
let match;
|
|
144
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
145
|
+
const raw = match[2] ?? match[3] ?? "";
|
|
146
|
+
// Only a bare name run can pick up sentence punctuation; a quoted name is
|
|
147
|
+
// taken exactly as written.
|
|
148
|
+
const name = (match[2] !== undefined ? raw : raw.replace(/[._-]+$/, "")).trim();
|
|
149
|
+
if (!name)
|
|
150
|
+
continue;
|
|
151
|
+
const key = name.toLowerCase();
|
|
152
|
+
if (seen.has(key))
|
|
153
|
+
continue;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
names.push(name);
|
|
156
|
+
}
|
|
157
|
+
return names;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The names one roster row answers to, lowercased: its display name, the slug of
|
|
161
|
+
* that display name, any server-supplied slug/handle, the email local part, and
|
|
162
|
+
* the first word of a multi-word display name (so `@Izzy` reaches "Izzy
|
|
163
|
+
* Rivera"). A key shared by two rows makes the token AMBIGUOUS, which fails the
|
|
164
|
+
* send — never a silent pick. Pure → unit-testable.
|
|
165
|
+
*/
|
|
166
|
+
export function memberMatchKeys(member) {
|
|
167
|
+
const keys = new Set();
|
|
168
|
+
const add = (value) => {
|
|
169
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
170
|
+
if (v)
|
|
171
|
+
keys.add(v);
|
|
172
|
+
};
|
|
173
|
+
const displayName = (member.displayName ?? "").trim();
|
|
174
|
+
add(displayName);
|
|
175
|
+
add(channelSlug(displayName));
|
|
176
|
+
add(member.slug);
|
|
177
|
+
add(member.handle);
|
|
178
|
+
add((member.email ?? "").split("@")[0]);
|
|
179
|
+
const firstWord = displayName.split(/\s+/)[0];
|
|
180
|
+
if (firstWord && firstWord !== displayName) {
|
|
181
|
+
add(firstWord);
|
|
182
|
+
add(channelSlug(firstWord));
|
|
183
|
+
}
|
|
184
|
+
return [...keys];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Resolve `@name` tokens against the channel roster into the structured
|
|
188
|
+
* `mentions` array. Case-insensitive on display name and slug/handle. Anything
|
|
189
|
+
* ambiguous (several roster rows answer to the name) or unresolvable (none do)
|
|
190
|
+
* FAILS with a message naming the candidates or the roster — the caller must
|
|
191
|
+
* never post a message whose "@" text mentions nobody. Pure → unit-testable.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveMentions(tokens, members) {
|
|
194
|
+
if (tokens.length === 0)
|
|
195
|
+
return { ok: true, mentions: [] };
|
|
196
|
+
const byKey = new Map();
|
|
197
|
+
for (const member of members) {
|
|
198
|
+
if (!member.personUid)
|
|
199
|
+
continue;
|
|
200
|
+
for (const key of memberMatchKeys(member)) {
|
|
201
|
+
const bucket = byKey.get(key);
|
|
202
|
+
if (bucket)
|
|
203
|
+
bucket.push(member);
|
|
204
|
+
else
|
|
205
|
+
byKey.set(key, [member]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const rosterNames = members
|
|
209
|
+
.map((m) => (m.displayName ?? "").trim() || m.personUid)
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.sort((a, b) => a.localeCompare(b));
|
|
212
|
+
const mentions = [];
|
|
213
|
+
const claimed = new Set();
|
|
214
|
+
for (const token of tokens) {
|
|
215
|
+
const matches = byKey.get(token.trim().toLowerCase()) ?? [];
|
|
216
|
+
const unique = [...new Map(matches.map((m) => [m.personUid, m])).values()];
|
|
217
|
+
if (unique.length === 0) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: `No channel member matches '@${token}'. Members: ` +
|
|
221
|
+
`${rosterNames.join(", ") || "(none)"}. ` +
|
|
222
|
+
`Fix the name, or pass --no-mentions to post the text literally.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (unique.length > 1) {
|
|
226
|
+
const candidates = unique
|
|
227
|
+
.map((m) => `${(m.displayName ?? "").trim() || m.personUid} (${m.personUid})`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
error: `'@${token}' is ambiguous — it matches ${unique.length} members: ${candidates}. ` +
|
|
232
|
+
`Use the full name in quotes (e.g. @"Full Name"), or pass --no-mentions ` +
|
|
233
|
+
`to post the text literally.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const member = unique[0];
|
|
237
|
+
if (claimed.has(member.personUid))
|
|
238
|
+
continue;
|
|
239
|
+
claimed.add(member.personUid);
|
|
240
|
+
mentions.push({
|
|
241
|
+
participantUid: member.personUid,
|
|
242
|
+
participantType: mentionParticipantType(member.personUid),
|
|
243
|
+
displayName: (member.displayName ?? "").trim() || member.personUid,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (mentions.length > MAX_CHANNEL_MENTIONS) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: `That message mentions ${mentions.length} members; the server accepts at most ` +
|
|
250
|
+
`${MAX_CHANNEL_MENTIONS}. Trim the mentions, or pass --no-mentions.`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, mentions };
|
|
254
|
+
}
|
|
104
255
|
/**
|
|
105
256
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
106
257
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
|
@@ -336,6 +487,23 @@ async function fetchChannels(token) {
|
|
|
336
487
|
const data = (await res.json());
|
|
337
488
|
return data.channels ?? [];
|
|
338
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Fetch a channel's roster (GET /v1/notify/channels/{id}/members). Any member
|
|
492
|
+
* who can read the channel may read its members, so this is the same gate the
|
|
493
|
+
* post itself passes. Used to resolve `@name` mentions into participant uids.
|
|
494
|
+
*/
|
|
495
|
+
async function fetchChannelMembers(token, channelId) {
|
|
496
|
+
const res = await vaultApiFetch({
|
|
497
|
+
token,
|
|
498
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/members`,
|
|
499
|
+
});
|
|
500
|
+
if (!res.ok) {
|
|
501
|
+
const err = (await res.json().catch(() => ({})));
|
|
502
|
+
throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
|
|
503
|
+
}
|
|
504
|
+
const data = (await res.json());
|
|
505
|
+
return data.members ?? [];
|
|
506
|
+
}
|
|
339
507
|
/**
|
|
340
508
|
* Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
|
|
341
509
|
* `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
|
|
@@ -373,18 +541,38 @@ async function runChannelSend(channelName, message, opts) {
|
|
|
373
541
|
process.exit(1);
|
|
374
542
|
}
|
|
375
543
|
const channel = matches[0];
|
|
544
|
+
// Mentions are structured-only server-side: resolve `@name` against the
|
|
545
|
+
// roster and send the array, or fail before posting. --no-mentions opts out
|
|
546
|
+
// and posts the text literally.
|
|
547
|
+
let mentions = [];
|
|
548
|
+
if (opts.mentions !== false) {
|
|
549
|
+
const tokens = parseMentionTokens(body);
|
|
550
|
+
if (tokens.length > 0) {
|
|
551
|
+
const members = await fetchChannelMembers(token, channel.channelId);
|
|
552
|
+
const resolved = resolveMentions(tokens, members);
|
|
553
|
+
if (!resolved.ok) {
|
|
554
|
+
console.error(chalk.red(resolved.error));
|
|
555
|
+
process.exit(1);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
mentions = resolved.mentions;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
376
561
|
const sendRes = await vaultApiFetch({
|
|
377
562
|
token,
|
|
378
563
|
path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
|
|
379
564
|
method: "POST",
|
|
380
|
-
body: { body },
|
|
565
|
+
body: mentions.length > 0 ? { body, mentions } : { body },
|
|
381
566
|
});
|
|
382
567
|
if (!sendRes.ok) {
|
|
383
568
|
const err = (await sendRes.json().catch(() => ({})));
|
|
384
569
|
console.error(chalk.red(friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText)));
|
|
385
570
|
process.exit(1);
|
|
386
571
|
}
|
|
387
|
-
|
|
572
|
+
const mentionSuffix = mentions.length > 0
|
|
573
|
+
? ` Mentioned ${mentions.map((m) => m.displayName).join(", ")}.`
|
|
574
|
+
: "";
|
|
575
|
+
console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.${mentionSuffix}`));
|
|
388
576
|
}
|
|
389
577
|
catch (err) {
|
|
390
578
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
@@ -781,8 +969,9 @@ export function registerDmCommand(program) {
|
|
|
781
969
|
.description("Send and read direct messages, and manage connection requests.");
|
|
782
970
|
dm
|
|
783
971
|
.command("send [recipient] [message]", { isDefault: true, hidden: true })
|
|
784
|
-
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels
|
|
972
|
+
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @"Jacob Posel") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare "@" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.')
|
|
785
973
|
.option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
|
|
974
|
+
.option("--no-mentions", "Do not resolve `@name` in a channel message into structured mentions — post the text literally")
|
|
786
975
|
.option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
|
|
787
976
|
.option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
|
|
788
977
|
.option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -52,14 +52,30 @@ export declare function mapSkillError(status: number, body: Record<string, unkno
|
|
|
52
52
|
* 403, 404, 409, 429 -> ExpectedUserError (HQ-CLI-6: printed, exit 1, not captured)
|
|
53
53
|
* everything else -> unmarked Error (still captured to Sentry)
|
|
54
54
|
*
|
|
55
|
-
* The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
55
|
+
* The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range, and
|
|
56
|
+
* every 5xx is a real server fault that must keep reaching Sentry so a genuine
|
|
57
|
+
* defect is never hidden behind a "caller error" label (HQ-CLI-Z, Sentry
|
|
58
|
+
* 7694457056). Before this, both call sites threw a bare `new Error(...)`, so a
|
|
59
|
+
* correctly-denied 403 (e.g. an agent principal with no person entity) fell
|
|
60
|
+
* through the boundary's closed allowlist and filed a crash for what hq-pro
|
|
61
|
+
* itself deliberately exempts from Sentry capture.
|
|
62
|
+
*
|
|
63
|
+
* A 400 is DISCRIMINATED BY ITS MACHINE-READABLE `code`, never by status alone
|
|
64
|
+
* and never by the prose (Sentry 7710011866). On the register route the
|
|
65
|
+
* observed 400s reject the CALLER'S OWN SKILL.md CONTENT
|
|
66
|
+
* (`SKILL_REGISTER_FRONTMATTER_INVALID`, `SKILL_REGISTER_TOO_LARGE`, …) — the
|
|
67
|
+
* caller fixes the file and retries; there is no hq-cli defect and no operator
|
|
68
|
+
* action — and hq-pro returns each of those through its NON-capturing
|
|
69
|
+
* `expectedValidationResponse` builder, so hq-cli was the only party filing a
|
|
70
|
+
* crash for them. Those codes (see `CALLER_CONTENT_REGISTER_400_CODES`) are
|
|
71
|
+
* marked `expected`. A 400 whose `code` is absent, non-string, or unlisted —
|
|
72
|
+
* including `SKILL_REGISTER_PATH_INVALID`, the ONE register field hq-cli builds
|
|
73
|
+
* itself (`path: skills/<slug>/SKILL.md`) — stays an unmarked, captured Error,
|
|
74
|
+
* as does every 422: an envelope the CLI built wrong is a genuine hq-cli defect.
|
|
75
|
+
* Client-side suppression cannot blind a real fault: hq-pro retains its
|
|
76
|
+
* capturing `response()` builder for every register 400 it still considers a
|
|
77
|
+
* fault, so this only stops hq-cli from double-filing what the server already
|
|
78
|
+
* exempts.
|
|
63
79
|
*
|
|
64
80
|
* `opts.machineIdentity` distinguishes a company agent (`isMachineIdentity()`).
|
|
65
81
|
* A machine session mints automatically and is never repaired by `hq login`, so
|
package/dist/commands/skill.js
CHANGED
|
@@ -18,6 +18,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
|
|
|
18
18
|
import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
|
|
19
19
|
import { AuthError } from "../utils/auth-error.js";
|
|
20
20
|
import { redactErrorText } from "../utils/redact-error-text.js";
|
|
21
|
+
import { stampVaultAccessDenied, vaultAccessDeniedDiagnostics, } from "../utils/vault-access-denied-error.js";
|
|
21
22
|
export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
|
|
22
23
|
export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
23
24
|
const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -208,6 +209,36 @@ export function mapSkillError(status, body) {
|
|
|
208
209
|
return `Server error: ${server || status}`;
|
|
209
210
|
return server || `Request failed (${status})`;
|
|
210
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* The CLOSED, ENUMERATED set of skills-API register `code` values whose 400
|
|
214
|
+
* refuses the CALLER-AUTHORED SKILL.md CONTENT rather than the CLI-built request
|
|
215
|
+
* envelope. hq-pro returns each of these through its NON-capturing
|
|
216
|
+
* `expectedValidationResponse` builder (hq-pro src/vault-service/write-routes.ts)
|
|
217
|
+
* — a deliberate, correctly-handled client 400 where the caller fixes the file
|
|
218
|
+
* and retries and there is no operator action:
|
|
219
|
+
*
|
|
220
|
+
* SKILL_REGISTER_FRONTMATTER_INVALID `SKILL.md must contain valid YAML frontmatter`
|
|
221
|
+
* SKILL_REGISTER_TOO_LARGE `Skill content exceeds <N> bytes`
|
|
222
|
+
* SKILL_REGISTER_CONTENT_INVALID malformed skill body / structure
|
|
223
|
+
* SKILL_REGISTER_METADATA_INVALID bad frontmatter metadata (e.g. description)
|
|
224
|
+
* SKILL_REGISTER_GOVERNANCE_INVALID `normalizeSkillTags` rejected the caller's tags
|
|
225
|
+
*
|
|
226
|
+
* DELIBERATELY EXCLUDES `SKILL_REGISTER_PATH_INVALID`: the `path` field is built
|
|
227
|
+
* by hq-cli (`skills/${slug}/SKILL.md`), so a rejection there is a genuine
|
|
228
|
+
* hq-cli defect and must keep reaching Sentry. The two evidenced codes are the
|
|
229
|
+
* first two; the rest are the register route's other caller-content validators,
|
|
230
|
+
* covered so a content refusal never files a crash. Client suppression is safe
|
|
231
|
+
* either way — hq-pro still captures every register 400 it classifies as a
|
|
232
|
+
* fault through its own `response()` builder, so narrowing this set to the two
|
|
233
|
+
* evidenced codes would only re-expose siblings the server already exempts.
|
|
234
|
+
*/
|
|
235
|
+
const CALLER_CONTENT_REGISTER_400_CODES = new Set([
|
|
236
|
+
"SKILL_REGISTER_FRONTMATTER_INVALID",
|
|
237
|
+
"SKILL_REGISTER_TOO_LARGE",
|
|
238
|
+
"SKILL_REGISTER_CONTENT_INVALID",
|
|
239
|
+
"SKILL_REGISTER_METADATA_INVALID",
|
|
240
|
+
"SKILL_REGISTER_GOVERNANCE_INVALID",
|
|
241
|
+
]);
|
|
211
242
|
/**
|
|
212
243
|
* Type a failed skills-API response by its HTTP status so the top-level error
|
|
213
244
|
* boundary can tell a correctly-denied client 4xx (ordinary caller state) from a
|
|
@@ -220,14 +251,30 @@ export function mapSkillError(status, body) {
|
|
|
220
251
|
* 403, 404, 409, 429 -> ExpectedUserError (HQ-CLI-6: printed, exit 1, not captured)
|
|
221
252
|
* everything else -> unmarked Error (still captured to Sentry)
|
|
222
253
|
*
|
|
223
|
-
* The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
254
|
+
* The 4xx allowlist is closed and ENUMERATED, not a `status < 500` range, and
|
|
255
|
+
* every 5xx is a real server fault that must keep reaching Sentry so a genuine
|
|
256
|
+
* defect is never hidden behind a "caller error" label (HQ-CLI-Z, Sentry
|
|
257
|
+
* 7694457056). Before this, both call sites threw a bare `new Error(...)`, so a
|
|
258
|
+
* correctly-denied 403 (e.g. an agent principal with no person entity) fell
|
|
259
|
+
* through the boundary's closed allowlist and filed a crash for what hq-pro
|
|
260
|
+
* itself deliberately exempts from Sentry capture.
|
|
261
|
+
*
|
|
262
|
+
* A 400 is DISCRIMINATED BY ITS MACHINE-READABLE `code`, never by status alone
|
|
263
|
+
* and never by the prose (Sentry 7710011866). On the register route the
|
|
264
|
+
* observed 400s reject the CALLER'S OWN SKILL.md CONTENT
|
|
265
|
+
* (`SKILL_REGISTER_FRONTMATTER_INVALID`, `SKILL_REGISTER_TOO_LARGE`, …) — the
|
|
266
|
+
* caller fixes the file and retries; there is no hq-cli defect and no operator
|
|
267
|
+
* action — and hq-pro returns each of those through its NON-capturing
|
|
268
|
+
* `expectedValidationResponse` builder, so hq-cli was the only party filing a
|
|
269
|
+
* crash for them. Those codes (see `CALLER_CONTENT_REGISTER_400_CODES`) are
|
|
270
|
+
* marked `expected`. A 400 whose `code` is absent, non-string, or unlisted —
|
|
271
|
+
* including `SKILL_REGISTER_PATH_INVALID`, the ONE register field hq-cli builds
|
|
272
|
+
* itself (`path: skills/<slug>/SKILL.md`) — stays an unmarked, captured Error,
|
|
273
|
+
* as does every 422: an envelope the CLI built wrong is a genuine hq-cli defect.
|
|
274
|
+
* Client-side suppression cannot blind a real fault: hq-pro retains its
|
|
275
|
+
* capturing `response()` builder for every register 400 it still considers a
|
|
276
|
+
* fault, so this only stops hq-cli from double-filing what the server already
|
|
277
|
+
* exempts.
|
|
231
278
|
*
|
|
232
279
|
* `opts.machineIdentity` distinguishes a company agent (`isMachineIdentity()`).
|
|
233
280
|
* A machine session mints automatically and is never repaired by `hq login`, so
|
|
@@ -255,6 +302,19 @@ export function skillApiError(status, body, opts = {}) {
|
|
|
255
302
|
if (status === 401) {
|
|
256
303
|
return opts.machineIdentity ? new Error(message) : new AuthError(message);
|
|
257
304
|
}
|
|
305
|
+
if (status === 400 &&
|
|
306
|
+
typeof body.code === "string" &&
|
|
307
|
+
CALLER_CONTENT_REGISTER_400_CODES.has(body.code)) {
|
|
308
|
+
// A register 400 refusing the caller's own SKILL.md content — expected
|
|
309
|
+
// caller state hq-pro itself does not capture (see the code allowlist
|
|
310
|
+
// above). The message is the UNCHANGED `redactErrorText(mapSkillError(...))`
|
|
311
|
+
// remedy; only the class changes. A 400 with an absent, non-string, or
|
|
312
|
+
// unlisted `code` (including the CLI-built `SKILL_REGISTER_PATH_INVALID`)
|
|
313
|
+
// falls through to the unmarked, captured Error below.
|
|
314
|
+
return Object.assign(new Error(message), {
|
|
315
|
+
expected: true,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
258
318
|
if (status === 403 || status === 404 || status === 409 || status === 429) {
|
|
259
319
|
return Object.assign(new Error(message), {
|
|
260
320
|
expected: true,
|
|
@@ -387,6 +447,23 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
387
447
|
// sync failure exactly as it does everywhere else, and a genuine
|
|
388
448
|
// fault is captured under its own type and stack.
|
|
389
449
|
console.warn(chalk.yellow(`⚠ Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync failed.`));
|
|
450
|
+
// ARM B (Sentry 7709408531): when the upload was DENIED by S3 (an
|
|
451
|
+
// AWS SDK v3 403 from a truncated IAM session policy that dropped the
|
|
452
|
+
// skills/ prefix), stamp bounded, hq-derived diagnostics onto the
|
|
453
|
+
// ORIGINAL error so the boundary can print an attributable remedy and
|
|
454
|
+
// capture WITH a bounded context. This is additive only: a
|
|
455
|
+
// non-enumerable field, no new Error, no change to `message`/`name`/
|
|
456
|
+
// `cause` — rewrapping is the exact HQ-CLI-14 defect this block
|
|
457
|
+
// prevents. A non-403 sync failure is left untouched and captures (or
|
|
458
|
+
// is suppressed) under its own type exactly as before.
|
|
459
|
+
if (err && typeof err === "object") {
|
|
460
|
+
const diagnostics = vaultAccessDeniedDiagnostics(err, {
|
|
461
|
+
companySlug,
|
|
462
|
+
objectKey: `skills/${slug}/SKILL.md`,
|
|
463
|
+
});
|
|
464
|
+
if (diagnostics)
|
|
465
|
+
stampVaultAccessDenied(err, diagnostics);
|
|
466
|
+
}
|
|
390
467
|
throw err;
|
|
391
468
|
}
|
|
392
469
|
if (syncResult.aborted) {
|
package/dist/main.js
CHANGED
|
@@ -45,6 +45,7 @@ import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
|
45
45
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
46
46
|
import { kickFlagRegistryReadiness } from "./lib/flag-registry.js";
|
|
47
47
|
import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
48
|
+
import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
|
|
48
49
|
import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
49
50
|
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
50
51
|
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
@@ -335,6 +336,27 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
335
336
|
});
|
|
336
337
|
deps.setExitCode(1);
|
|
337
338
|
}
|
|
339
|
+
else if (isVaultAccessDeniedError(err)) {
|
|
340
|
+
// ARM B (Sentry 7709408531): `hq skill create`'s post-register vault sync
|
|
341
|
+
// was DENIED by S3 — an AWS SDK v3 403 from an hq-pro IAM session-policy
|
|
342
|
+
// truncation that dropped the skills/ prefix. Because the HEAD carried no
|
|
343
|
+
// body, the SDK minted an untyped `Unknown http=403 UnknownError` with a
|
|
344
|
+
// system-only stack, so it reached the final else, printed
|
|
345
|
+
// `hq: Unknown: Unknown http=403 UnknownError`, and captured an
|
|
346
|
+
// un-attributable event. skill.ts stamped bounded, hq-derived diagnostics
|
|
347
|
+
// onto the error at the failure site; print the attributable remedy naming
|
|
348
|
+
// the object and company, and STILL capture WITH that bounded context —
|
|
349
|
+
// this is a real hq-pro platform fault that must stay REPORTED and become
|
|
350
|
+
// actionable, never silenced. Mirrors the package-root branch's
|
|
351
|
+
// print-and-capture shape. Keyed on hq-cli's own stamped field, so it is
|
|
352
|
+
// disjoint from every neighbour and changes no existing ordering.
|
|
353
|
+
const { vaultAccessDenied } = err;
|
|
354
|
+
deps.stderr.write(`hq: ${vaultAccessDeniedMessage(vaultAccessDenied)}\n`);
|
|
355
|
+
deps.sentry.captureException(err, {
|
|
356
|
+
contexts: { vault_access_denied: vaultAccessDenied },
|
|
357
|
+
});
|
|
358
|
+
deps.setExitCode(1);
|
|
359
|
+
}
|
|
338
360
|
else {
|
|
339
361
|
// A full disk / exhausted quota / read-only filesystem is the user's
|
|
340
362
|
// machine, not an HQ code defect. Surface a clear, actionable message and
|
|
@@ -128,8 +128,17 @@ export interface ClientHealthHeartbeat {
|
|
|
128
128
|
syncState: ClientHealthSyncState;
|
|
129
129
|
/** Last time a sync RUN started — distinct from success. */
|
|
130
130
|
lastSyncAttemptAt?: string;
|
|
131
|
-
/** Advances only on genuine
|
|
131
|
+
/** Advances only on a genuine COMPLETED run (including no-change runs). Never the engine watermark. */
|
|
132
132
|
lastSyncSuccessAt?: string;
|
|
133
|
+
/**
|
|
134
|
+
* Sync-engine per-file journal high-water mark (hq-cloud `journal.lastSync`).
|
|
135
|
+
* Advances whenever ANY file moves — including on a run that later FAILS —
|
|
136
|
+
* so it is NOT proof a run completed and must never be read as a completed
|
|
137
|
+
* success. Distinct from `lastSyncSuccessAt`, and never folded into it.
|
|
138
|
+
* Absent on clients with no local sync engine (desktop) and on clients that
|
|
139
|
+
* predate this field — absence means "unknown", not "no activity".
|
|
140
|
+
*/
|
|
141
|
+
syncEngineWatermarkAt?: string;
|
|
133
142
|
consecutiveFailures: number;
|
|
134
143
|
conflictCount?: number;
|
|
135
144
|
updaterState?: ClientHealthUpdaterState;
|
|
@@ -253,6 +253,8 @@ export function parseClientHealthHeartbeat(input) {
|
|
|
253
253
|
heartbeat.lastSyncAttemptAt = assertIsoUtc("lastSyncAttemptAt", raw.lastSyncAttemptAt);
|
|
254
254
|
if (raw.lastSyncSuccessAt !== undefined)
|
|
255
255
|
heartbeat.lastSyncSuccessAt = assertIsoUtc("lastSyncSuccessAt", raw.lastSyncSuccessAt);
|
|
256
|
+
if (raw.syncEngineWatermarkAt !== undefined)
|
|
257
|
+
heartbeat.syncEngineWatermarkAt = assertIsoUtc("syncEngineWatermarkAt", raw.syncEngineWatermarkAt);
|
|
256
258
|
if (raw.conflictCount !== undefined) {
|
|
257
259
|
heartbeat.conflictCount = assertBoundedInt("conflictCount", raw.conflictCount, CLIENT_HEALTH_MAX_CONFLICT_COUNT);
|
|
258
260
|
}
|
|
@@ -335,7 +335,13 @@ export function buildCliHeartbeat(input) {
|
|
|
335
335
|
if (input.versions.sync && SEMVER.test(input.versions.sync)) {
|
|
336
336
|
versions.desktop = input.versions.sync;
|
|
337
337
|
}
|
|
338
|
-
|
|
338
|
+
// `lastSyncSuccessAt` is ONLY this CLI's own completed-run success — never
|
|
339
|
+
// the engine journal watermark. The watermark is carried on its own field
|
|
340
|
+
// (`syncEngineWatermarkAt`) so support can tell a completed run from mere
|
|
341
|
+
// per-file engine movement; folding the two would fabricate a completed-run
|
|
342
|
+
// success out of a run that may have failed (US-019).
|
|
343
|
+
const lastSyncSuccessAt = input.state.lastSyncSuccessAt ?? null;
|
|
344
|
+
const syncEngineWatermarkAt = input.journalLastSyncAt;
|
|
339
345
|
let syncState;
|
|
340
346
|
switch (input.kind) {
|
|
341
347
|
case "sync_attempt":
|
|
@@ -351,17 +357,20 @@ export function buildCliHeartbeat(input) {
|
|
|
351
357
|
// overwrote the `error` state the failing sync had reported while
|
|
352
358
|
// leaving the streak that earned it untouched.
|
|
353
359
|
//
|
|
354
|
-
// Only this CLI's own `sync_success` clears the streak. The
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
360
|
+
// Only this CLI's own `sync_success` clears the streak. The engine
|
|
361
|
+
// journal watermark (`syncEngineWatermarkAt`) deliberately does NOT: the
|
|
362
|
+
// engine stamps it per FILE update (hq-cloud journal.ts `updateEntry`),
|
|
363
|
+
// and a push stamps it before throwing its upload worker errors — so it
|
|
364
|
+
// means "some file moved", not "a run succeeded", and must never clear an
|
|
365
|
+
// alarm counter. `never_synced` still keys off ANY observed sync activity
|
|
366
|
+
// (a completed run OR engine movement); it only means this installation
|
|
367
|
+
// has never moved a byte.
|
|
361
368
|
if (input.state.consecutiveFailures > 0)
|
|
362
369
|
syncState = "error";
|
|
363
|
-
else
|
|
364
|
-
|
|
370
|
+
else {
|
|
371
|
+
const hasSyncedSomething = lastSyncSuccessAt !== null || syncEngineWatermarkAt !== null;
|
|
372
|
+
syncState = hasSyncedSomething ? "idle" : "never_synced";
|
|
373
|
+
}
|
|
365
374
|
break;
|
|
366
375
|
}
|
|
367
376
|
const heartbeat = {
|
|
@@ -382,20 +391,15 @@ export function buildCliHeartbeat(input) {
|
|
|
382
391
|
if (lastSyncSuccessAt !== null) {
|
|
383
392
|
heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
|
|
384
393
|
}
|
|
394
|
+
if (syncEngineWatermarkAt !== null) {
|
|
395
|
+
heartbeat.syncEngineWatermarkAt = syncEngineWatermarkAt;
|
|
396
|
+
}
|
|
385
397
|
if ((input.kind === "sync_success" || input.kind === "sync_failure") &&
|
|
386
398
|
input.localFilesOverview) {
|
|
387
399
|
heartbeat.localFilesOverview = input.localFilesOverview;
|
|
388
400
|
}
|
|
389
401
|
return heartbeat;
|
|
390
402
|
}
|
|
391
|
-
function latestIso(a, b) {
|
|
392
|
-
const times = [a, b]
|
|
393
|
-
.map((value) => (typeof value === "string" ? Date.parse(value) : NaN))
|
|
394
|
-
.filter((value) => Number.isFinite(value));
|
|
395
|
-
if (times.length === 0)
|
|
396
|
-
return null;
|
|
397
|
-
return new Date(Math.max(...times)).toISOString();
|
|
398
|
-
}
|
|
399
403
|
const defaultPoster = (heartbeat, token, signal) => vaultApiFetch({
|
|
400
404
|
token,
|
|
401
405
|
path: "/v1/client-health/heartbeat",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The finite, hq-derived diagnostics for a denied vault object write. A `type`
|
|
3
|
+
* (not an `interface`) so it carries the implicit index signature Sentry's
|
|
4
|
+
* `Contexts` requires, exactly as `PackageRootResolutionDiagnostics` does.
|
|
5
|
+
*/
|
|
6
|
+
export type VaultAccessDeniedDiagnostics = {
|
|
7
|
+
/** The only operation that stamps this today; a closed literal, not free text. */
|
|
8
|
+
operation: "skill-sync";
|
|
9
|
+
/** hq-derived, already `COMPANY_SLUG_PATTERN`-validated at the call site. */
|
|
10
|
+
companySlug: string;
|
|
11
|
+
/** hq-built vault key (`skills/<slug>/SKILL.md`); slug is pattern-validated. */
|
|
12
|
+
objectKey: string;
|
|
13
|
+
/** Always 403 here; kept explicit so the context self-describes. */
|
|
14
|
+
httpStatusCode: number;
|
|
15
|
+
/** AWS request correlation id (`$metadata.requestId`), or null when absent. */
|
|
16
|
+
requestId: string | null;
|
|
17
|
+
/** AWS extended request id (`$metadata.extendedRequestId`), or null. */
|
|
18
|
+
extendedRequestId: string | null;
|
|
19
|
+
};
|
|
20
|
+
/** The hq-derived context the sync failure site already holds. */
|
|
21
|
+
export interface VaultAccessDeniedHint {
|
|
22
|
+
companySlug: string;
|
|
23
|
+
objectKey: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* If `err` is an AWS SDK v3 authorization failure — an object whose
|
|
27
|
+
* `$metadata.httpStatusCode` is 403 — return bounded diagnostics built from the
|
|
28
|
+
* hq-derived `hint` plus the AWS-minted request identifiers; otherwise `null`.
|
|
29
|
+
*
|
|
30
|
+
* A null result means "not an S3 403" and the caller must leave the error
|
|
31
|
+
* un-stamped so it captures under its own type, exactly as today.
|
|
32
|
+
*/
|
|
33
|
+
export declare function vaultAccessDeniedDiagnostics(err: unknown, hint: VaultAccessDeniedHint): VaultAccessDeniedDiagnostics | null;
|
|
34
|
+
/**
|
|
35
|
+
* Stamp bounded diagnostics onto the ORIGINAL sync error as a NON-ENUMERABLE
|
|
36
|
+
* field, preserving the error's identity, `name`, `message` and `cause`
|
|
37
|
+
* (HQ-CLI-14 forbids rewrapping). Non-enumerable so the field never widens the
|
|
38
|
+
* error's own serialization, a `JSON.stringify`, or Sentry's default capture.
|
|
39
|
+
*/
|
|
40
|
+
export declare function stampVaultAccessDenied(err: object, diagnostics: VaultAccessDeniedDiagnostics): void;
|
|
41
|
+
/** The carrier the top-level boundary matches: an error hq stamped itself. */
|
|
42
|
+
export interface VaultAccessDeniedError {
|
|
43
|
+
vaultAccessDenied: VaultAccessDeniedDiagnostics;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* True when `err` carries the diagnostics {@link stampVaultAccessDenied} put
|
|
47
|
+
* there. Keyed on hq-cli's OWN stamped field (not on `$metadata`), so the
|
|
48
|
+
* boundary branch is disjoint from every neighbour and an AWS 403 that reached
|
|
49
|
+
* the boundary by some other path — un-stamped — still captures bare.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isVaultAccessDeniedError(err: unknown): err is VaultAccessDeniedError;
|
|
52
|
+
/**
|
|
53
|
+
* One fixed-shape, input-free actionable line: it names the denied vault object
|
|
54
|
+
* and company, states that the local stamp succeeded, and gives the remedy
|
|
55
|
+
* (grants + re-run). Built only from hq-derived, pattern-validated fields, so —
|
|
56
|
+
* like `syncStateLockMessage` — it needs no redaction or length cap.
|
|
57
|
+
*/
|
|
58
|
+
export declare function vaultAccessDeniedMessage(diagnostics: VaultAccessDeniedDiagnostics): string;
|
|
59
|
+
//# sourceMappingURL=vault-access-denied-error.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// src/utils/vault-access-denied-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Recognise — and make ATTRIBUTABLE — an AWS S3 AUTHORIZATION failure raised
|
|
4
|
+
// inside `hq skill create`'s post-register vault sync. Sibling in spirit to
|
|
5
|
+
// `sync-state-lock-error.ts` (HQ-CLI-14) and `package-root-diagnostics.ts`: a
|
|
6
|
+
// structurally-matched carrier plus a bounded, hq-derived diagnostics payload.
|
|
7
|
+
//
|
|
8
|
+
// UNLIKE the suppression siblings, this class stays CAPTURED. The reported
|
|
9
|
+
// event (Sentry 7709408531) is a REAL platform fault: hq-pro's STS vendor
|
|
10
|
+
// TRUNCATED the IAM session policy and dropped the very `skills/` prefix being
|
|
11
|
+
// written, so S3 answered the sync HEAD with 403. Because a HEAD carries no
|
|
12
|
+
// body, the AWS SDK v3 could not parse an XML error code and minted an untyped
|
|
13
|
+
// `Unknown http=403 UnknownError` whose `$metadata.httpStatusCode` is 403 and
|
|
14
|
+
// whose stack is system-only. That reached the top-level boundary's final else,
|
|
15
|
+
// which printed `hq: Unknown: Unknown http=403 UnknownError` and captured an
|
|
16
|
+
// event carrying nothing that identifies the object, the company, or the
|
|
17
|
+
// truncation. This module does NOT silence the 403 — it keeps it reported while
|
|
18
|
+
// giving the user an actionable line and the tracker a bounded context.
|
|
19
|
+
//
|
|
20
|
+
// Two hard rules, mirroring the diagnostics siblings:
|
|
21
|
+
// 1. STRUCTURAL match, never `instanceof`. The carrier is whatever the AWS SDK
|
|
22
|
+
// threw; the only signal read is `$metadata.httpStatusCode === 403`, so a
|
|
23
|
+
// future SDK rename cannot break it. A miss degrades to today's behaviour
|
|
24
|
+
// (still captured, just uncontextualised) — it can never suppress.
|
|
25
|
+
// 2. NO caller free text, NO secrets. The company slug and object key are
|
|
26
|
+
// hq-DERIVED (both are already pattern-validated upstream); the AWS request
|
|
27
|
+
// identifiers are opaque correlation tokens, length-bounded here. STS
|
|
28
|
+
// credentials, presigned URLs and message prose never enter the payload.
|
|
29
|
+
/** The non-enumerable field name the sync catch stamps and the boundary reads. */
|
|
30
|
+
const VAULT_ACCESS_DENIED_FIELD = "vaultAccessDenied";
|
|
31
|
+
/** Upper bound on an AWS request identifier before it enters a Sentry context. */
|
|
32
|
+
const REQUEST_ID_MAX_LENGTH = 256;
|
|
33
|
+
/** An AWS request identifier, coerced to a bounded string or null. */
|
|
34
|
+
function boundedRequestId(value) {
|
|
35
|
+
if (typeof value !== "string" || value.length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
return value.slice(0, REQUEST_ID_MAX_LENGTH);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* If `err` is an AWS SDK v3 authorization failure — an object whose
|
|
41
|
+
* `$metadata.httpStatusCode` is 403 — return bounded diagnostics built from the
|
|
42
|
+
* hq-derived `hint` plus the AWS-minted request identifiers; otherwise `null`.
|
|
43
|
+
*
|
|
44
|
+
* A null result means "not an S3 403" and the caller must leave the error
|
|
45
|
+
* un-stamped so it captures under its own type, exactly as today.
|
|
46
|
+
*/
|
|
47
|
+
export function vaultAccessDeniedDiagnostics(err, hint) {
|
|
48
|
+
if (err === null || typeof err !== "object")
|
|
49
|
+
return null;
|
|
50
|
+
const metadata = err.$metadata;
|
|
51
|
+
if (metadata === null || typeof metadata !== "object")
|
|
52
|
+
return null;
|
|
53
|
+
if (metadata.httpStatusCode !== 403) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
operation: "skill-sync",
|
|
58
|
+
companySlug: hint.companySlug,
|
|
59
|
+
objectKey: hint.objectKey,
|
|
60
|
+
httpStatusCode: 403,
|
|
61
|
+
requestId: boundedRequestId(metadata.requestId),
|
|
62
|
+
extendedRequestId: boundedRequestId(metadata.extendedRequestId),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** True only for a diagnostics object of the exact closed shape. */
|
|
66
|
+
function isVaultAccessDeniedDiagnostics(value) {
|
|
67
|
+
if (value === null || typeof value !== "object")
|
|
68
|
+
return false;
|
|
69
|
+
const d = value;
|
|
70
|
+
return (d.operation === "skill-sync" &&
|
|
71
|
+
typeof d.companySlug === "string" &&
|
|
72
|
+
typeof d.objectKey === "string" &&
|
|
73
|
+
d.httpStatusCode === 403);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Stamp bounded diagnostics onto the ORIGINAL sync error as a NON-ENUMERABLE
|
|
77
|
+
* field, preserving the error's identity, `name`, `message` and `cause`
|
|
78
|
+
* (HQ-CLI-14 forbids rewrapping). Non-enumerable so the field never widens the
|
|
79
|
+
* error's own serialization, a `JSON.stringify`, or Sentry's default capture.
|
|
80
|
+
*/
|
|
81
|
+
export function stampVaultAccessDenied(err, diagnostics) {
|
|
82
|
+
Object.defineProperty(err, VAULT_ACCESS_DENIED_FIELD, {
|
|
83
|
+
value: diagnostics,
|
|
84
|
+
enumerable: false,
|
|
85
|
+
configurable: true,
|
|
86
|
+
writable: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* True when `err` carries the diagnostics {@link stampVaultAccessDenied} put
|
|
91
|
+
* there. Keyed on hq-cli's OWN stamped field (not on `$metadata`), so the
|
|
92
|
+
* boundary branch is disjoint from every neighbour and an AWS 403 that reached
|
|
93
|
+
* the boundary by some other path — un-stamped — still captures bare.
|
|
94
|
+
*/
|
|
95
|
+
export function isVaultAccessDeniedError(err) {
|
|
96
|
+
if (err === null || typeof err !== "object")
|
|
97
|
+
return false;
|
|
98
|
+
return isVaultAccessDeniedDiagnostics(err.vaultAccessDenied);
|
|
99
|
+
}
|
|
100
|
+
/** Recover the create `<slug>` from an hq-built `skills/<slug>/SKILL.md` key. */
|
|
101
|
+
function slugFromObjectKey(objectKey) {
|
|
102
|
+
const match = /^skills\/([a-z0-9][a-z0-9-]*)\/SKILL\.md$/.exec(objectKey);
|
|
103
|
+
return match ? match[1] : null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* One fixed-shape, input-free actionable line: it names the denied vault object
|
|
107
|
+
* and company, states that the local stamp succeeded, and gives the remedy
|
|
108
|
+
* (grants + re-run). Built only from hq-derived, pattern-validated fields, so —
|
|
109
|
+
* like `syncStateLockMessage` — it needs no redaction or length cap.
|
|
110
|
+
*/
|
|
111
|
+
export function vaultAccessDeniedMessage(diagnostics) {
|
|
112
|
+
const slug = slugFromObjectKey(diagnostics.objectKey);
|
|
113
|
+
const reRun = slug
|
|
114
|
+
? `hq skill --company ${diagnostics.companySlug} create ${slug}`
|
|
115
|
+
: `hq skill --company ${diagnostics.companySlug} create <slug>`;
|
|
116
|
+
return (`The skill was stamped locally, but the vault upload of ` +
|
|
117
|
+
`'${diagnostics.objectKey}' was denied (HTTP 403) for company ` +
|
|
118
|
+
`'${diagnostics.companySlug}'. Your file grants don't cover that path. Ask ` +
|
|
119
|
+
`a ${diagnostics.companySlug} owner to confirm your file grants cover the ` +
|
|
120
|
+
`skills/ prefix, then re-run \`${reRun}\`.`);
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=vault-access-denied-error.js.map
|