@oh-hai/cli 0.2.3 → 0.3.1
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 +125 -0
- package/README.md +59 -29
- package/dist/commands/ask.js +71 -10
- package/dist/commands/ask.js.map +1 -1
- package/dist/commands/bridge.d.ts +2 -0
- package/dist/commands/bridge.js +502 -0
- package/dist/commands/bridge.js.map +1 -0
- package/dist/commands/context.d.ts +22 -0
- package/dist/commands/doctor.js +12 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/fleet.d.ts +2 -0
- package/dist/commands/fleet.js +164 -0
- package/dist/commands/fleet.js.map +1 -0
- package/dist/commands/handlers.js +8 -0
- package/dist/commands/handlers.js.map +1 -1
- package/dist/commands/messages.d.ts +2 -0
- package/dist/commands/messages.js +131 -0
- package/dist/commands/messages.js.map +1 -0
- package/dist/commands/messaging/build.d.ts +10 -1
- package/dist/commands/messaging/build.js +22 -4
- package/dist/commands/messaging/build.js.map +1 -1
- package/dist/commands/messaging/capability.d.ts +42 -0
- package/dist/commands/messaging/capability.js +164 -0
- package/dist/commands/messaging/capability.js.map +1 -0
- package/dist/commands/messaging/http.d.ts +28 -1
- package/dist/commands/messaging/http.js +234 -13
- package/dist/commands/messaging/http.js.map +1 -1
- package/dist/commands/messaging/inbox-entries.d.ts +83 -0
- package/dist/commands/messaging/inbox-entries.js +371 -0
- package/dist/commands/messaging/inbox-entries.js.map +1 -0
- package/dist/commands/messaging/inbox-stream.d.ts +37 -0
- package/dist/commands/messaging/inbox-stream.js +224 -0
- package/dist/commands/messaging/inbox-stream.js.map +1 -0
- package/dist/commands/messaging/session-drain.d.ts +50 -0
- package/dist/commands/messaging/session-drain.js +163 -0
- package/dist/commands/messaging/session-drain.js.map +1 -0
- package/dist/commands/messaging/session-scope.d.ts +93 -0
- package/dist/commands/messaging/session-scope.js +0 -0
- package/dist/commands/messaging/session-scope.js.map +1 -0
- package/dist/commands/messaging/sessions-http.d.ts +20 -0
- package/dist/commands/messaging/sessions-http.js +153 -0
- package/dist/commands/messaging/sessions-http.js.map +1 -0
- package/dist/commands/messaging/shared.d.ts +19 -1
- package/dist/commands/messaging/shared.js +69 -2
- package/dist/commands/messaging/shared.js.map +1 -1
- package/dist/commands/messaging/validate.d.ts +33 -1
- package/dist/commands/messaging/validate.js +78 -0
- package/dist/commands/messaging/validate.js.map +1 -1
- package/dist/commands/messaging/wire.d.ts +180 -3
- package/dist/commands/notify.js +47 -5
- package/dist/commands/notify.js.map +1 -1
- package/dist/commands/registry.js +128 -3
- package/dist/commands/registry.js.map +1 -1
- package/dist/commands/self-test.d.ts +17 -0
- package/dist/commands/self-test.js +472 -0
- package/dist/commands/self-test.js.map +1 -0
- package/dist/commands/session.d.ts +2 -0
- package/dist/commands/session.js +158 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/commands/setup.js +44 -0
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/task.js +61 -11
- package/dist/commands/task.js.map +1 -1
- package/dist/commands/teach.js +84 -4
- package/dist/commands/teach.js.map +1 -1
- package/dist/commands/whoami.js +14 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/exit-codes.d.ts +37 -1
- package/dist/exit-codes.js +42 -1
- package/dist/exit-codes.js.map +1 -1
- package/dist/help.js +5 -1
- package/dist/help.js.map +1 -1
- package/dist/watch-lock.js +7 -0
- package/dist/watch-lock.js.map +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The Hub capability document, and the §8.0 FEATURE-DETECTION GATE an addressed send must pass
|
|
2
|
+
// before anything is posted (issue #657).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS A PRE-FLIGHT AND NOT A POST-HOC CHECK. §10 robustness says a Hub ignores fields it does
|
|
5
|
+
// not understand — so a pre-v0.5 Hub handed an envelope carrying `to` does not refuse it. It drops
|
|
6
|
+
// the address and files the message in the HUMAN inbox. The §8.1 destination-snapshot check catches
|
|
7
|
+
// that, but only from the ACK: by then the content an agent addressed to another agent has already
|
|
8
|
+
// been disclosed to a person, and no error code un-sends it. A round-trip is a cheap price for not
|
|
9
|
+
// leaking; "the Hub's refusals are precise" was only ever true of Hubs that understand the field.
|
|
10
|
+
//
|
|
11
|
+
// WHAT IS REQUIRED, AND WHAT DELIBERATELY IS NOT. The gate demands a declared minor >= 0.5 — that is
|
|
12
|
+
// exactly the property that makes the Hub *refuse* rather than silently misroute, since v0.5 is where
|
|
13
|
+
// `to` is defined and constrained. It does NOT demand an advertised `inter_agent`: this Hub routes
|
|
14
|
+
// addressed messages today while deliberately withholding that object until the response (#652) and
|
|
15
|
+
// receipt (#653) producers land, so requiring it would make `--to` unusable against the very Hub that
|
|
16
|
+
// implements it. An explicitly DISABLED `inter_agent` is a different matter and is refused here,
|
|
17
|
+
// because that is the Hub stating the leg is off.
|
|
18
|
+
//
|
|
19
|
+
// FAIL CLOSED. An unreachable or unreadable capability document refuses the send. We cannot prove the
|
|
20
|
+
// Hub understands `to`, and the cost of being wrong is disclosure, not inconvenience.
|
|
21
|
+
import { CliError } from "../../envelope.js";
|
|
22
|
+
/** The minor at which `to` / `agent.session` exist and are constrained (spec §4/§4.1). */
|
|
23
|
+
const ADDRESSING_MINOR = 5;
|
|
24
|
+
/** Per-process capability cache, keyed by Hub base URL. The document is effectively static for a
|
|
25
|
+
* process lifetime, and an addressed send would otherwise pay a round-trip every call. `null` marks
|
|
26
|
+
* a fetch that FAILED, cached so a down/unreachable discovery endpoint is not re-probed per send —
|
|
27
|
+
* it still fails closed either way. */
|
|
28
|
+
const CACHE = new Map();
|
|
29
|
+
/** Test seam — a real process wants the cache to live exactly as long as it does. */
|
|
30
|
+
export function resetCapabilityCache() {
|
|
31
|
+
CACHE.clear();
|
|
32
|
+
}
|
|
33
|
+
/** The declared minor of an `0.<minor>` version string, or null when it is absent/malformed. */
|
|
34
|
+
function minorOf(version) {
|
|
35
|
+
if (typeof version !== "string")
|
|
36
|
+
return null;
|
|
37
|
+
const m = /^0\.(\d+)$/.exec(version.trim());
|
|
38
|
+
return m ? Number(m[1]) : null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* GET /v1/capability — the public capability document (no auth), memoized per process.
|
|
42
|
+
*
|
|
43
|
+
* Best-effort by return type: an unreachable / non-2xx / unparseable document resolves to
|
|
44
|
+
* `undefined`. Callers decide what that means — `fleet ls` degrades its caption to "unknown", while
|
|
45
|
+
* `assertHubSpeaksAddressing` below refuses the send.
|
|
46
|
+
*/
|
|
47
|
+
export async function fetchCapability(ctx) {
|
|
48
|
+
const key = ctx.config.baseUrl;
|
|
49
|
+
const cached = CACHE.get(key);
|
|
50
|
+
if (cached !== undefined)
|
|
51
|
+
return cached ?? undefined;
|
|
52
|
+
const timeout = ctx.config.timeoutMs !== undefined && ctx.config.timeoutMs > 0 ? Math.min(ctx.config.timeoutMs, 2_147_483_647) : 10_000;
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await ctx.runtime.fetchImpl(`${key}/v1/capability`, { method: "GET", headers: {}, signal: AbortSignal.timeout(timeout) });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
CACHE.set(key, null);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (res.status < 200 || res.status >= 300 || typeof res.json !== "function") {
|
|
62
|
+
CACHE.set(key, null);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
let body;
|
|
66
|
+
try {
|
|
67
|
+
body = await res.json();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
CACHE.set(key, null);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
74
|
+
CACHE.set(key, null);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const doc = body;
|
|
78
|
+
// `agent_list_visibility` is a GRANT and the only field a caller ACTS on — `fleet ls` turns it into
|
|
79
|
+
// the scope caption and into `data.scope` for scripts. A blind cast would read the STRING "false"
|
|
80
|
+
// as truthy and report the whole fleet for a list the Hub never said was account-wide. Anything
|
|
81
|
+
// that is not a real boolean is dropped, so it reads `unknown`: the honest answer for a document
|
|
82
|
+
// that did not say.
|
|
83
|
+
const sessions = doc.sessions;
|
|
84
|
+
if (sessions !== null && typeof sessions === "object" && !Array.isArray(sessions)) {
|
|
85
|
+
const visibility = sessions.agent_list_visibility;
|
|
86
|
+
if (visibility !== undefined && typeof visibility !== "boolean") {
|
|
87
|
+
delete sessions.agent_list_visibility;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (sessions !== undefined) {
|
|
91
|
+
delete doc.sessions;
|
|
92
|
+
}
|
|
93
|
+
const capability = doc;
|
|
94
|
+
CACHE.set(key, capability);
|
|
95
|
+
return capability;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The §8.0 gate: refuse an addressed send unless this Hub demonstrably understands `to`.
|
|
99
|
+
*
|
|
100
|
+
* Throws a `usage` CliError (exit 2 — the caller's invocation cannot work against this Hub, and no
|
|
101
|
+
* retry will change that) BEFORE any envelope is posted. See the file header for why the check is
|
|
102
|
+
* a pre-flight, why `inter_agent` is not required, and why an unreadable document fails closed.
|
|
103
|
+
*/
|
|
104
|
+
export async function assertHubSpeaksAddressing(ctx, to) {
|
|
105
|
+
const capability = await fetchCapability(ctx);
|
|
106
|
+
if (capability === undefined) {
|
|
107
|
+
throw new CliError("usage", `refusing to send to ${to}: this Hub's capability document (${ctx.config.baseUrl}/v1/capability) could not be read, ` +
|
|
108
|
+
"so it cannot be confirmed to understand v0.5 addressing. A Hub that does not would IGNORE the address and file " +
|
|
109
|
+
"this message in a human inbox (§10), which no error code can undo — so the send fails closed (§8.0).");
|
|
110
|
+
}
|
|
111
|
+
const minor = minorOf(capability.ma2h_version);
|
|
112
|
+
if (minor === null || minor < ADDRESSING_MINOR) {
|
|
113
|
+
throw new CliError("usage", `refusing to send to ${to}: this Hub declares MA2H ${capability.ma2h_version ?? "(no version)"}, which predates ` +
|
|
114
|
+
"the v0.5 addressing vocabulary. It would ignore `to` and file this message in a human inbox rather than refusing it (§8.0/§10).");
|
|
115
|
+
}
|
|
116
|
+
// Absent `inter_agent` is NOT read as disabled (see the file header) — but an explicit `false` is
|
|
117
|
+
// the Hub stating the leg is off, which is worth catching before a round-trip that can only 403.
|
|
118
|
+
if (capability.inter_agent?.enabled === false) {
|
|
119
|
+
throw new CliError("usage", `refusing to send to ${to}: this Hub advertises the inter-agent leg as disabled (§8.0 inter_agent.enabled=false).`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The §8.0 gate for the RECEIVE half — `oh-hai bridge`'s session-presenting drain (#658).
|
|
124
|
+
*
|
|
125
|
+
* The same disclosure logic as the send gate, pointed the other way, and the failure it prevents is
|
|
126
|
+
* worse than a wasted round-trip. A pre-v0.5 Hub does not refuse `?session=`; per §10 it IGNORES the
|
|
127
|
+
* unknown query parameter and answers with the v0.4 body — which means the bridge would issue a
|
|
128
|
+
* **destructive PRINCIPAL-scoped drain**, claiming (and, on ack, consuming) every directive queued
|
|
129
|
+
* for the whole agent, while believing it had drained only its own session's mail. Concurrent runs
|
|
130
|
+
* of the same principal would then eat each other's directives: exactly the race the watch-lock
|
|
131
|
+
* exists to prevent, reintroduced by a silently-dropped parameter.
|
|
132
|
+
*
|
|
133
|
+
* So the gate runs BEFORE the first drain or stream connect, and it fails CLOSED on an unreadable
|
|
134
|
+
* capability document for the same reason the send gate does — we cannot prove the Hub understands
|
|
135
|
+
* `session`, and the cost of being wrong is consuming someone else's mail.
|
|
136
|
+
*
|
|
137
|
+
* What is deliberately NOT required: a present `inbound.session_param`. An ABSENT capability field
|
|
138
|
+
* must never be read as "disabled" (the lesson `assertHubSpeaksAddressing` records in this file's
|
|
139
|
+
* header) — a declared minor >= 0.5 is what makes the Hub *refuse* rather than silently misroute,
|
|
140
|
+
* and that is the property being tested. An EXPLICIT `false` is a different matter: that is the Hub
|
|
141
|
+
* stating the parameter is not honoured, and it is refused.
|
|
142
|
+
*/
|
|
143
|
+
export async function assertHubSpeaksSessionDrain(ctx) {
|
|
144
|
+
const capability = await fetchCapability(ctx);
|
|
145
|
+
if (capability === undefined) {
|
|
146
|
+
throw new CliError("usage", `refusing to bridge: this Hub's capability document (${ctx.config.baseUrl}/v1/capability) could not be read, so it ` +
|
|
147
|
+
"cannot be confirmed to understand the v0.5 session-scoped drain. A Hub that does not would IGNORE `?session=` and " +
|
|
148
|
+
"answer with the v0.4 principal-wide mailbox (§10) — consuming directives meant for every other run of this agent. " +
|
|
149
|
+
"The bridge fails closed (§8.0).");
|
|
150
|
+
}
|
|
151
|
+
const minor = minorOf(capability.ma2h_version);
|
|
152
|
+
if (minor === null || minor < ADDRESSING_MINOR) {
|
|
153
|
+
throw new CliError("usage", `refusing to bridge: this Hub declares MA2H ${capability.ma2h_version ?? "(no version)"}, which predates the v0.5 ` +
|
|
154
|
+
"session-scoped drain (§8.7.1). It would ignore `?session=` and drain this agent's WHOLE mailbox destructively " +
|
|
155
|
+
"instead of refusing. Use `oh-hai inbox watch` against a pre-0.5 Hub.");
|
|
156
|
+
}
|
|
157
|
+
if (capability.sessions?.enabled === false) {
|
|
158
|
+
throw new CliError("usage", "refusing to bridge: this Hub advertises the v0.5 session primitive as disabled (§8.0 sessions.enabled=false).");
|
|
159
|
+
}
|
|
160
|
+
if (capability.inbound?.session_param === false) {
|
|
161
|
+
throw new CliError("usage", "refusing to bridge: this Hub advertises `inbound.session_param: false` — it does not honour the session-scoped drain (§8.7.1).");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=capability.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capability.js","sourceRoot":"","sources":["../../../src/commands/messaging/capability.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
import { CliError } from "../../envelope.js";
|
|
1
2
|
import type { CommandContext } from "../context.js";
|
|
2
3
|
import type { GetMessageBody, InboundDelivery, InboxAckResult, SubmitAck } from "./wire.js";
|
|
4
|
+
/** The per-request bound for a single Hub call: `config.timeoutMs` (from `--timeout` /
|
|
5
|
+
* `MA2H_TIMEOUT_MS`, §6), defaulted and clamped to the timer ceiling so an oversized/negative value
|
|
6
|
+
* can't overflow `AbortSignal.timeout` and fail the request spuriously. */
|
|
7
|
+
export declare function requestTimeoutMs(ctx: CommandContext): number;
|
|
3
8
|
/** Redact any occurrence of the bearer from a Hub-supplied string before it can reach
|
|
4
9
|
* stdout/stderr/logs/the `--json` envelope (§5.4) — a hostile/buggy Hub could echo the
|
|
5
10
|
* Authorization value back in an error message, an ack field, or (inbox watch) a directive. */
|
|
6
11
|
export declare function redactToken(message: string | undefined, token: string): string | undefined;
|
|
7
12
|
/** POST an envelope to the Hub's ingest endpoint (agent bearer). `202` → the SubmitAck; every
|
|
8
13
|
* other status maps to the right §7 exit code (via the Hub's error envelope when present). */
|
|
9
|
-
export declare function submitEnvelope(ctx: CommandContext, token: string, envelope: unknown
|
|
14
|
+
export declare function submitEnvelope(ctx: CommandContext, token: string, envelope: unknown, opts?: {
|
|
15
|
+
addressedTo?: string;
|
|
16
|
+
}): Promise<SubmitAck>;
|
|
10
17
|
/** GET one message (the submitter-bound pull, agent bearer). Embeds the terminal Response.
|
|
11
18
|
* A `2xx` returns the parsed body; every other status maps to a §7 exit code. */
|
|
12
19
|
export declare function pollMessage(ctx: CommandContext, token: string, id: string, timeoutMs: number): Promise<GetMessageBody>;
|
|
@@ -17,6 +24,26 @@ export declare function pollMessage(ctx: CommandContext, token: string, id: stri
|
|
|
17
24
|
export declare function drainInbox(ctx: CommandContext, token: string, max?: number): Promise<{
|
|
18
25
|
directives: InboundDelivery[];
|
|
19
26
|
}>;
|
|
27
|
+
/** GET the agent's OWN submitted messages — the submitter-bound self-list (spec §9.1, agent bearer,
|
|
28
|
+
* the #400 primitive paginated by #527). The Hub scopes the result to (accountId, agentId) and
|
|
29
|
+
* returns a pure INDEX: each row is the stored envelope MINUS the embedded terminal `response` and
|
|
30
|
+
* derived `delivery` (the agent pulls a specific answer with `pollMessage`). `opts` carries the
|
|
31
|
+
* optional `limit`/`offset` pagination and `status`/`type` filters as query params;
|
|
32
|
+
* `expectedAgentId` is the caller's own agent id — every returned row MUST be its own submission
|
|
33
|
+
* (see the ownership guard). A `2xx` returns the validated message array; every other status maps
|
|
34
|
+
* to a §7 exit code. Every returned string is bearer-redacted before the caller renders it (§5.4). */
|
|
35
|
+
export declare function listMessages(ctx: CommandContext, token: string, opts: {
|
|
36
|
+
limit?: number;
|
|
37
|
+
offset?: number;
|
|
38
|
+
status?: string;
|
|
39
|
+
type?: string;
|
|
40
|
+
}, expectedAgentId: string): Promise<GetMessageBody[]>;
|
|
20
41
|
/** POST a single batched consume-ack for the drained ids — the §14 directive receipt (agent bearer).
|
|
21
42
|
* A `2xx` returns the consumed count; every other status maps to a §7 exit code. */
|
|
22
43
|
export declare function ackInbox(ctx: CommandContext, token: string, ids: string[]): Promise<InboxAckResult>;
|
|
44
|
+
/** Map a Hub error response to a CliError. The exit code comes from the HTTP status; the stable
|
|
45
|
+
* `error.code` string (§8) prefers the Hub's own `error.code` when it maps to the SAME exit tier
|
|
46
|
+
* — so a documented code like `version_not_supported` survives instead of being flattened to
|
|
47
|
+
* `bad_request` (both exit 9), without letting a Hub-supplied code change the exit semantics. The
|
|
48
|
+
* server message is stripped of terminal control chars (the top-level catch prints it). */
|
|
49
|
+
export declare function statusToCliError(status: number, message: string | undefined, hubCode?: string, hint?: string): CliError;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { CliError } from "../../envelope.js";
|
|
12
12
|
import { exitCodeForError, isErrorCode } from "../../exit-codes.js";
|
|
13
13
|
import { throwTransportError } from "../http.js";
|
|
14
|
+
import { assertHubSpeaksAddressing } from "./capability.js";
|
|
14
15
|
import { MAX_TIMER_MS, sanitizeForTerminal } from "./shared.js";
|
|
15
16
|
/** Default per-request bound when no `--timeout`/`MA2H_TIMEOUT_MS` is set — mirrors the scripts
|
|
16
17
|
* layer's DEFAULT_TIMEOUT_MS so an unresponsive Hub can't hang a command indefinitely. */
|
|
@@ -18,7 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
|
|
|
18
19
|
/** The per-request bound for a single Hub call: `config.timeoutMs` (from `--timeout` /
|
|
19
20
|
* `MA2H_TIMEOUT_MS`, §6), defaulted and clamped to the timer ceiling so an oversized/negative value
|
|
20
21
|
* can't overflow `AbortSignal.timeout` and fail the request spuriously. */
|
|
21
|
-
function requestTimeoutMs(ctx) {
|
|
22
|
+
export function requestTimeoutMs(ctx) {
|
|
22
23
|
const t = ctx.config.timeoutMs;
|
|
23
24
|
return Math.min(t !== undefined && t > 0 ? t : DEFAULT_TIMEOUT_MS, MAX_TIMER_MS);
|
|
24
25
|
}
|
|
@@ -32,7 +33,15 @@ export function redactToken(message, token) {
|
|
|
32
33
|
}
|
|
33
34
|
/** POST an envelope to the Hub's ingest endpoint (agent bearer). `202` → the SubmitAck; every
|
|
34
35
|
* other status maps to the right §7 exit code (via the Hub's error envelope when present). */
|
|
35
|
-
export async function submitEnvelope(ctx, token, envelope) {
|
|
36
|
+
export async function submitEnvelope(ctx, token, envelope, opts = {}) {
|
|
37
|
+
// §8.0 FEATURE DETECTION, before a single byte is posted. A pre-v0.5 Hub does not refuse an
|
|
38
|
+
// envelope carrying `to` — §10 says it IGNORES the field, files the message in the human inbox,
|
|
39
|
+
// and acks success. The §8.1 snapshot check below catches that only from the ack, by which point
|
|
40
|
+
// agent-addressed content has already been disclosed to a person and cannot be recalled. Gating
|
|
41
|
+
// HERE, at the one choke point every submit path funnels through, is also why the replay paths
|
|
42
|
+
// cannot miss it — a per-caller gate already got forgotten once.
|
|
43
|
+
if (opts.addressedTo !== undefined)
|
|
44
|
+
await assertHubSpeaksAddressing(ctx, opts.addressedTo);
|
|
36
45
|
const url = `${ctx.config.baseUrl}/v1/messages`;
|
|
37
46
|
const init = {
|
|
38
47
|
method: "POST",
|
|
@@ -49,7 +58,11 @@ export async function submitEnvelope(ctx, token, envelope) {
|
|
|
49
58
|
}
|
|
50
59
|
if (res.status !== 202) {
|
|
51
60
|
const { code, message } = await readErrorEnvelope(res);
|
|
52
|
-
|
|
61
|
+
// On an ADDRESSED submit, append the one sentence that turns each documented §8.5 refusal into
|
|
62
|
+
// a next step. Done here — where the HTTP status and the Hub's own `error.code` are still in
|
|
63
|
+
// hand — rather than by re-inspecting a flattened CliError message at the call site.
|
|
64
|
+
const hint = opts.addressedTo !== undefined ? addressedHint(res.status, code, opts.addressedTo) : undefined;
|
|
65
|
+
throw statusToCliError(res.status, redactToken(message, token), code, hint);
|
|
53
66
|
}
|
|
54
67
|
const body = await readJson(res);
|
|
55
68
|
// A conformant Hub returns `{ id, status, poll_url, review_url? }` for every message type.
|
|
@@ -67,14 +80,94 @@ export async function submitEnvelope(ctx, token, envelope) {
|
|
|
67
80
|
// Redact the bearer from every ack field before returning — a hostile/buggy Hub could echo the
|
|
68
81
|
// Authorization value into id/status/poll_url/review_url, which the submit handlers print to
|
|
69
82
|
// stdout / the --json envelope (§5.4). Build a clean ack (drops any extra Hub fields too).
|
|
83
|
+
const destination = destinationOf(body, token, opts.addressedTo !== undefined);
|
|
84
|
+
// §8.1 MUST: every ADDRESSED ack carries the destination snapshot, and its absence is not a
|
|
85
|
+
// cosmetic gap — it is positive proof that the Hub did not route the address. A pre-0.5 Hub cannot
|
|
86
|
+
// emit this object at all, so a 202 without it means `to` was ignored and the message was filed in
|
|
87
|
+
// the HUMAN inbox. Printing a warning while still returning a successful ack (the earlier shape)
|
|
88
|
+
// left every caller exiting 0 and `--json` saying `ok: true` for a message that reached the wrong
|
|
89
|
+
// recipient — a silent misroute is exactly what this snapshot exists to make impossible. The id is
|
|
90
|
+
// included because the submit WAS accepted: the caller needs it to find (and cancel) the message.
|
|
91
|
+
if (opts.addressedTo !== undefined && destination === undefined) {
|
|
92
|
+
throw new CliError("server", `the Hub accepted this message (id ${redactToken(String(body.id), token)}) but returned no destination snapshot, ` +
|
|
93
|
+
`which means it did not route "${opts.addressedTo}" — the message was filed in the HUMAN inbox instead. ` +
|
|
94
|
+
"This Hub predates v0.5 addressing (§8.1).");
|
|
95
|
+
}
|
|
70
96
|
const ack = {
|
|
71
97
|
id: redactToken(body.id, token),
|
|
72
98
|
status: redactToken(body.status, token),
|
|
73
99
|
poll_url: redactToken(body.poll_url, token),
|
|
74
100
|
...(typeof body.review_url === "string" ? { review_url: redactToken(body.review_url, token) } : {}),
|
|
101
|
+
...(destination !== undefined ? { destination } : {}),
|
|
75
102
|
};
|
|
76
103
|
return ack;
|
|
77
104
|
}
|
|
105
|
+
/** Read the v0.5 §8.1 `destination` snapshot off an ack, defensively. Absent / wrong-shaped ⇒
|
|
106
|
+
* undefined, which the submit handlers render as "no destination snapshot" — a meaningful signal in
|
|
107
|
+
* itself on an ADDRESSED submit, because a pre-0.5 Hub cannot emit this object at all, so its
|
|
108
|
+
* absence is the sender's proof that `to` was IGNORED and the message filed in a human inbox.
|
|
109
|
+
* An unrecognized `state` is passed through verbatim (sanitized at render) rather than coerced to
|
|
110
|
+
* `unknown`: inventing a reachability claim is exactly the dishonesty §8.1 exists to prevent. */
|
|
111
|
+
function destinationOf(body, token, addressed) {
|
|
112
|
+
const raw = body.destination;
|
|
113
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
114
|
+
return undefined;
|
|
115
|
+
const { state, last_seen: lastSeen } = raw;
|
|
116
|
+
if (typeof state !== "string" || state === "")
|
|
117
|
+
return undefined;
|
|
118
|
+
if (addressed) {
|
|
119
|
+
// The v0.5 submit-ack schema (and conformance vector sv-034) fixes BOTH the enum and the
|
|
120
|
+
// relationship: `state` is online|offline|unknown, and `unknown` — the "no visibility" answer —
|
|
121
|
+
// MUST carry no `last_seen`, because a timestamp IS visibility. Passing an out-of-enum state
|
|
122
|
+
// through "verbatim" was the wrong kind of honesty: rendering `reachable` or an `unknown` with a
|
|
123
|
+
// last-seen invents a reachability claim the protocol cannot express, which is exactly the false
|
|
124
|
+
// belief this snapshot exists to prevent. A violation is a malformed ack, not a usable reading.
|
|
125
|
+
if (state !== "online" && state !== "offline" && state !== "unknown") {
|
|
126
|
+
throw new CliError("server", `the Hub returned an unknown destination reachability state ${JSON.stringify(sanitizeForTerminal(state))}.`);
|
|
127
|
+
}
|
|
128
|
+
// The schema pins the relationship in BOTH directions, and each direction is load-bearing:
|
|
129
|
+
// `unknown` must carry no `last_seen` (a timestamp IS visibility, so one here contradicts the
|
|
130
|
+
// claim), and `online`/`offline` MUST carry one (a known state with no timestamp is an
|
|
131
|
+
// unanchored assertion — "online" as of when?). Half-validating leaves the second case rendering
|
|
132
|
+
// a reachability reading the sender cannot date, which is the same unfalsifiable claim the
|
|
133
|
+
// snapshot exists to replace.
|
|
134
|
+
if (state === "unknown" && lastSeen !== undefined) {
|
|
135
|
+
throw new CliError("server", "the Hub returned an `unknown` destination reachability carrying a last_seen, which the v0.5 ack schema forbids.");
|
|
136
|
+
}
|
|
137
|
+
if (state !== "unknown" && !isIsoTimestamp(lastSeen)) {
|
|
138
|
+
throw new CliError("server", `the Hub reported the destination as ${state} without the last_seen timestamp the v0.5 ack schema requires to anchor it.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
state: redactToken(state, token),
|
|
143
|
+
...(typeof lastSeen === "string" ? { last_seen: redactToken(lastSeen, token) } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The actionable second sentence for a refused ADDRESSED submit (spec §8.5). Keyed on the HTTP
|
|
147
|
+
* status plus the Hub's `error.code`, both of which are documented and stable:
|
|
148
|
+
* - `403` — the leg is ACCOUNT-OPT-IN and defaults off (§8.0). Nothing the sender can fix in argv.
|
|
149
|
+
* - `422 unknown_destination` — no such agent (or a session that is not the addressee's) in THIS
|
|
150
|
+
* account. Deliberately indistinguishable from "exists but not yours", so the guidance can only
|
|
151
|
+
* point at the discovery surface.
|
|
152
|
+
* - `410 destination_gone` — the addressed session is terminal; the PRINCIPAL may still be live.
|
|
153
|
+
* - `422 invalid_field` — a Hub that predates v0.5 addressing vocabulary.
|
|
154
|
+
* Anything else falls through to the Hub's own message unadorned. */
|
|
155
|
+
function addressedHint(status, code, to) {
|
|
156
|
+
if (status === 403) {
|
|
157
|
+
return "the inter-agent leg is opt-in per account and off by default — an account owner enables it before agents can address each other.";
|
|
158
|
+
}
|
|
159
|
+
if (status === 410) {
|
|
160
|
+
const principal = to.split("#")[0] ?? to;
|
|
161
|
+
return `that session is terminal — re-address the principal (\`--to ${principal}\`) or pick a live session from \`oh-hai fleet ls\`.`;
|
|
162
|
+
}
|
|
163
|
+
if (status === 422 && code === "unknown_destination") {
|
|
164
|
+
return "no live agent (or session) in this account answers to that address — check it with `oh-hai fleet ls`.";
|
|
165
|
+
}
|
|
166
|
+
if (status === 422 && code === "invalid_field") {
|
|
167
|
+
return "this Hub does not speak v0.5 addressing, so `--to` cannot be routed here.";
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
78
171
|
/** GET one message (the submitter-bound pull, agent bearer). Embeds the terminal Response.
|
|
79
172
|
* A `2xx` returns the parsed body; every other status maps to a §7 exit code. */
|
|
80
173
|
export async function pollMessage(ctx, token, id, timeoutMs) {
|
|
@@ -93,7 +186,7 @@ export async function pollMessage(ctx, token, id, timeoutMs) {
|
|
|
93
186
|
}
|
|
94
187
|
if (res.status < 200 || res.status >= 300) {
|
|
95
188
|
const { code, message } = await readErrorEnvelope(res);
|
|
96
|
-
throw
|
|
189
|
+
throw statusToCliError(res.status, redactToken(message, token), code);
|
|
97
190
|
}
|
|
98
191
|
const body = await readJson(res);
|
|
99
192
|
// Validate the body shape before the await loop keys terminality on it: the `id` must be present
|
|
@@ -104,9 +197,12 @@ export async function pollMessage(ctx, token, id, timeoutMs) {
|
|
|
104
197
|
if (typeof body.id !== "string" || body.id !== id || !isValidTypeStatus(body.type, body.status)) {
|
|
105
198
|
throw new CliError("server", "the Hub returned a malformed or mismatched message (bad/other id, or a status invalid for its type).");
|
|
106
199
|
}
|
|
107
|
-
// A
|
|
108
|
-
// "successfully" with no resolution value/actor.
|
|
109
|
-
|
|
200
|
+
// A body that reached a §7 RESOLUTION must embed its Response (the pull contract) — else `await`
|
|
201
|
+
// would exit "successfully" with no resolution value/actor. Keyed on the resolution set rather
|
|
202
|
+
// than "not open and not delivered": the v0.5 §14.2 delivery-track states carry no §7 Response by
|
|
203
|
+
// design (they describe DELIVERY, not a human's answer), so the old negative form would have
|
|
204
|
+
// rejected a legitimate `queued` / `acknowledged` / `bounced` poll body as malformed.
|
|
205
|
+
if (isResolved(body.type, body.status) && (typeof body.response !== "object" || body.response === null)) {
|
|
110
206
|
throw new CliError("server", "the Hub returned a resolved message with no embedded Response.");
|
|
111
207
|
}
|
|
112
208
|
return body;
|
|
@@ -132,7 +228,7 @@ export async function drainInbox(ctx, token, max) {
|
|
|
132
228
|
}
|
|
133
229
|
if (res.status < 200 || res.status >= 300) {
|
|
134
230
|
const { code, message } = await readErrorEnvelope(res);
|
|
135
|
-
throw
|
|
231
|
+
throw statusToCliError(res.status, redactToken(message, token), code);
|
|
136
232
|
}
|
|
137
233
|
const body = await readJson(res);
|
|
138
234
|
// Validate the drain shape before the loop trusts it: `directives` MUST be an array, and every
|
|
@@ -146,6 +242,76 @@ export async function drainInbox(ctx, token, max) {
|
|
|
146
242
|
}
|
|
147
243
|
return { directives: directives };
|
|
148
244
|
}
|
|
245
|
+
/** GET the agent's OWN submitted messages — the submitter-bound self-list (spec §9.1, agent bearer,
|
|
246
|
+
* the #400 primitive paginated by #527). The Hub scopes the result to (accountId, agentId) and
|
|
247
|
+
* returns a pure INDEX: each row is the stored envelope MINUS the embedded terminal `response` and
|
|
248
|
+
* derived `delivery` (the agent pulls a specific answer with `pollMessage`). `opts` carries the
|
|
249
|
+
* optional `limit`/`offset` pagination and `status`/`type` filters as query params;
|
|
250
|
+
* `expectedAgentId` is the caller's own agent id — every returned row MUST be its own submission
|
|
251
|
+
* (see the ownership guard). A `2xx` returns the validated message array; every other status maps
|
|
252
|
+
* to a §7 exit code. Every returned string is bearer-redacted before the caller renders it (§5.4). */
|
|
253
|
+
export async function listMessages(ctx, token, opts, expectedAgentId) {
|
|
254
|
+
const query = new URLSearchParams();
|
|
255
|
+
if (opts.limit !== undefined)
|
|
256
|
+
query.set("limit", String(opts.limit));
|
|
257
|
+
if (opts.offset !== undefined)
|
|
258
|
+
query.set("offset", String(opts.offset));
|
|
259
|
+
if (opts.status !== undefined)
|
|
260
|
+
query.set("status", opts.status);
|
|
261
|
+
if (opts.type !== undefined)
|
|
262
|
+
query.set("type", opts.type);
|
|
263
|
+
const suffix = query.toString();
|
|
264
|
+
const url = `${ctx.config.baseUrl}/v1/messages${suffix ? `?${suffix}` : ""}`;
|
|
265
|
+
const init = {
|
|
266
|
+
method: "GET",
|
|
267
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
268
|
+
signal: AbortSignal.timeout(requestTimeoutMs(ctx)),
|
|
269
|
+
};
|
|
270
|
+
let res;
|
|
271
|
+
try {
|
|
272
|
+
res = await ctx.runtime.fetchImpl(url, init);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
throwTransportError(error, ctx.config.baseUrl);
|
|
276
|
+
}
|
|
277
|
+
if (res.status < 200 || res.status >= 300) {
|
|
278
|
+
const { code, message } = await readErrorEnvelope(res);
|
|
279
|
+
throw statusToCliError(res.status, redactToken(message, token), code);
|
|
280
|
+
}
|
|
281
|
+
const body = await readJson(res);
|
|
282
|
+
// The self-list body is `{ messages: [...] }`; validate each row is an INDEX item with the string
|
|
283
|
+
// id/type/status the renderer keys on (mirrors drainInbox's shape guard) — a malformed body is a
|
|
284
|
+
// server error, not a silently-empty list that would hide a Hub/proxy regression.
|
|
285
|
+
const messages = body.messages;
|
|
286
|
+
if (!Array.isArray(messages) || !messages.every(isListItem)) {
|
|
287
|
+
throw new CliError("server", "the Hub returned a malformed message list (expected { messages: [{ id, type, status }] }).");
|
|
288
|
+
}
|
|
289
|
+
// Defense-in-depth against the dual-audience route: `GET /v1/messages` returns the submitter-bound
|
|
290
|
+
// self-list for an agent bearer but the account-wide inbox for a human/session bearer. Refuse any
|
|
291
|
+
// row not submitted by THIS agent — that catches a misconfigured non-agent bearer whenever the
|
|
292
|
+
// account has other agents' messages. (A heuristic, not a proof of the agent branch: a single-agent
|
|
293
|
+
// or empty account has no foreign row to trip on — but the index projection below then yields only
|
|
294
|
+
// this agent's own rows, so nothing extra leaks. See the polish backlog for the rigorous fix.)
|
|
295
|
+
if (messages.some((m) => m.agent?.id !== expectedAgentId)) {
|
|
296
|
+
throw new CliError("server", "the Hub returned a message this agent did not submit — refusing a non-self-list result.");
|
|
297
|
+
}
|
|
298
|
+
// Project every row to a pure INDEX (drop the answer body + bulky detail payloads); see toIndexRow.
|
|
299
|
+
const index = messages.map(toIndexRow);
|
|
300
|
+
// Redact the bearer from every field before the caller prints it to stdout / the --json envelope —
|
|
301
|
+
// a hostile/buggy Hub could echo the Authorization value into a title/id. Round-trips the array
|
|
302
|
+
// through redactToken so a match in ANY nested string is scrubbed (mirrors the MCP client's
|
|
303
|
+
// #redact). Fail CLOSED on the pathological case where the re-parse fails: never return the
|
|
304
|
+
// un-redacted array (that would leak the bearer) — surface a clean server error instead.
|
|
305
|
+
const redacted = redactToken(JSON.stringify(index), token);
|
|
306
|
+
if (redacted === undefined)
|
|
307
|
+
return index;
|
|
308
|
+
try {
|
|
309
|
+
return JSON.parse(redacted);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
throw new CliError("server", "the Hub response could not be safely processed.");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
149
315
|
/** POST a single batched consume-ack for the drained ids — the §14 directive receipt (agent bearer).
|
|
150
316
|
* A `2xx` returns the consumed count; every other status maps to a §7 exit code. */
|
|
151
317
|
export async function ackInbox(ctx, token, ids) {
|
|
@@ -165,7 +331,7 @@ export async function ackInbox(ctx, token, ids) {
|
|
|
165
331
|
}
|
|
166
332
|
if (res.status < 200 || res.status >= 300) {
|
|
167
333
|
const { code, message } = await readErrorEnvelope(res);
|
|
168
|
-
throw
|
|
334
|
+
throw statusToCliError(res.status, redactToken(message, token), code);
|
|
169
335
|
}
|
|
170
336
|
const body = await readJson(res);
|
|
171
337
|
// The Hub returns the ids it actually consumed (spec §14; server `ackDirectives` → string[]), not a
|
|
@@ -194,12 +360,43 @@ function isInboundDelivery(value) {
|
|
|
194
360
|
return false;
|
|
195
361
|
return d.priority === undefined || typeof d.priority === "string";
|
|
196
362
|
}
|
|
363
|
+
/** Structural guard for one self-list row — an INDEX item carrying the string `id` / `type` /
|
|
364
|
+
* `status` the renderer reads. The Hub strips `response`/`delivery`, so those are absent by design;
|
|
365
|
+
* a row missing any of the three keyed strings is a malformed body, not a usable list item. */
|
|
366
|
+
function isListItem(value) {
|
|
367
|
+
if (value === null || typeof value !== "object")
|
|
368
|
+
return false;
|
|
369
|
+
const { id, type, status } = value;
|
|
370
|
+
return typeof id === "string" && typeof type === "string" && typeof status === "string";
|
|
371
|
+
}
|
|
372
|
+
/** Project a self-list row down to an INDEX shape (mirrors the server's `toSearchResultBody`): drop
|
|
373
|
+
* the terminal answer payload (`response`/`delivery` — never render a resolved answer from the bulk
|
|
374
|
+
* list) AND the bulky detail payloads (`context`/`state`/`request`/`action`, which can be large or
|
|
375
|
+
* opaque). A history list must stay lightweight; the agent pulls a specific message's detail via
|
|
376
|
+
* GET /:id. Keeps the index fields (id/type/title/body/status/created_at/priority/agent/…). */
|
|
377
|
+
function toIndexRow(row) {
|
|
378
|
+
const rest = { ...row };
|
|
379
|
+
for (const field of ["response", "delivery", "context", "state", "request", "action"]) {
|
|
380
|
+
delete rest[field];
|
|
381
|
+
}
|
|
382
|
+
return rest;
|
|
383
|
+
}
|
|
197
384
|
/** Valid lifecycle statuses per message type (spec §7). A status valid for one type but not the
|
|
198
385
|
* message's actual type is a malformed body. `delivered` is terminal-on-acceptance for `notify`
|
|
199
386
|
* ONLY — ask/task go from `open` straight to a resolution, so a `{type:"ask", status:"delivered"}`
|
|
200
387
|
* must be rejected (else the await loop would treat it as non-terminal and hang to timeout). */
|
|
388
|
+
/** The v0.5 §14.2 DELIVERY-track states belong to the NOTIFY set only.
|
|
389
|
+
*
|
|
390
|
+
* An addressed notify's lifecycle IS the delivery track, so `status` legitimately reads
|
|
391
|
+
* `queued`/`acknowledged`/`bounced`/`expired` there (spec §5.1). An addressed ASK/TASK keeps the §7
|
|
392
|
+
* resolution machinery at `status` — an undeliverable track terminal auto-resolves it to
|
|
393
|
+
* `cancelled`/`dismissed` — and surfaces the outbound track under `mailbox` instead
|
|
394
|
+
* (v0.5 get-message.schema.json). Admitting the delivery states into the ask/task sets would let a
|
|
395
|
+
* malformed `{type:"ask", status:"bounced"}` through, and `ask await` would then report it as a
|
|
396
|
+
* successful terminal resolution carrying no Response — the exact "status valid globally but
|
|
397
|
+
* impossible for this type" failure this table exists to catch. */
|
|
201
398
|
const STATUSES_BY_TYPE = {
|
|
202
|
-
notify: new Set(["open", "delivered"]),
|
|
399
|
+
notify: new Set(["open", "delivered", "queued", "acknowledged", "bounced", "expired"]),
|
|
203
400
|
ask: new Set(["open", "answered", "declined", "cancelled", "expired"]),
|
|
204
401
|
task: new Set(["open", "completed", "dismissed", "expired"]),
|
|
205
402
|
};
|
|
@@ -209,13 +406,34 @@ function isValidTypeStatus(type, status) {
|
|
|
209
406
|
const allowed = STATUSES_BY_TYPE[type];
|
|
210
407
|
return allowed !== undefined && allowed.has(status);
|
|
211
408
|
}
|
|
409
|
+
/** A `format: date-time` string, checked as far as is useful here: a parseable ISO timestamp. Used
|
|
410
|
+
* for the reachability anchor, where an unparseable value is as unusable as an absent one. */
|
|
411
|
+
function isIsoTimestamp(value) {
|
|
412
|
+
return typeof value === "string" && value.trim() !== "" && !Number.isNaN(Date.parse(value));
|
|
413
|
+
}
|
|
414
|
+
/** The §7 RESOLUTION states per type — the ones whose body MUST embed a terminal Response. A notify
|
|
415
|
+
* has no resolution track at all (its `expired` belongs to the §14.2 delivery track and carries no
|
|
416
|
+
* human answer), which is why this is per-type rather than one flat set: `expired` means two
|
|
417
|
+
* different things depending on which track owns the message. */
|
|
418
|
+
const RESOLUTIONS_BY_TYPE = {
|
|
419
|
+
ask: new Set(["answered", "declined", "cancelled", "expired"]),
|
|
420
|
+
task: new Set(["completed", "dismissed", "expired"]),
|
|
421
|
+
};
|
|
422
|
+
function isResolved(type, status) {
|
|
423
|
+
if (typeof type !== "string" || typeof status !== "string")
|
|
424
|
+
return false;
|
|
425
|
+
return RESOLUTIONS_BY_TYPE[type]?.has(status) === true;
|
|
426
|
+
}
|
|
212
427
|
/** Map a Hub error response to a CliError. The exit code comes from the HTTP status; the stable
|
|
213
428
|
* `error.code` string (§8) prefers the Hub's own `error.code` when it maps to the SAME exit tier
|
|
214
429
|
* — so a documented code like `version_not_supported` survives instead of being flattened to
|
|
215
430
|
* `bad_request` (both exit 9), without letting a Hub-supplied code change the exit semantics. The
|
|
216
431
|
* server message is stripped of terminal control chars (the top-level catch prints it). */
|
|
217
|
-
function
|
|
218
|
-
const
|
|
432
|
+
export function statusToCliError(status, message, hubCode, hint) {
|
|
433
|
+
const base_ = message !== undefined ? sanitizeForTerminal(message) : `Hub returned ${status}.`;
|
|
434
|
+
// The hint is CLI-authored (never Hub-supplied), so it needs no sanitizing — but it is appended
|
|
435
|
+
// AFTER the Hub's own message so the verbatim server text stays the headline.
|
|
436
|
+
const detail = hint !== undefined ? `${base_} — ${hint}` : base_;
|
|
219
437
|
const base = statusToErrorCode(status);
|
|
220
438
|
// Preserve the Hub's code ONLY when it is a documented §8 code AND maps to the same exit tier —
|
|
221
439
|
// an unrecognized code (e.g. `rate_limited` on a 429) must not leak into the CLI envelope, and a
|
|
@@ -229,7 +447,10 @@ function hubStatusToCliError(status, message, hubCode) {
|
|
|
229
447
|
function statusToErrorCode(status) {
|
|
230
448
|
if (status === 401 || status === 403)
|
|
231
449
|
return "auth";
|
|
232
|
-
|
|
450
|
+
// 410 Gone joins 404: the named resource is no longer usable (a terminal destination session,
|
|
451
|
+
// §8.5 `destination_gone`). Distinguishing it from 404 at the EXIT-code level would buy a caller
|
|
452
|
+
// nothing — both mean "re-address"; the Hub's own message carries the distinction.
|
|
453
|
+
if (status === 404 || status === 410)
|
|
233
454
|
return "not_found";
|
|
234
455
|
if (status === 409)
|
|
235
456
|
return "conflict";
|