agent-embassy 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +19 -34
- package/README.md +110 -228
- package/SECURITY.md +55 -89
- package/dist/src/errors.d.ts +10 -1
- package/dist/src/errors.js +3 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
- package/dist/src/gateway/claude-helper-protocol.js +12 -11
- package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
- package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
- package/dist/src/gateway/claude-helper-supervisor.js +10 -9
- package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
- package/dist/src/gateway/claude-helper.js +6 -6
- package/dist/src/gateway/claude-helper.js.map +1 -1
- package/dist/src/gateway/claude-peer.d.ts +0 -3
- package/dist/src/gateway/claude-peer.js +6 -18
- package/dist/src/gateway/claude-peer.js.map +1 -1
- package/dist/src/gateway/cli.d.ts +17 -6
- package/dist/src/gateway/cli.js +912 -261
- package/dist/src/gateway/cli.js.map +1 -1
- package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
- package/dist/src/gateway/codex-socket-holder.js +76 -0
- package/dist/src/gateway/codex-socket-holder.js.map +1 -0
- package/dist/src/gateway/codex-stateless-transport.js +1 -1
- package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
- package/dist/src/gateway/config.d.ts +1 -10
- package/dist/src/gateway/config.js +4 -10
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/control.d.ts +45 -77
- package/dist/src/gateway/control.js +56 -141
- package/dist/src/gateway/control.js.map +1 -1
- package/dist/src/gateway/federation-nodes.d.ts +29 -2
- package/dist/src/gateway/federation-nodes.js +177 -7
- package/dist/src/gateway/federation-nodes.js.map +1 -1
- package/dist/src/gateway/peer-client.d.ts +4 -3
- package/dist/src/gateway/peer-client.js +22 -13
- package/dist/src/gateway/peer-client.js.map +1 -1
- package/dist/src/gateway/peer-protocol.d.ts +8 -11
- package/dist/src/gateway/peer-protocol.js +5 -12
- package/dist/src/gateway/peer-protocol.js.map +1 -1
- package/dist/src/gateway/provenance-envelope.d.ts +0 -1
- package/dist/src/gateway/provenance-envelope.js +4 -19
- package/dist/src/gateway/provenance-envelope.js.map +1 -1
- package/dist/src/gateway/providers.d.ts +7 -4
- package/dist/src/gateway/providers.js +16 -20
- package/dist/src/gateway/providers.js.map +1 -1
- package/dist/src/gateway/server.d.ts +3 -12
- package/dist/src/gateway/server.js +31 -50
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/gateway/service-agent.d.ts +187 -0
- package/dist/src/gateway/service-agent.js +758 -0
- package/dist/src/gateway/service-agent.js.map +1 -0
- package/dist/src/gateway/service.d.ts +114 -31
- package/dist/src/gateway/service.js +461 -566
- package/dist/src/gateway/service.js.map +1 -1
- package/dist/src/gateway/status-view.d.ts +167 -0
- package/dist/src/gateway/status-view.js +488 -0
- package/dist/src/gateway/status-view.js.map +1 -0
- package/dist/src/gateway/store.d.ts +103 -21
- package/dist/src/gateway/store.js +454 -529
- package/dist/src/gateway/store.js.map +1 -1
- package/dist/src/gateway/types.d.ts +48 -99
- package/dist/src/gateway/types.js +15 -52
- package/dist/src/gateway/types.js.map +1 -1
- package/docs/CONFIGURATION.md +173 -44
- package/docs/DELIVERY.md +11 -11
- package/docs/GATEWAY-ARCHITECTURE.md +277 -375
- package/package.json +4 -12
- package/skills/embassy-peer/SKILL.md +65 -90
- package/skills/embassy-peer/agents/openai.yaml +1 -1
- package/README.zh-CN.md +0 -275
- package/assets/live-dashboard/app.css +0 -1619
- package/assets/vendor/react/LICENSE +0 -21
- package/assets/vendor/react/react-dom.production.min.js +0 -267
- package/assets/vendor/react/react.production.min.js +0 -31
- package/dist/src/gateway/acp-client.d.ts +0 -110
- package/dist/src/gateway/acp-client.js +0 -407
- package/dist/src/gateway/acp-client.js.map +0 -1
- package/dist/src/gateway/acp-provider.d.ts +0 -66
- package/dist/src/gateway/acp-provider.js +0 -275
- package/dist/src/gateway/acp-provider.js.map +0 -1
- package/dist/src/gateway/cli-copy.d.ts +0 -8
- package/dist/src/gateway/cli-copy.en.d.ts +0 -22
- package/dist/src/gateway/cli-copy.en.js +0 -62
- package/dist/src/gateway/cli-copy.en.js.map +0 -1
- package/dist/src/gateway/cli-copy.js +0 -27
- package/dist/src/gateway/cli-copy.js.map +0 -1
- package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
- package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
- package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/codex-doctor.d.ts +0 -36
- package/dist/src/gateway/codex-doctor.js +0 -127
- package/dist/src/gateway/codex-doctor.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.d.ts +0 -7
- package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.en.js +0 -505
- package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.js +0 -514
- package/dist/src/gateway/dashboard-copy.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
- package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/dashboard-model.d.ts +0 -343
- package/dist/src/gateway/dashboard-model.js +0 -1061
- package/dist/src/gateway/dashboard-model.js.map +0 -1
- package/dist/src/gateway/dashboard.d.ts +0 -20
- package/dist/src/gateway/dashboard.js +0 -874
- package/dist/src/gateway/dashboard.js.map +0 -1
- package/dist/src/gateway/deepseek-detect.d.ts +0 -14
- package/dist/src/gateway/deepseek-detect.js +0 -41
- package/dist/src/gateway/deepseek-detect.js.map +0 -1
- package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
- package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
- package/dist/src/gateway/live-dashboard-assets.js +0 -74
- package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
- package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
- package/dist/src/gateway/live-dashboard-command.js +0 -334
- package/dist/src/gateway/live-dashboard-command.js.map +0 -1
- package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
- package/dist/src/gateway/live-dashboard-http.js +0 -383
- package/dist/src/gateway/live-dashboard-http.js.map +0 -1
- package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
- package/dist/src/gateway/live-dashboard-protocol.js +0 -114
- package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
- package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
- package/dist/src/gateway/live-dashboard-server.js +0 -144
- package/dist/src/gateway/live-dashboard-server.js.map +0 -1
- package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
- package/dist/src/gateway/live-dashboard-stream.js +0 -234
- package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
- package/dist/src/gateway/live-dashboard.d.ts +0 -28
- package/dist/src/gateway/live-dashboard.js +0 -154
- package/dist/src/gateway/live-dashboard.js.map +0 -1
- package/dist/src/gateway/locale.d.ts +0 -4
- package/dist/src/gateway/locale.js +0 -10
- package/dist/src/gateway/locale.js.map +0 -1
- package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
- package/dist/src/gateway/progress-watch-machine.js +0 -70
- package/dist/src/gateway/progress-watch-machine.js.map +0 -1
- package/docs/CONFIGURATION.zh-CN.md +0 -97
- package/docs/DASHBOARD.md +0 -98
- package/docs/DASHBOARD.zh-CN.md +0 -49
- package/docs/DELIVERY.zh-CN.md +0 -55
package/dist/src/gateway/cli.js
CHANGED
|
@@ -1,35 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Foreground broker plus bounded metadata-only control client. */
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
3
4
|
import { realpathSync } from "node:fs";
|
|
4
5
|
import { lstat, realpath } from "node:fs/promises";
|
|
6
|
+
import { userInfo } from "node:os";
|
|
5
7
|
import path from "node:path";
|
|
6
8
|
import { fileURLToPath } from "node:url";
|
|
7
9
|
import { BridgeError } from "../errors.js";
|
|
8
|
-
import { getCliCopy } from "./cli-copy.js";
|
|
9
|
-
import { callerIdentityConflictHintEn } from "./cli-copy.en.js";
|
|
10
|
-
import { callerIdentityConflictHintZhCn } from "./cli-copy.zh-CN.js";
|
|
11
10
|
import { GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS, GATEWAY_CONTROL_MAX_MESSAGE_BYTES, GATEWAY_CONTROL_MAX_RESPONSE_BYTES, GATEWAY_CONTROL_PROTOCOL_VERSION, GatewayControlTransportError, isClaudeSessionSelector, isGatewayAlias, isGatewayConversationId, isGatewayDeliveryToken, isGatewayReplyAddress, sendGatewayControlRequest } from "./control.js";
|
|
12
11
|
import { defaultGatewayStateDir, loadGatewayConfig } from "./config.js";
|
|
13
|
-
import { loadGatewayNodeInventory } from "./federation-nodes.js";
|
|
14
|
-
import { isDashboardLocale } from "./locale.js";
|
|
15
|
-
import { DEFAULT_LIVE_DASHBOARD_PORT, runLiveDashboardCommand } from "./live-dashboard-command.js";
|
|
12
|
+
import { isDefaultedGatewayNodeInventory, loadGatewayNodeInventory } from "./federation-nodes.js";
|
|
16
13
|
import { runGatewayServer } from "./server.js";
|
|
17
|
-
import { PROGRESS_WATCH_DEFAULT_IDLE_MS } from "./progress-watch-machine.js";
|
|
18
14
|
import { PeerHandlerError, runPeerStdio } from "./peer-stdio.js";
|
|
15
|
+
import { boundedServiceDetail, defaultProbeHostLease, defaultRunLaunchctl, installServiceAgent, readOwnedSmallFile, recordedPid, serviceAgentStatus, uninstallServiceAgent } from "./service-agent.js";
|
|
16
|
+
import { GATEWAY_CONTROLLER_LOCK_FILE } from "./store.js";
|
|
17
|
+
import { diffWatch, emptyWatchState, renderStatus, renderWatchEvent, STATUS_RECENT, STATUS_ROUTE_STALE_AFTER_MS, terminalPainter } from "./status-view.js";
|
|
19
18
|
const THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
20
19
|
const CLI_MAX_OUTPUT_BYTES = GATEWAY_CONTROL_MAX_RESPONSE_BYTES;
|
|
21
20
|
const DELIVERY_POLL_INTERVAL_MS = 250;
|
|
22
21
|
const DELIVERY_POLL_MIN_REQUEST_TIMEOUT_MS = 50;
|
|
23
22
|
const PEER_AWAIT_REQUEST_TIMEOUT_MS = 35_000;
|
|
24
|
-
export const EMBASSY_VERSION = "
|
|
25
|
-
// RELEASE VERSION SWEEP — every place the version lives:
|
|
26
|
-
// npm-shrinkwrap.json (
|
|
27
|
-
// test/gateway-cli.test.ts package-
|
|
28
|
-
|
|
23
|
+
export const EMBASSY_VERSION = "3.0.0";
|
|
24
|
+
// RELEASE VERSION SWEEP — every place the version lives, seven sites:
|
|
25
|
+
// package.json; npm-shrinkwrap.json (two: the root and packages[""]); this
|
|
26
|
+
// constant; test/gateway-cli.test.ts's literal package-version assertion; and
|
|
27
|
+
// README.md's two `embassy status` renders. test/status-view.test.ts pins
|
|
28
|
+
// those renders to this constant and derives its own fixture and regexes from
|
|
29
|
+
// it, so it is not a sweep site.
|
|
29
30
|
export const gatewayCliCommands = [
|
|
30
|
-
"serve", "health", "status", "
|
|
31
|
-
"wait-delivery", "
|
|
32
|
-
"unregister-codex",
|
|
31
|
+
"serve", "service", "health", "status", "watch", "check", "delivery-status",
|
|
32
|
+
"wait-delivery", "refresh", "register-codex",
|
|
33
|
+
"unregister-codex",
|
|
33
34
|
"send", "reply",
|
|
34
35
|
"register-peer", "unregister-peer", "await",
|
|
35
36
|
"peer-stdio",
|
|
@@ -42,17 +43,140 @@ export const gatewayCliExitCodes = Object.freeze({
|
|
|
42
43
|
ambiguous: 5,
|
|
43
44
|
failure: 6,
|
|
44
45
|
});
|
|
46
|
+
const HELP_USAGE = `Embassy — local messaging for Claude Code and Codex
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
embassy <command> [options]
|
|
50
|
+
|
|
51
|
+
Commands:
|
|
52
|
+
serve Run the socket-only broker
|
|
53
|
+
service install|uninstall|status
|
|
54
|
+
Run the broker as a macOS launchd agent
|
|
55
|
+
health Check broker health
|
|
56
|
+
status [--json] [--recent <n>]
|
|
57
|
+
Show the broker, connectors, routes, and recent
|
|
58
|
+
messages; JSON when piped or with --json
|
|
59
|
+
watch [--json] Tail messages and route activity until Ctrl-C
|
|
60
|
+
check [--to <alias>] [--timeout <s>]
|
|
61
|
+
Round-trip self-test against a registered peer
|
|
62
|
+
refresh Rescan for Claude sessions
|
|
63
|
+
register-codex Register or succeed a Codex task
|
|
64
|
+
unregister-codex Unregister the current Codex task
|
|
65
|
+
register-peer --alias <peer-alias> [--token-stdin|--emit-env]
|
|
66
|
+
Register a universal shell peer
|
|
67
|
+
unregister-peer --alias <peer-alias> [--token-stdin]
|
|
68
|
+
Unregister a universal shell peer
|
|
69
|
+
await --alias <peer-alias> [--token-stdin]
|
|
70
|
+
Wait for one peer message and acknowledge stdout
|
|
71
|
+
peer-stdio Serve the bounded federation protocol on stdin/stdout
|
|
72
|
+
send Send stdin to a route with --to, or to a conversation
|
|
73
|
+
you belong to with --conversation; a discovered Claude
|
|
74
|
+
session's route installs on its first send
|
|
75
|
+
reply Deprecated alias for send --conversation
|
|
76
|
+
delivery-status Read a delivery token
|
|
77
|
+
wait-delivery Wait for terminal delivery status
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
--token-stdin Read the peer token as the first LF-terminated stdin line
|
|
81
|
+
--emit-env Print the first registration token as an export command
|
|
82
|
+
--version, -v Print the version
|
|
83
|
+
--help, -h Show this help
|
|
84
|
+
`;
|
|
85
|
+
/**
|
|
86
|
+
* Fixed one-line stderr summaries; stdout carries the protocol and stderr
|
|
87
|
+
* never carries private detail. The `service` subtree is the one deliberate
|
|
88
|
+
* exception, and only for its own local files: it reports the plist path, the
|
|
89
|
+
* log path, a missing program path, and launchctl's bounded stderr, because
|
|
90
|
+
* managing those files is the whole command. launchctl's *stdout* is never
|
|
91
|
+
* quoted — a `print` dump carries the agent's environment values.
|
|
92
|
+
*/
|
|
93
|
+
const CLI_STDERR = {
|
|
94
|
+
input: "request rejected.",
|
|
95
|
+
decision: "gateway rejected the request.",
|
|
96
|
+
unavailable: "gateway unavailable.",
|
|
97
|
+
ambiguous: "outcome ambiguous; do not retry automatically.",
|
|
98
|
+
failure: "command failed.",
|
|
99
|
+
unsafe: "gateway state directory or socket has unexpected permissions or ownership. Verify the exact path, owner, and modes before retrying.",
|
|
100
|
+
tokenUnknown: "delivery token not recognized; it may have expired or left bounded retention.",
|
|
101
|
+
deliveryTimeout: "the delivery has not settled yet; the gateway is still running. Check again later with embassy delivery-status.",
|
|
102
|
+
};
|
|
103
|
+
/** Exact next-step remedies appended after the summary for the faults that have one. */
|
|
104
|
+
const CLI_HINT = {
|
|
105
|
+
noBrokerRunning: "No broker is running (state dir {stateDir}). Run `embassy service install` once, or `embassy serve` in a terminal — or verify EMBASSY_STATE_DIR is not scrubbed or misdirected (for example by a sandboxed task's HOME).",
|
|
106
|
+
controlConnectDenied: "the broker may be running, but this process cannot connect; grant this task write access to the gateway state directory, then retry. Do not start a second broker. If access should already work, verify EMBASSY_STATE_DIR names this user's own state directory.",
|
|
107
|
+
controlInvalidResponse: "if either Embassy installation changed recently, rebuild or repoint this client to the broker's installation; otherwise restart the broker, then retry.",
|
|
108
|
+
controlVersionMismatch: "rebuild or repoint this client to the broker's Embassy installation, then retry.",
|
|
109
|
+
stateAccessDenied: "local policy denied access to the gateway state directory; grant this process access, then retry starting the broker. If access should already work, verify EMBASSY_STATE_DIR names this user's own state directory.",
|
|
110
|
+
messageTooLarge: "message exceeds the 16 KiB acceptance cap; shorten or split it. For long prose, pipe the body from a file.",
|
|
111
|
+
stateResetRequired: "state reset required; follow docs/CONFIGURATION.md#private-state-reset. Resetting abandons unsettled work. To check for unsettled work after upgrading, temporarily use Embassy 2.0.x before resetting.",
|
|
112
|
+
callerIdentityConflict: "both agent identities were inherited; rerun this Codex-side call with env -u CLAUDE_CODE_MESSAGING_SOCKET, or this Claude-side call with env -u CODEX_THREAD_ID",
|
|
113
|
+
aliasHostMismatch: "aliases on this machine end with @{localHost} (from {stateDir}/nodes.json); found @{given}",
|
|
114
|
+
aliasHostDefaulted: "no nodes.json has been written at {stateDir} yet — a broker writes it on first start; until then this machine defaults to @{localHost}; found @{given}",
|
|
115
|
+
stateInUse: "another broker may own {stateDir}: if `embassy serve` is not running anywhere, the lock {stateDir}/.gateway-controller.lock is stale (recorded host {host}, pid {pid}) — remove it and start again.",
|
|
116
|
+
stateInUseUnrecorded: "another broker may own {stateDir}: if `embassy serve` is not running anywhere, the lock {stateDir}/.gateway-controller.lock is stale — remove it and start again.",
|
|
117
|
+
stateLockUnverified: "the lock {stateDir}/.gateway-controller.lock cannot be read as a controller record; if `embassy serve` is not running anywhere, remove that file and start again.",
|
|
118
|
+
nodeInventoryChanged: "nodes.json at {stateDir} changed while the broker was starting; start again.",
|
|
119
|
+
stateWriteFailed: "nodes.json could not be written at {stateDir} (disk full, read-only, or quota?)",
|
|
120
|
+
stateSyncFailed: "nodes.json was written at {stateDir} but the directory could not be synced; start again and check the volume",
|
|
121
|
+
unknownTarget: "no current route answers to that name. A Claude session is addressed by its live name: run embassy refresh, then read embassy status for the name it has now.",
|
|
122
|
+
aliasCollision: "the alias names more than one live session; rename one, or address the session by UUID with --to <session-uuid>.",
|
|
123
|
+
workspaceOverlap: "that session's workspace contains the gateway state directory; move one so they no longer overlap, then retry.",
|
|
124
|
+
callerAliasMismatch: "--from must be the sending session's own alias; read the name embassy status shows for this session.",
|
|
125
|
+
targetChanged: "the session you addressed renamed or exited while the send was being set up; run embassy refresh and address it by its current name.",
|
|
126
|
+
checkNoTarget: "no Codex task is registered, so there is nothing to check. Run `embassy register-codex --alias codex-<name>@{localHost}` from inside the task, or name any current route with `embassy check --to <alias>`.",
|
|
127
|
+
checkAllStale: "every registered Codex task is unobserved or was last observed more than ten minutes ago ({aliases}), so nothing was sent. Read `embassy status` for each one's remedy, or check one anyway with `embassy check --to <alias>`.",
|
|
128
|
+
peerAliasRequired: "a peer token is present (EMBASSY_PEER_TOKEN or --token-stdin) but --from is not a peer-* alias; unset the token or name the peer route.",
|
|
129
|
+
codexCallerRequired: "--from names a Codex task ({alias}) but no CODEX_THREAD_ID was inherited; run this inside that Codex task, as a shell step of its current turn.",
|
|
130
|
+
peerCallerRequired: "--from names a shell peer ({alias}) but no peer token was given; pass the peer_ token on the first stdin line with --token-stdin (or EMBASSY_PEER_TOKEN from a stable shell).",
|
|
131
|
+
codexRouteUnregistered: "no registration for {alias} on this broker; run `embassy register-codex --alias {alias}` from inside the Codex task (after a private state reset, every Codex task registers again).",
|
|
132
|
+
codexThreadMismatch: "{alias} is not registered to this task's inherited CODEX_THREAD_ID; --from must name the alias this task registered (read it from `embassy status`), or register this task with `embassy register-codex`.",
|
|
133
|
+
callerIdentityRequired: "no caller credential was inherited, and a conversation names no route to infer one from: run this inside the Codex task (CODEX_THREAD_ID), inside the Claude Code session (CLAUDE_CODE_MESSAGING_SOCKET), or as a registered shell peer with --token-stdin and the peer_ token on the first stdin line.",
|
|
134
|
+
unknownMethod: "the broker does not implement that control method, so this client and the broker are from different Embassy builds; rebuild or update this client to the broker's Embassy installation, then retry.",
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* The three connect-stage codes that all mean the same thing to the person
|
|
138
|
+
* reading them: nothing is serving this state directory. A missing socket
|
|
139
|
+
* never reaches the transport (validatePrivateGatewayControlSocket maps it to
|
|
140
|
+
* CONTROL_SOCKET_UNAVAILABLE first), and a broker that died leaving its socket
|
|
141
|
+
* behind reports CONTROL_LISTENER_UNAVAILABLE — so the hint has to cover all
|
|
142
|
+
* three or it is unreachable in practice.
|
|
143
|
+
*/
|
|
144
|
+
const isNoBrokerCode = (code) => code === "CONTROL_SOCKET_UNAVAILABLE" || code === "CONTROL_SOCKET_MISSING" ||
|
|
145
|
+
code === "CONTROL_LISTENER_UNAVAILABLE";
|
|
146
|
+
/** Best effort, and never throws: a hint must not replace the fault it explains. */
|
|
147
|
+
function resolvedStateDirForHint(env) {
|
|
148
|
+
try {
|
|
149
|
+
return path.resolve(defaultGatewayStateDir(env));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return env.EMBASSY_STATE_DIR ?? env.XDG_STATE_HOME ?? "unresolvable";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Renders a CLI_HINT entry, substituting {name} placeholders from `vars` in a
|
|
157
|
+
* single pass: a substituted value is never rescanned, so a state directory
|
|
158
|
+
* literally named `/tmp/{host}` cannot expand into anything else.
|
|
159
|
+
*/
|
|
160
|
+
function renderHint(hint, vars) {
|
|
161
|
+
const text = CLI_HINT[hint];
|
|
162
|
+
if (vars === undefined)
|
|
163
|
+
return text;
|
|
164
|
+
return text.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (token, name) => Object.hasOwn(vars, name) ? vars[name] : token);
|
|
165
|
+
}
|
|
166
|
+
const hintLine = (hint, env) => `[embassy] ${renderHint(hint, { stateDir: resolvedStateDirForHint(env) })}\n`;
|
|
45
167
|
class CliFault extends Error {
|
|
46
168
|
code;
|
|
47
169
|
retryable;
|
|
48
170
|
hint;
|
|
49
171
|
kind;
|
|
50
|
-
|
|
172
|
+
hintVars;
|
|
173
|
+
constructor(code, retryable = false, hint, kind, hintVars) {
|
|
51
174
|
super("The gateway client rejected the request.");
|
|
52
175
|
this.code = code;
|
|
53
176
|
this.retryable = retryable;
|
|
54
177
|
this.hint = hint;
|
|
55
178
|
this.kind = kind;
|
|
179
|
+
this.hintVars = hintVars;
|
|
56
180
|
this.name = "CliFault";
|
|
57
181
|
}
|
|
58
182
|
}
|
|
@@ -82,39 +206,7 @@ function parseOptions(args, valueNames, flagNames = []) {
|
|
|
82
206
|
}
|
|
83
207
|
return parsed;
|
|
84
208
|
}
|
|
85
|
-
|
|
86
|
-
const stripped = [];
|
|
87
|
-
let locale;
|
|
88
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
89
|
-
const token = args[index];
|
|
90
|
-
if (token !== "--lang") {
|
|
91
|
-
if (token !== undefined)
|
|
92
|
-
stripped.push(token);
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
if (locale !== undefined)
|
|
96
|
-
fault();
|
|
97
|
-
const value = args[index + 1];
|
|
98
|
-
if (!isDashboardLocale(value))
|
|
99
|
-
fault();
|
|
100
|
-
locale = value;
|
|
101
|
-
index += 1;
|
|
102
|
-
}
|
|
103
|
-
const inherited = env.EMBASSY_LOCALE;
|
|
104
|
-
if (locale === undefined && inherited !== undefined && inherited.length > 0 && !isDashboardLocale(inherited))
|
|
105
|
-
fault();
|
|
106
|
-
return {
|
|
107
|
-
args: stripped,
|
|
108
|
-
locale: locale ?? (isDashboardLocale(inherited) ? inherited : DEFAULT_CLI_LOCALE),
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
function fallbackCliLocale(args, env) {
|
|
112
|
-
const indices = args.flatMap((value, index) => value === "--lang" ? [index] : []);
|
|
113
|
-
const flagged = indices.length === 1 ? args[indices[0] + 1] : undefined;
|
|
114
|
-
return isDashboardLocale(flagged) ? flagged
|
|
115
|
-
: isDashboardLocale(env.EMBASSY_LOCALE) ? env.EMBASSY_LOCALE : DEFAULT_CLI_LOCALE;
|
|
116
|
-
}
|
|
117
|
-
const fixedStderr = (locale, kind) => `[embassy] ${getCliCopy(locale)[`error.${kind}`]}\n`;
|
|
209
|
+
const fixedStderr = (kind) => `[embassy] ${CLI_STDERR[kind]}\n`;
|
|
118
210
|
function requireString(options, name) {
|
|
119
211
|
const value = options[name];
|
|
120
212
|
if (typeof value !== "string")
|
|
@@ -139,12 +231,20 @@ function requireCodexAlias(options, name) {
|
|
|
139
231
|
return alias;
|
|
140
232
|
}
|
|
141
233
|
const gatewayAliasHost = (alias) => alias.slice(alias.lastIndexOf("@") + 1);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
234
|
+
/**
|
|
235
|
+
* An alias naming another host is rejected before any broker call. The hint
|
|
236
|
+
* says where this machine's own host came from, because the two cases have
|
|
237
|
+
* different remedies: a durable nodes.json is the answer, while a defaulted
|
|
238
|
+
* identity is still provisional until the first `embassy serve` records it.
|
|
239
|
+
*/
|
|
240
|
+
function aliasHostFault(local, given) {
|
|
241
|
+
return new CliFault("INVALID_ARGUMENTS", false, local.defaulted ? "aliasHostDefaulted" : "aliasHostMismatch", undefined, { localHost: local.host, given, stateDir: local.stateDir });
|
|
242
|
+
}
|
|
243
|
+
function requireConversationId(options, name) {
|
|
244
|
+
const conversationId = requireString(options, name);
|
|
245
|
+
if (!isGatewayConversationId(conversationId))
|
|
146
246
|
fault();
|
|
147
|
-
return
|
|
247
|
+
return conversationId;
|
|
148
248
|
}
|
|
149
249
|
function requireClaudeSelector(options, name) {
|
|
150
250
|
const selector = requireString(options, name);
|
|
@@ -152,22 +252,6 @@ function requireClaudeSelector(options, name) {
|
|
|
152
252
|
fault();
|
|
153
253
|
return selector;
|
|
154
254
|
}
|
|
155
|
-
function trackIdleMinutes(options) {
|
|
156
|
-
const tracking = options.track === true;
|
|
157
|
-
const raw = options["idle-minutes"];
|
|
158
|
-
if (raw !== undefined && !tracking)
|
|
159
|
-
fault();
|
|
160
|
-
if (!tracking)
|
|
161
|
-
return undefined;
|
|
162
|
-
if (raw === undefined)
|
|
163
|
-
return PROGRESS_WATCH_DEFAULT_IDLE_MS / 60_000;
|
|
164
|
-
if (typeof raw !== "string" || !/^[1-9][0-9]{0,3}$/.test(raw))
|
|
165
|
-
fault();
|
|
166
|
-
const minutes = Number(raw);
|
|
167
|
-
if (!Number.isSafeInteger(minutes) || minutes > 24 * 60)
|
|
168
|
-
fault();
|
|
169
|
-
return minutes;
|
|
170
|
-
}
|
|
171
255
|
function requireDeliveryToken(options, name) {
|
|
172
256
|
const token = requireString(options, name);
|
|
173
257
|
if (!isGatewayDeliveryToken(token))
|
|
@@ -239,7 +323,7 @@ async function readMessageBody(stdin) {
|
|
|
239
323
|
const buffer = Buffer.from(chunk);
|
|
240
324
|
length += buffer.length;
|
|
241
325
|
if (length > GATEWAY_CONTROL_MAX_MESSAGE_BYTES)
|
|
242
|
-
throw new CliFault("MESSAGE_TOO_LARGE", false, "
|
|
326
|
+
throw new CliFault("MESSAGE_TOO_LARGE", false, "messageTooLarge");
|
|
243
327
|
chunks.push(buffer);
|
|
244
328
|
}
|
|
245
329
|
let text;
|
|
@@ -264,7 +348,7 @@ async function readPeerInput(stdin, source, body) {
|
|
|
264
348
|
const buffer = Buffer.from(chunk);
|
|
265
349
|
length += buffer.length;
|
|
266
350
|
if (length > GATEWAY_CONTROL_MAX_MESSAGE_BYTES + 38)
|
|
267
|
-
throw new CliFault("MESSAGE_TOO_LARGE", false, "
|
|
351
|
+
throw new CliFault("MESSAGE_TOO_LARGE", false, "messageTooLarge");
|
|
268
352
|
chunks.push(buffer);
|
|
269
353
|
}
|
|
270
354
|
const value = Buffer.concat(chunks, length), newline = value.indexOf(0x0a);
|
|
@@ -285,66 +369,30 @@ async function readPeerInput(stdin, source, body) {
|
|
|
285
369
|
return { token, text };
|
|
286
370
|
}
|
|
287
371
|
const emptyParams = (args) => args.length === 0 ? {} : fault();
|
|
288
|
-
function parseServeInboundMode(args) {
|
|
289
|
-
const options = parseOptions(args, ["inbound"]);
|
|
290
|
-
if (Object.keys(options).length === 0)
|
|
291
|
-
return "paired";
|
|
292
|
-
count(options, 1);
|
|
293
|
-
if (options.inbound !== "open")
|
|
294
|
-
fault();
|
|
295
|
-
return "open";
|
|
296
|
-
}
|
|
297
|
-
function parseLiveDashboardPort(value) {
|
|
298
|
-
if (value === undefined)
|
|
299
|
-
return DEFAULT_LIVE_DASHBOARD_PORT;
|
|
300
|
-
if (typeof value !== "string" || !/^[0-9]+$/.test(value))
|
|
301
|
-
fault();
|
|
302
|
-
const port = Number(value);
|
|
303
|
-
if (!Number.isSafeInteger(port) || port < 1_024 || port > 65_535)
|
|
304
|
-
fault();
|
|
305
|
-
return port;
|
|
306
|
-
}
|
|
307
|
-
function parseLiveDashboardArgs(args) {
|
|
308
|
-
if (args.length === 0)
|
|
309
|
-
throw new CliFault("INVALID_ARGUMENTS", false, "hint.dashboardLiveRequired");
|
|
310
|
-
const options = parseOptions(args, ["port"], ["live"]);
|
|
311
|
-
count(options, 1, 2);
|
|
312
|
-
if (options.live !== true)
|
|
313
|
-
fault();
|
|
314
|
-
return parseLiveDashboardPort(options.port);
|
|
315
|
-
}
|
|
316
372
|
const envelope = (method, params) => ({ protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION, method, params });
|
|
317
373
|
async function buildRequest(command, args, env, stdin, loadLocalHost) {
|
|
318
374
|
const simple = {
|
|
319
|
-
health: "health",
|
|
320
|
-
"refresh-dashboard": "refresh_dashboard",
|
|
375
|
+
health: "health", refresh: "refresh_discovery",
|
|
321
376
|
};
|
|
322
377
|
const simpleMethod = simple[command];
|
|
323
378
|
if (simpleMethod !== undefined)
|
|
324
379
|
return envelope(simpleMethod, emptyParams(args));
|
|
325
380
|
switch (command) {
|
|
326
381
|
case "serve":
|
|
327
|
-
case "
|
|
382
|
+
case "service":
|
|
328
383
|
case "peer-stdio":
|
|
384
|
+
case "status":
|
|
385
|
+
case "watch":
|
|
386
|
+
case "check":
|
|
329
387
|
return fault();
|
|
330
388
|
case "health":
|
|
331
|
-
case "
|
|
332
|
-
case "doctor":
|
|
333
|
-
case "refresh-dashboard": return fault();
|
|
389
|
+
case "refresh": return fault();
|
|
334
390
|
case "delivery-status":
|
|
335
391
|
case "wait-delivery": {
|
|
336
392
|
const options = parseOptions(args, ["token"]);
|
|
337
393
|
count(options, 1);
|
|
338
394
|
return envelope("delivery_status", { token: requireDeliveryToken(options, "token") });
|
|
339
395
|
}
|
|
340
|
-
case "untrack": {
|
|
341
|
-
const options = parseOptions(args, ["conversation"]);
|
|
342
|
-
count(options, 1);
|
|
343
|
-
const conversationId = requireString(options, "conversation");
|
|
344
|
-
if (!isGatewayConversationId(conversationId))
|
|
345
|
-
fault();
|
|
346
|
-
return envelope("untrack", { conversationId });
|
|
347
|
-
}
|
|
348
396
|
case "register-codex": {
|
|
349
397
|
const options = parseOptions(args, ["alias", "succeeds"]);
|
|
350
398
|
const alias = requireCodexAlias(options, "alias");
|
|
@@ -353,19 +401,26 @@ async function buildRequest(command, args, env, stdin, loadLocalHost) {
|
|
|
353
401
|
const threadId = requireExclusiveCodexThreadId(env);
|
|
354
402
|
if (succeedsAlias === alias)
|
|
355
403
|
fault();
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
358
|
-
(
|
|
359
|
-
|
|
404
|
+
const local = await loadLocalHost();
|
|
405
|
+
if (gatewayAliasHost(alias) !== local.host)
|
|
406
|
+
throw aliasHostFault(local, gatewayAliasHost(alias));
|
|
407
|
+
if (succeedsAlias !== undefined && gatewayAliasHost(succeedsAlias) !== local.host) {
|
|
408
|
+
throw aliasHostFault(local, gatewayAliasHost(succeedsAlias));
|
|
409
|
+
}
|
|
360
410
|
return envelope("register_codex", {
|
|
361
|
-
alias, threadId, hostId:
|
|
411
|
+
alias, threadId, hostId: local.host, busyPolicy: "queue",
|
|
362
412
|
...(succeedsAlias === undefined ? {} : { succeedsAlias }),
|
|
363
413
|
});
|
|
364
414
|
}
|
|
365
415
|
case "unregister-codex": {
|
|
366
416
|
const options = parseOptions(args, ["alias"]);
|
|
367
417
|
count(options, 1);
|
|
368
|
-
|
|
418
|
+
const alias = requireCodexAlias(options, "alias");
|
|
419
|
+
const threadId = requireExclusiveCodexThreadId(env);
|
|
420
|
+
const local = await loadLocalHost();
|
|
421
|
+
if (gatewayAliasHost(alias) !== local.host)
|
|
422
|
+
throw aliasHostFault(local, gatewayAliasHost(alias));
|
|
423
|
+
return envelope("unregister_codex", { alias, threadId });
|
|
369
424
|
}
|
|
370
425
|
case "register-peer":
|
|
371
426
|
case "unregister-peer":
|
|
@@ -379,72 +434,73 @@ async function buildRequest(command, args, env, stdin, loadLocalHost) {
|
|
|
379
434
|
fault();
|
|
380
435
|
if (options["emit-env"] === true && source !== undefined)
|
|
381
436
|
fault();
|
|
437
|
+
const requireLocalAlias = async () => {
|
|
438
|
+
const local = await loadLocalHost();
|
|
439
|
+
if (gatewayAliasHost(alias) !== local.host)
|
|
440
|
+
throw aliasHostFault(local, gatewayAliasHost(alias));
|
|
441
|
+
};
|
|
382
442
|
if (source === undefined) {
|
|
383
443
|
if (command !== "register-peer")
|
|
384
444
|
fault("CALLER_IDENTITY_REQUIRED");
|
|
445
|
+
await requireLocalAlias();
|
|
385
446
|
return envelope("register_peer", { alias });
|
|
386
447
|
}
|
|
387
448
|
const { token } = await readPeerInput(stdin, source, false);
|
|
449
|
+
await requireLocalAlias();
|
|
388
450
|
return envelope(command === "register-peer" ? "register_peer" : command === "unregister-peer" ? "unregister_peer" : "await_peer", { alias, token });
|
|
389
451
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
452
|
+
// `reply` is the deprecated spelling of `send --conversation`: it names the
|
|
453
|
+
// caller's own alias `--alias` instead of `--from`, and both verbs build
|
|
454
|
+
// the one `send` request. Keep it until the reply hints already delivered
|
|
455
|
+
// in older envelopes have aged out.
|
|
456
|
+
case "send":
|
|
457
|
+
case "reply": {
|
|
458
|
+
const options = command === "reply"
|
|
459
|
+
? parseOptions(args, ["conversation", "alias"], ["token-stdin"])
|
|
460
|
+
: parseOptions(args, ["from", "to", "conversation"], ["expects-reply", "token-stdin"]);
|
|
461
|
+
count(options, 2, command === "reply" ? 3 : 4);
|
|
462
|
+
const fromAlias = requireAlias(options, command === "reply" ? "alias" : "from");
|
|
463
|
+
const conversationId = options.conversation === undefined
|
|
464
|
+
? undefined : requireConversationId(options, "conversation");
|
|
465
|
+
const toAlias = options.to === undefined
|
|
466
|
+
? undefined : requireClaudeSelector(options, "to");
|
|
467
|
+
// One target, and a conversation is always answered expecting a reply.
|
|
468
|
+
if ((toAlias === undefined) === (conversationId === undefined))
|
|
469
|
+
fault();
|
|
470
|
+
if (conversationId !== undefined && options["expects-reply"] === true)
|
|
471
|
+
fault();
|
|
406
472
|
const source = peerTokenSource(options, env);
|
|
407
473
|
const principals = Number(hasIdentity(env.CODEX_THREAD_ID)) + Number(hasIdentity(env.CLAUDE_CODE_MESSAGING_SOCKET)) + Number(source !== undefined);
|
|
408
474
|
if (principals > 1)
|
|
409
475
|
throw callerIdentityConflictFault(env);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
476
|
+
// A conversation names no route to infer a provider from, so a caller
|
|
477
|
+
// with nothing inherited gets the provider-neutral code and every
|
|
478
|
+
// credential it could present; a route send keeps its Claude-shaped
|
|
479
|
+
// default. A peer credential authenticates a peer-* alias and nothing
|
|
480
|
+
// else, in either addressing form.
|
|
481
|
+
if (principals === 0) {
|
|
482
|
+
if (conversationId !== undefined)
|
|
483
|
+
throw new CliFault("CALLER_IDENTITY_REQUIRED", false, "callerIdentityRequired");
|
|
484
|
+
// A route send names its sender's shape, so the hint can name the
|
|
485
|
+
// one credential that shape needs; only a Claude-shaped alias keeps
|
|
486
|
+
// the Claude-specific code.
|
|
487
|
+
if (fromAlias.startsWith("codex-"))
|
|
488
|
+
throw new CliFault("CALLER_IDENTITY_REQUIRED", false, "codexCallerRequired", undefined, { alias: fromAlias });
|
|
489
|
+
if (fromAlias.startsWith("peer-"))
|
|
490
|
+
throw new CliFault("CALLER_IDENTITY_REQUIRED", false, "peerCallerRequired", undefined, { alias: fromAlias });
|
|
491
|
+
}
|
|
492
|
+
if (source !== undefined && !fromAlias.startsWith("peer-"))
|
|
493
|
+
throw new CliFault("INVALID_ARGUMENTS", false, "peerAliasRequired");
|
|
413
494
|
const peer = source === undefined ? undefined : await readPeerInput(stdin, source, true);
|
|
414
495
|
const authority = peer === undefined ? hasIdentity(env.CODEX_THREAD_ID)
|
|
415
496
|
? { threadId: requireExclusiveCodexThreadId(env) }
|
|
416
497
|
: { replyAddress: requireExclusiveClaudeReplyAddress(env) }
|
|
417
498
|
: { peerToken: peer.token };
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
expectsReply: options["expects-reply"] === true
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
return envelope("send", { ...common, ...authority });
|
|
424
|
-
}
|
|
425
|
-
case "reply": {
|
|
426
|
-
const options = parseOptions(args, ["conversation", "alias", "idle-minutes"], ["track", "token-stdin"]);
|
|
427
|
-
count(options, 2, 5);
|
|
428
|
-
const conversationId = requireString(options, "conversation");
|
|
429
|
-
if (!isGatewayConversationId(conversationId))
|
|
430
|
-
fault();
|
|
431
|
-
const alias = requireAlias(options, "alias");
|
|
432
|
-
const idleMinutes = trackIdleMinutes(options);
|
|
433
|
-
const threadId = env.CODEX_THREAD_ID, source = peerTokenSource(options, env);
|
|
434
|
-
const principals = Number(hasIdentity(threadId)) + Number(hasIdentity(env.CLAUDE_CODE_MESSAGING_SOCKET)) + Number(source !== undefined);
|
|
435
|
-
if (principals > 1)
|
|
436
|
-
throw callerIdentityConflictFault(env);
|
|
437
|
-
const replyAddress = optionalClaudeReplyAddress(env);
|
|
438
|
-
const codex = hasIdentity(threadId);
|
|
439
|
-
if (!codex && replyAddress === undefined && source === undefined)
|
|
440
|
-
fault("CALLER_IDENTITY_REQUIRED");
|
|
441
|
-
const peer = source === undefined ? undefined : await readPeerInput(stdin, source, true);
|
|
442
|
-
return envelope("reply", {
|
|
443
|
-
conversationId, text: peer?.text ?? await readMessageBody(stdin),
|
|
444
|
-
...(idleMinutes === undefined ? {} : { trackIdleMinutes: idleMinutes }),
|
|
445
|
-
caller: peer !== undefined ? { kind: "peer", alias, token: peer.token } : codex
|
|
446
|
-
? { kind: "codex", alias, threadId: requireCodexThreadId(env) }
|
|
447
|
-
: { kind: "claude", alias, replyAddress: requireClaudeReplyAddress(env) },
|
|
499
|
+
const target = toAlias === undefined
|
|
500
|
+
? { conversationId: conversationId }
|
|
501
|
+
: { toAlias, expectsReply: options["expects-reply"] === true };
|
|
502
|
+
return envelope("send", {
|
|
503
|
+
fromAlias, text: peer?.text ?? await readMessageBody(stdin), ...target, ...authority,
|
|
448
504
|
});
|
|
449
505
|
}
|
|
450
506
|
}
|
|
@@ -460,7 +516,7 @@ export async function validatePrivateGatewayControlSocket(stateDir, socketPath)
|
|
|
460
516
|
const code = error !== null && typeof error === "object" && "code" in error
|
|
461
517
|
? error.code : undefined;
|
|
462
518
|
if (code === "EPERM" || code === "EACCES")
|
|
463
|
-
throw new CliFault("CONTROL_CONNECT_DENIED", true, "
|
|
519
|
+
throw new CliFault("CONTROL_CONNECT_DENIED", true, "controlConnectDenied");
|
|
464
520
|
throw new CliFault("CONTROL_SOCKET_UNAVAILABLE", true);
|
|
465
521
|
}
|
|
466
522
|
const uid = process.getuid?.();
|
|
@@ -501,46 +557,114 @@ async function writeComplete(output, frame) {
|
|
|
501
557
|
}
|
|
502
558
|
});
|
|
503
559
|
}
|
|
504
|
-
function writeFailure(stdout, stderr,
|
|
560
|
+
function writeFailure(stdout, stderr, command, code, options) {
|
|
505
561
|
stdout.write(serializedOutput({ ok: false, command: command ?? "unknown", error: {
|
|
506
562
|
code, ambiguous: options.ambiguous ?? false, retryable: options.retryable ?? false,
|
|
563
|
+
...(options.detail ?? {}),
|
|
507
564
|
} }));
|
|
508
|
-
stderr.write(fixedStderr(
|
|
565
|
+
stderr.write(fixedStderr(options.kind));
|
|
509
566
|
}
|
|
510
|
-
function writeStateResetHint(stderr,
|
|
567
|
+
function writeStateResetHint(stderr, code) {
|
|
511
568
|
if (code === "GATEWAY_STATE_SCHEMA_UNSUPPORTED" || code === "CORRUPT_GATEWAY_STATE") {
|
|
512
|
-
stderr.write(`[embassy] ${
|
|
569
|
+
stderr.write(`[embassy] ${CLI_HINT.stateResetRequired}\n`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* A BridgeError's own message never reaches a terminal — stderr carries only
|
|
574
|
+
* fixed lines and these hints, so nothing private can escape through a
|
|
575
|
+
* message. Any remedy an operator must actually read therefore lives here,
|
|
576
|
+
* keyed by code and interpolating only the resolved state directory and the
|
|
577
|
+
* bounded values the error carried in `detail`.
|
|
578
|
+
*/
|
|
579
|
+
const BRIDGE_ERROR_HINTS = {
|
|
580
|
+
GATEWAY_STATE_IN_USE: "stateInUse",
|
|
581
|
+
GATEWAY_STATE_LOCK_UNVERIFIED: "stateLockUnverified",
|
|
582
|
+
GATEWAY_NODE_INVENTORY_CHANGED: "nodeInventoryChanged",
|
|
583
|
+
GATEWAY_STATE_WRITE_FAILED: "stateWriteFailed",
|
|
584
|
+
};
|
|
585
|
+
/** The state directory a hint should name, resolved the same way every command resolves it. */
|
|
586
|
+
function hintStateDir(env) {
|
|
587
|
+
try {
|
|
588
|
+
return path.resolve(defaultGatewayStateDir(env));
|
|
513
589
|
}
|
|
590
|
+
catch {
|
|
591
|
+
return "the Embassy state directory";
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function writeBridgeErrorHint(stderr, error, env) {
|
|
595
|
+
const hint = BRIDGE_ERROR_HINTS[error.code];
|
|
596
|
+
if (hint === undefined)
|
|
597
|
+
return;
|
|
598
|
+
const detail = error.detail;
|
|
599
|
+
// Two codes render differently depending on what the error could establish:
|
|
600
|
+
// a lock whose recorded machine name is unrepresentable names no host at
|
|
601
|
+
// all, and a write that reached the file but not the directory entry says so.
|
|
602
|
+
const named = hint === "stateInUse" && (detail?.host === undefined || detail.pid === undefined)
|
|
603
|
+
? "stateInUseUnrecorded"
|
|
604
|
+
: hint === "stateWriteFailed" && detail?.stage === "sync" ? "stateSyncFailed" : hint;
|
|
605
|
+
stderr.write(`[embassy] ${renderHint(named, {
|
|
606
|
+
stateDir: hintStateDir(env),
|
|
607
|
+
...(detail?.host === undefined ? {} : { host: detail.host }),
|
|
608
|
+
...(detail?.pid === undefined ? {} : { pid: detail.pid }),
|
|
609
|
+
})}\n`);
|
|
514
610
|
}
|
|
515
611
|
function isRejectedResult(result) {
|
|
516
612
|
return result !== null && typeof result === "object" && result.accepted === false;
|
|
517
613
|
}
|
|
518
|
-
function
|
|
519
|
-
|
|
614
|
+
function refusalHint(request, result) {
|
|
615
|
+
if (!isRejectedResult(result))
|
|
616
|
+
return undefined;
|
|
617
|
+
const reason = result.reason;
|
|
618
|
+
const from = request.method === "send" ? request.params.fromAlias : undefined;
|
|
619
|
+
if (reason === "PEER_ALIAS_COLLISION")
|
|
620
|
+
return { hint: "aliasCollision" };
|
|
621
|
+
if (typeof reason === "string" && reason.startsWith("CLAUDE_PEER_WORKSPACE_"))
|
|
622
|
+
return { hint: "workspaceOverlap" };
|
|
623
|
+
// A Codex task with no registration under its alias — every task, after a
|
|
624
|
+
// private state reset — is told to register; a registration held by another
|
|
625
|
+
// task is a genuine mismatch with its own remedy; a conversation answered
|
|
626
|
+
// from the wrong end gets the own-alias remedy.
|
|
627
|
+
if (reason === "ROUTE_UNREGISTERED" && from !== undefined && from.startsWith("codex-")) {
|
|
628
|
+
return { hint: "codexRouteUnregistered", vars: { alias: from } };
|
|
629
|
+
}
|
|
630
|
+
if (reason === "CODEX_THREAD_MISMATCH" && from !== undefined)
|
|
631
|
+
return { hint: "codexThreadMismatch", vars: { alias: from } };
|
|
632
|
+
if (reason === "CLAUDE_ROUTE_MISMATCH" || reason === "CONVERSATION_CALLER_MISMATCH")
|
|
633
|
+
return { hint: "callerAliasMismatch" };
|
|
634
|
+
if (reason === "CLAUDE_TARGET_CHANGED")
|
|
635
|
+
return { hint: "targetChanged" };
|
|
636
|
+
if (result.code !== "not_found" || request.method !== "send")
|
|
637
|
+
return undefined;
|
|
638
|
+
const target = request.params.toAlias;
|
|
639
|
+
if (target === undefined)
|
|
640
|
+
return undefined;
|
|
641
|
+
return target.startsWith("codex-") || target.startsWith("peer-") ? undefined : { hint: "unknownTarget" };
|
|
642
|
+
}
|
|
643
|
+
/** UNKNOWN_METHOD is build skew, not an argument error: say which side to move. */
|
|
644
|
+
function writeUnknownMethodHint(stderr, code) {
|
|
645
|
+
if (code === "UNKNOWN_METHOD")
|
|
646
|
+
stderr.write(`[embassy] ${CLI_HINT.unknownMethod}\n`);
|
|
520
647
|
}
|
|
521
648
|
function responseExitCode(response) {
|
|
522
649
|
return !response.ok ? gatewayCliExitCodes.failure
|
|
523
650
|
: isRejectedResult(response.result) ? gatewayCliExitCodes.rejected : gatewayCliExitCodes.ok;
|
|
524
651
|
}
|
|
525
|
-
function codexDoctorConditions(result) {
|
|
526
|
-
const connectors = result !== null && typeof result === "object"
|
|
527
|
-
? result.connectors : undefined;
|
|
528
|
-
const connector = Array.isArray(connectors) ? connectors.find((row) => row !== null && typeof row === "object" && row.provider === "codex") : undefined;
|
|
529
|
-
const doctor = connector !== null && typeof connector === "object"
|
|
530
|
-
? connector.codexDoctor : undefined;
|
|
531
|
-
const conditions = doctor !== null && typeof doctor === "object"
|
|
532
|
-
? doctor.conditions : undefined;
|
|
533
|
-
return Array.isArray(conditions)
|
|
534
|
-
? conditions.filter((value) => typeof value === "string") : ["unknown"];
|
|
535
|
-
}
|
|
536
652
|
function waitDeliveryExitCode(response) {
|
|
537
653
|
return response.ok && response.result.found && response.result.terminal && response.result.state === "delivered"
|
|
538
654
|
? gatewayCliExitCodes.ok : gatewayCliExitCodes.failure;
|
|
539
655
|
}
|
|
540
656
|
const defaultDelay = async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
541
|
-
async function waitForDelivery(socketPath, request, sendRequest, now, delay
|
|
542
|
-
|
|
657
|
+
async function waitForDelivery(socketPath, request, sendRequest, now, delay,
|
|
658
|
+
// `check` bounds this wait by its own budget; `wait-delivery` keeps waiting
|
|
659
|
+
// to the broker's delivery deadline, which is hours by default.
|
|
660
|
+
maximumWaitMs,
|
|
661
|
+
// `check` also passes its Ctrl-C signal, so an operator who gives up during
|
|
662
|
+
// this wait reaches the cleanup hop within one poll instead of one budget.
|
|
663
|
+
signal) {
|
|
664
|
+
let deadline = maximumWaitMs === undefined ? undefined : now() + maximumWaitMs;
|
|
543
665
|
while (true) {
|
|
666
|
+
if (signal?.aborted === true)
|
|
667
|
+
return { kind: "interrupted" };
|
|
544
668
|
const remaining = deadline === undefined ? undefined : deadline - now();
|
|
545
669
|
if (remaining !== undefined && remaining <= 0)
|
|
546
670
|
return { kind: "timeout" };
|
|
@@ -580,6 +704,343 @@ async function waitForDelivery(socketPath, request, sendRequest, now, delay) {
|
|
|
580
704
|
await delay(Math.min(DELIVERY_POLL_INTERVAL_MS, after));
|
|
581
705
|
}
|
|
582
706
|
}
|
|
707
|
+
const SERVICE_HEALTH_DEADLINE_MS = 10_000;
|
|
708
|
+
const SERVICE_HEALTH_POLL_INTERVAL_MS = 200;
|
|
709
|
+
/**
|
|
710
|
+
* Per-attempt cap. Without one, each attempt inherits the 3-second control
|
|
711
|
+
* timeout and a stalled socket stretches the "10 s" window past two minutes.
|
|
712
|
+
*/
|
|
713
|
+
const SERVICE_HEALTH_REQUEST_TIMEOUT_MS = 1_000;
|
|
714
|
+
/**
|
|
715
|
+
* control.ts rejects any timeout below 50 ms with CONTROL_INVALID_RESPONSE.
|
|
716
|
+
* An attempt squeezed into the tail of the window would therefore fabricate a
|
|
717
|
+
* fault and become the "last observed" code, so the poll treats less than
|
|
718
|
+
* this much remaining as the deadline already reached.
|
|
719
|
+
*/
|
|
720
|
+
const SERVICE_HEALTH_MIN_REQUEST_TIMEOUT_MS = 50;
|
|
721
|
+
/** A second, independent bound: a clock that never advances cannot loop forever. */
|
|
722
|
+
const SERVICE_HEALTH_MAX_ATTEMPTS = SERVICE_HEALTH_DEADLINE_MS / SERVICE_HEALTH_POLL_INTERVAL_MS;
|
|
723
|
+
/** Elapsed time is measured monotonically; a wall-clock step must not move it. */
|
|
724
|
+
const monotonicNow = () => performance.now();
|
|
725
|
+
/**
|
|
726
|
+
* Codes that answer the question rather than postpone it. Polling still runs
|
|
727
|
+
* to the deadline — a mode or ownership check can be momentarily unlucky
|
|
728
|
+
* while the broker is publishing its socket — but if the *last* thing
|
|
729
|
+
* observed was one of these refusals rather than silence, install reports it
|
|
730
|
+
* with that code's own class and points at `embassy health`, the command that
|
|
731
|
+
* explains it, instead of a retryable timeout.
|
|
732
|
+
*/
|
|
733
|
+
const SERVICE_HEALTH_DECISIVE = new Map([
|
|
734
|
+
["CONTROL_STATE_UNSAFE", { kind: "unsafe", retryable: false, exitCode: gatewayCliExitCodes.invalidInput }],
|
|
735
|
+
["CONTROL_SOCKET_UNSAFE", { kind: "unsafe", retryable: false, exitCode: gatewayCliExitCodes.invalidInput }],
|
|
736
|
+
["CONTROL_CONNECT_DENIED", { kind: "unavailable", hint: "controlConnectDenied", retryable: true, exitCode: gatewayCliExitCodes.unavailable }],
|
|
737
|
+
["CONTROL_VERSION_MISMATCH", { kind: "unavailable", hint: "controlVersionMismatch", retryable: true, exitCode: gatewayCliExitCodes.unavailable }],
|
|
738
|
+
]);
|
|
739
|
+
/**
|
|
740
|
+
* The install command's own probe, bounded by wall clock rather than by an
|
|
741
|
+
* attempt count: a freshly bootstrapped launchd agent has to load its state
|
|
742
|
+
* and publish a control socket, which on a cold cache is seconds. This never
|
|
743
|
+
* throws, but silence at the deadline is not success — install reports the
|
|
744
|
+
* last code it observed and exits non-zero, because an agent that never
|
|
745
|
+
* answered is exactly the case the operator has to hear about.
|
|
746
|
+
*/
|
|
747
|
+
async function pollServiceHealth(config, sendRequest, validateSocket, delay, now) {
|
|
748
|
+
const started = now();
|
|
749
|
+
const elapsed = () => Math.max(0, now() - started);
|
|
750
|
+
const remainingMs = () => SERVICE_HEALTH_DEADLINE_MS - elapsed();
|
|
751
|
+
let lastObserved = "SERVICE_HEALTH_NO_RESPONSE";
|
|
752
|
+
for (let attempt = 0; attempt < SERVICE_HEALTH_MAX_ATTEMPTS; attempt += 1) {
|
|
753
|
+
const remaining = remainingMs();
|
|
754
|
+
if (remaining < SERVICE_HEALTH_MIN_REQUEST_TIMEOUT_MS)
|
|
755
|
+
break;
|
|
756
|
+
try {
|
|
757
|
+
await validateSocket(config.stateDir, config.controlSocketPath);
|
|
758
|
+
const response = await sendRequest({
|
|
759
|
+
socketPath: config.controlSocketPath,
|
|
760
|
+
request: envelope("health", {}),
|
|
761
|
+
timeoutMs: Math.floor(Math.min(remaining, SERVICE_HEALTH_REQUEST_TIMEOUT_MS)),
|
|
762
|
+
});
|
|
763
|
+
if (response.ok)
|
|
764
|
+
return { ok: true, result: response.result, elapsedMs: elapsed() };
|
|
765
|
+
lastObserved = response.error.code;
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
lastObserved = error instanceof GatewayControlTransportError ? error.code
|
|
769
|
+
: error instanceof CliFault ? error.code
|
|
770
|
+
: error instanceof BridgeError ? error.code
|
|
771
|
+
: "SERVICE_HEALTH_NO_RESPONSE";
|
|
772
|
+
}
|
|
773
|
+
const left = remainingMs();
|
|
774
|
+
if (left < SERVICE_HEALTH_MIN_REQUEST_TIMEOUT_MS)
|
|
775
|
+
break;
|
|
776
|
+
await delay(Math.min(SERVICE_HEALTH_POLL_INTERVAL_MS, left));
|
|
777
|
+
}
|
|
778
|
+
return { ok: false, lastObserved, elapsedMs: elapsed() };
|
|
779
|
+
}
|
|
780
|
+
const WATCH_POLL_INTERVAL_MS = 1_000;
|
|
781
|
+
const CHECK_TIMEOUT_DEFAULT_SECONDS = 60;
|
|
782
|
+
const CHECK_TIMEOUT_MAX_SECONDS = 600;
|
|
783
|
+
/** control.ts refuses a request timeout below this, so it is also the floor here. */
|
|
784
|
+
const CONTROL_MIN_REQUEST_TIMEOUT_MS = 50;
|
|
785
|
+
/**
|
|
786
|
+
* The unit the attempt bound is counted in while `check` awaits a reply: one
|
|
787
|
+
* attempt per second of budget, plus one. A broker answering `timeout`
|
|
788
|
+
* instantly therefore cannot spin the loop even if the clock never moves.
|
|
789
|
+
*/
|
|
790
|
+
const CHECK_REPLY_ATTEMPT_MS = 1_000;
|
|
791
|
+
const MAX_CONTROLLER_LOCK_BYTES = 4_096;
|
|
792
|
+
/**
|
|
793
|
+
* Whether stdout is a terminal. Reading the stream's own `isTTY` rather than
|
|
794
|
+
* `process.stdout` keeps this injectable: a test writes to a capture that
|
|
795
|
+
* declares `isTTY`, and nothing consults the real process.
|
|
796
|
+
*/
|
|
797
|
+
const isTerminal = (output) => output.isTTY === true;
|
|
798
|
+
/** Color is opt-out through NO_COLOR, and never carries meaning by itself. */
|
|
799
|
+
const useColor = (output, env) => isTerminal(output) && (env.NO_COLOR === undefined || env.NO_COLOR.length === 0);
|
|
800
|
+
/** A bounded integer option; anything outside the range is an argument fault. */
|
|
801
|
+
function boundedOption(options, name, fallback, minimum, maximum) {
|
|
802
|
+
const raw = options[name];
|
|
803
|
+
if (raw === undefined)
|
|
804
|
+
return fallback;
|
|
805
|
+
if (typeof raw !== "string" || !/^[0-9]{1,9}$/.test(raw))
|
|
806
|
+
fault();
|
|
807
|
+
const value = Number(raw);
|
|
808
|
+
if (value < minimum || value > maximum)
|
|
809
|
+
fault();
|
|
810
|
+
return value;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* The broker's pid, from the controller lock its store writes in the state
|
|
814
|
+
* directory. Best effort and never throws: `status` prints with or without it,
|
|
815
|
+
* and that file's path is already named in the CLI's own hints, so this
|
|
816
|
+
* discloses nothing new.
|
|
817
|
+
*/
|
|
818
|
+
export async function readGatewayControllerPid(stateDir) {
|
|
819
|
+
return recordedPid(await readOwnedSmallFile(path.join(stateDir, GATEWAY_CONTROLLER_LOCK_FILE), process.getuid?.(), MAX_CONTROLLER_LOCK_BYTES));
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* `watch` and `check` both run until the operator stops them, and `check` has
|
|
823
|
+
* a temporary registration to take back down when that happens. Tests inject
|
|
824
|
+
* the signal; a real terminal gets SIGINT/SIGTERM handlers that are removed
|
|
825
|
+
* again on the way out, so Ctrl-C runs the cleanup path instead of killing
|
|
826
|
+
* node with 130 and leaving the registration to its own expiry.
|
|
827
|
+
*/
|
|
828
|
+
function interruptSignal(signal) {
|
|
829
|
+
if (signal !== undefined)
|
|
830
|
+
return { signal, dispose: () => undefined };
|
|
831
|
+
const controller = new AbortController();
|
|
832
|
+
const stop = () => controller.abort();
|
|
833
|
+
process.on("SIGINT", stop);
|
|
834
|
+
process.on("SIGTERM", stop);
|
|
835
|
+
return { signal: controller.signal, dispose: () => {
|
|
836
|
+
process.off("SIGINT", stop);
|
|
837
|
+
process.off("SIGTERM", stop);
|
|
838
|
+
} };
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* How long the throwaway registration is allowed to outlive the check: the
|
|
842
|
+
* whole budget plus a minute, so a broker that is merely slow still finds a
|
|
843
|
+
* live mailbox, and an operator who kills the process at the worst possible
|
|
844
|
+
* moment waits at most that long for the broker to reclaim it.
|
|
845
|
+
*/
|
|
846
|
+
const CHECK_IDENTITY_GRACE_MS = 60_000;
|
|
847
|
+
/**
|
|
848
|
+
* Set when a real Ctrl-C interrupted `check`. The abandoned `await_peer`
|
|
849
|
+
* request may still hold its socket — and with it the event loop — until the
|
|
850
|
+
* broker answers it. The operator already has the summary, so the entry point
|
|
851
|
+
* exits once stdio has drained instead of lingering on that socket.
|
|
852
|
+
*/
|
|
853
|
+
let interruptedCheckExit = false;
|
|
854
|
+
/**
|
|
855
|
+
* The round-trip self-test. It mints its own principal — a throwaway `peer-*`
|
|
856
|
+
* registration — rather than borrowing an inherited Codex or Claude identity,
|
|
857
|
+
* because the operator running it after a Claude Code or Codex upgrade is
|
|
858
|
+
* usually in a plain terminal with neither. That also gives the reply
|
|
859
|
+
* somewhere to arrive: the peer mailbox this process is the only waiter on.
|
|
860
|
+
* The conversation token returned by the send is the correlation seam; the
|
|
861
|
+
* echoed id is confirmation printed beside it, never a second identity.
|
|
862
|
+
*/
|
|
863
|
+
async function runGatewayCheck(options) {
|
|
864
|
+
const { sendRequest, socketPath, stdout, stderr } = options;
|
|
865
|
+
const paint = terminalPainter(options.color);
|
|
866
|
+
const call = async (method, params, timeoutMs) => {
|
|
867
|
+
const response = await sendRequest({
|
|
868
|
+
socketPath, request: envelope(method, params),
|
|
869
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
870
|
+
});
|
|
871
|
+
if (!response.ok)
|
|
872
|
+
writeUnknownMethodHint(stderr, response.error.code);
|
|
873
|
+
return response;
|
|
874
|
+
};
|
|
875
|
+
const hops = [];
|
|
876
|
+
const emit = (hop) => {
|
|
877
|
+
hops.push(hop);
|
|
878
|
+
const mark = hop.ok ? paint("ok ", "green") : paint("FAIL", "red");
|
|
879
|
+
const timing = hop.elapsedMs === undefined ? "" : ` ${String(hop.elapsedMs)} ms`;
|
|
880
|
+
stdout.write(` ${mark} ${hop.name.padEnd(9)} ${hop.detail}${timing}\n`);
|
|
881
|
+
return hop;
|
|
882
|
+
};
|
|
883
|
+
const alias = `peer-check-${randomBytes(4).toString("hex")}@${options.hostId}`;
|
|
884
|
+
const id = randomBytes(4).toString("hex");
|
|
885
|
+
stdout.write(`embassy check ${id} → ${options.target}\n\n`);
|
|
886
|
+
let stage = options.now();
|
|
887
|
+
const lap = () => {
|
|
888
|
+
const at = options.now();
|
|
889
|
+
const elapsed = Math.max(0, Math.round(at - stage));
|
|
890
|
+
stage = at;
|
|
891
|
+
return elapsed;
|
|
892
|
+
};
|
|
893
|
+
const ttlMs = options.timeoutMs + CHECK_IDENTITY_GRACE_MS;
|
|
894
|
+
// Ctrl-C. The signal is raced against the two long waits below, so the
|
|
895
|
+
// cleanup hop runs the moment the operator gives up, not when the broker's
|
|
896
|
+
// long-poll happens to return.
|
|
897
|
+
let interrupted = false;
|
|
898
|
+
const interruption = new Promise((resolve) => {
|
|
899
|
+
if (options.interrupt.aborted) {
|
|
900
|
+
resolve("interrupted");
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
options.interrupt.addEventListener("abort", () => resolve("interrupted"), { once: true });
|
|
904
|
+
});
|
|
905
|
+
const summarize = (exitCode) => {
|
|
906
|
+
const failed = hops.find((hop) => !hop.ok && hop.name !== "cleanup");
|
|
907
|
+
const cleanup = hops.find((hop) => !hop.ok && hop.name === "cleanup");
|
|
908
|
+
stdout.write(`\n${failed !== undefined
|
|
909
|
+
? `check ${interrupted ? "interrupted" : "failed"} at the ${failed.name} hop`
|
|
910
|
+
: cleanup === undefined ? "check passed"
|
|
911
|
+
: `check passed; cleanup failed (${cleanup.code ?? "no safe code"})`}\n`);
|
|
912
|
+
return failed === undefined && cleanup !== undefined ? gatewayCliExitCodes.failure : exitCode;
|
|
913
|
+
};
|
|
914
|
+
const registered = await call("register_peer", { alias, ephemeral: true, ttlMs });
|
|
915
|
+
if (!registered.ok || !("token" in registered.result)) {
|
|
916
|
+
const refusal = !registered.ok ? registered.error.code
|
|
917
|
+
: `the broker refused a temporary check identity (${registered.result.code}${"reason" in registered.result && registered.result.reason !== undefined
|
|
918
|
+
? ` ${registered.result.reason}` : ""})`;
|
|
919
|
+
emit({ ok: false, name: "register", detail: refusal, elapsedMs: lap() });
|
|
920
|
+
return summarize(gatewayCliExitCodes.failure);
|
|
921
|
+
}
|
|
922
|
+
const token = registered.result.token;
|
|
923
|
+
emit({ ok: true, name: "register", detail: `${alias} (ephemeral, ${String(Math.round(ttlMs / 60_000))} min)`, elapsedMs: lap() });
|
|
924
|
+
// Every later hop runs inside this closure so that one `finally` releases
|
|
925
|
+
// the temporary identity and the summary line is printed exactly once,
|
|
926
|
+
// whichever hop gave up first.
|
|
927
|
+
const roundTrip = async () => {
|
|
928
|
+
// The body names no reply verb: the broker's own reply hint, delivered
|
|
929
|
+
// inside the provenance envelope, always carries the current one.
|
|
930
|
+
const text = `[embassy check ${id}] Embassy round-trip self-test. Reply with one line repeating ${id}, using the command in this message's reply hint.`;
|
|
931
|
+
const sent = await call("send", {
|
|
932
|
+
fromAlias: alias, toAlias: options.target, text, expectsReply: true, peerToken: token
|
|
933
|
+
});
|
|
934
|
+
if (!sent.ok) {
|
|
935
|
+
emit({ ok: false, name: "send", detail: sent.error.code, elapsedMs: lap() });
|
|
936
|
+
return gatewayCliExitCodes.failure;
|
|
937
|
+
}
|
|
938
|
+
if (!sent.result.accepted) {
|
|
939
|
+
const { code, reason } = sent.result;
|
|
940
|
+
emit({ ok: false, name: "send",
|
|
941
|
+
detail: `${code}${reason === undefined ? "" : ` ${reason}`}`, elapsedMs: lap() });
|
|
942
|
+
return gatewayCliExitCodes.rejected;
|
|
943
|
+
}
|
|
944
|
+
const { conversationId, deliveryToken } = sent.result;
|
|
945
|
+
emit({ ok: true, name: "send", detail: `accepted, conversation …${conversationId.slice(-8)}`, elapsedMs: lap() });
|
|
946
|
+
const delivery = await waitForDelivery(socketPath, envelope("delivery_status", { token: deliveryToken }), sendRequest, options.now, options.delay, options.timeoutMs, options.interrupt);
|
|
947
|
+
if (delivery.kind === "interrupted") {
|
|
948
|
+
interrupted = true;
|
|
949
|
+
emit({ ok: false, name: "delivered", detail: "interrupted before it settled", elapsedMs: lap() });
|
|
950
|
+
return gatewayCliExitCodes.failure;
|
|
951
|
+
}
|
|
952
|
+
if (delivery.kind !== "response" || !delivery.response.ok || !delivery.response.result.found ||
|
|
953
|
+
delivery.response.result.state !== "delivered") {
|
|
954
|
+
const detail = delivery.kind === "timeout"
|
|
955
|
+
? `still unsettled after ${String(Math.round(options.timeoutMs / 1_000))} s`
|
|
956
|
+
: delivery.kind === "unknown" ? "the delivery token left bounded retention"
|
|
957
|
+
: !delivery.response.ok ? delivery.response.error.code
|
|
958
|
+
: delivery.response.result.found
|
|
959
|
+
? `${delivery.response.result.state}${delivery.response.result.safeErrorCode === undefined
|
|
960
|
+
? "" : ` ${delivery.response.result.safeErrorCode}`}`
|
|
961
|
+
: "the delivery token left bounded retention";
|
|
962
|
+
emit({ ok: false, name: "delivered", detail, elapsedMs: lap() });
|
|
963
|
+
return gatewayCliExitCodes.failure;
|
|
964
|
+
}
|
|
965
|
+
emit({ ok: true, name: "delivered", detail: "the peer's transport accepted it", elapsedMs: lap() });
|
|
966
|
+
// Two independent bounds, as everywhere else that polls here: the wall
|
|
967
|
+
// clock, and an attempt count, so a broker that answers "timeout"
|
|
968
|
+
// instantly cannot spin this loop forever.
|
|
969
|
+
const replyDeadline = options.now() + options.timeoutMs;
|
|
970
|
+
const attempts = Math.ceil(options.timeoutMs / CHECK_REPLY_ATTEMPT_MS) + 1;
|
|
971
|
+
let answered = false;
|
|
972
|
+
let uncorrelated = 0;
|
|
973
|
+
for (let attempt = 0; attempt < attempts && !answered; attempt += 1) {
|
|
974
|
+
const remaining = replyDeadline - options.now();
|
|
975
|
+
if (remaining < CONTROL_MIN_REQUEST_TIMEOUT_MS)
|
|
976
|
+
break;
|
|
977
|
+
// The long-poll is raced against Ctrl-C rather than merely checked
|
|
978
|
+
// between polls: the broker holds `await_peer` open for up to 35 s, and
|
|
979
|
+
// the cleanup below must not wait for that. The abandoned request ends
|
|
980
|
+
// on its own once the cleanup retires the mailbox it was waiting on.
|
|
981
|
+
const waited = options.interrupt.aborted ? "interrupted" : await Promise.race([
|
|
982
|
+
call("await_peer", { alias, token }, Math.min(PEER_AWAIT_REQUEST_TIMEOUT_MS, Math.floor(remaining))),
|
|
983
|
+
interruption,
|
|
984
|
+
]);
|
|
985
|
+
if (waited === "interrupted") {
|
|
986
|
+
interrupted = true;
|
|
987
|
+
emit({ ok: false, name: "reply", detail: "interrupted before a reply arrived", elapsedMs: lap() });
|
|
988
|
+
return gatewayCliExitCodes.failure;
|
|
989
|
+
}
|
|
990
|
+
if (!waited.ok) {
|
|
991
|
+
if (waited.error.code === "REQUEST_TIMEOUT")
|
|
992
|
+
continue;
|
|
993
|
+
emit({ ok: false, name: "reply", detail: waited.error.code, elapsedMs: lap() });
|
|
994
|
+
return gatewayCliExitCodes.failure;
|
|
995
|
+
}
|
|
996
|
+
if (waited.result.state === "timeout")
|
|
997
|
+
continue;
|
|
998
|
+
// The receipt is part of the round trip: a message the broker would not
|
|
999
|
+
// let this mailbox acknowledge is a failed reply hop, not a passed one.
|
|
1000
|
+
const receipt = await call("peer_receipt", { alias, token, receipt: waited.result.receipt });
|
|
1001
|
+
if (!receipt.ok || !receipt.result.accepted) {
|
|
1002
|
+
emit({ ok: false, name: "reply", detail: !receipt.ok ? receipt.error.code
|
|
1003
|
+
: `receipt refused (${receipt.result.code}${"reason" in receipt.result && receipt.result.reason !== undefined
|
|
1004
|
+
? ` ${String(receipt.result.reason)}` : ""})`, elapsedMs: lap() });
|
|
1005
|
+
return gatewayCliExitCodes.failure;
|
|
1006
|
+
}
|
|
1007
|
+
const frame = JSON.parse(waited.result.frame);
|
|
1008
|
+
if (frame.result.conversationId !== conversationId) {
|
|
1009
|
+
// Consumed, because this mailbox has exactly one waiter and the
|
|
1010
|
+
// message would otherwise sit unacknowledged — but never silently:
|
|
1011
|
+
// an answer that opened its own conversation is the single most
|
|
1012
|
+
// likely way a peer's reply rule has drifted.
|
|
1013
|
+
uncorrelated += 1;
|
|
1014
|
+
stdout.write(paint(` note received an uncorrelated message from ${frame.result.fromAlias} (new conversation) — replies must use \`embassy send --conversation <token>\`\n`, "yellow"));
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
emit({ ok: true, name: "reply", detail: `${frame.result.fromAlias}${frame.result.text.includes(id) ? ` echoed ${id}` : ` answered without repeating ${id}`}`,
|
|
1018
|
+
elapsedMs: lap() });
|
|
1019
|
+
answered = true;
|
|
1020
|
+
}
|
|
1021
|
+
if (!answered) {
|
|
1022
|
+
emit({ ok: false, name: "reply", detail: `no reply within ${String(Math.round(options.timeoutMs / 1_000))} s — the peer received the message but did not answer${uncorrelated === 0 ? "" : ` (${String(uncorrelated)} uncorrelated message(s) received)`}`,
|
|
1023
|
+
elapsedMs: lap() });
|
|
1024
|
+
return gatewayCliExitCodes.failure;
|
|
1025
|
+
}
|
|
1026
|
+
return gatewayCliExitCodes.ok;
|
|
1027
|
+
};
|
|
1028
|
+
let exitCode;
|
|
1029
|
+
try {
|
|
1030
|
+
exitCode = await roundTrip();
|
|
1031
|
+
}
|
|
1032
|
+
finally {
|
|
1033
|
+
const released = await call("unregister_peer", { alias, token }).catch(() => undefined);
|
|
1034
|
+
const failure = released === undefined ? "CONTROL_REQUEST_FAILED"
|
|
1035
|
+
: !released.ok ? released.error.code
|
|
1036
|
+
: released.result.accepted ? undefined : released.result.code;
|
|
1037
|
+
emit({ ok: failure === undefined, name: "cleanup",
|
|
1038
|
+
...(failure === undefined ? {} : { code: failure }),
|
|
1039
|
+
detail: failure === undefined ? "temporary check identity removed"
|
|
1040
|
+
: `the temporary check identity could not be removed; it expires on its own within ${String(Math.round(ttlMs / 60_000))} min` });
|
|
1041
|
+
}
|
|
1042
|
+
return summarize(exitCode);
|
|
1043
|
+
}
|
|
583
1044
|
/** Run one command; foreground runners own and release their signal handlers. */
|
|
584
1045
|
export async function runGatewayCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
585
1046
|
const env = dependencies.env ?? process.env;
|
|
@@ -592,29 +1053,26 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
|
|
|
592
1053
|
return gatewayCliExitCodes.ok;
|
|
593
1054
|
}
|
|
594
1055
|
const command = isCommand(argv[0]) ? argv[0] : undefined;
|
|
595
|
-
|
|
596
|
-
let serverReady = false
|
|
597
|
-
let liveDashboardPort;
|
|
1056
|
+
const args = argv.slice(1);
|
|
1057
|
+
let serverReady = false;
|
|
598
1058
|
let identity;
|
|
599
1059
|
const loadIdentity = () => identity ??= (async () => {
|
|
600
1060
|
const inventory = await (dependencies.loadNodeInventory ?? loadGatewayNodeInventory)(path.resolve(defaultGatewayStateDir(env)));
|
|
601
|
-
return { inventory, config: loadConfig(env, inventory) };
|
|
1061
|
+
return { inventory, config: loadConfig(env, inventory), defaulted: isDefaultedGatewayNodeInventory(inventory) };
|
|
602
1062
|
})();
|
|
603
1063
|
const success = (result) => {
|
|
604
1064
|
stdout.write(serializedOutput({ ok: true, command: command, result }));
|
|
605
1065
|
};
|
|
606
1066
|
try {
|
|
607
|
-
const common = commonOptions(argv.slice(1), env);
|
|
608
|
-
locale = common.locale;
|
|
609
1067
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
610
|
-
emptyParams(
|
|
611
|
-
stdout.write(
|
|
1068
|
+
emptyParams(args);
|
|
1069
|
+
stdout.write(HELP_USAGE);
|
|
612
1070
|
return gatewayCliExitCodes.ok;
|
|
613
1071
|
}
|
|
614
1072
|
if (command === undefined)
|
|
615
1073
|
fault("UNKNOWN_COMMAND");
|
|
616
1074
|
if (command === "peer-stdio") {
|
|
617
|
-
emptyParams(
|
|
1075
|
+
emptyParams(args);
|
|
618
1076
|
try {
|
|
619
1077
|
const { config, inventory } = await loadIdentity();
|
|
620
1078
|
await validateSocket(config.stateDir, config.controlSocketPath);
|
|
@@ -654,9 +1112,215 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
|
|
|
654
1112
|
return gatewayCliExitCodes.unavailable;
|
|
655
1113
|
}
|
|
656
1114
|
}
|
|
1115
|
+
if (command === "service") {
|
|
1116
|
+
const subcommand = args[0];
|
|
1117
|
+
if (args.length !== 1)
|
|
1118
|
+
fault();
|
|
1119
|
+
if (subcommand !== "install" && subcommand !== "uninstall" && subcommand !== "status")
|
|
1120
|
+
fault();
|
|
1121
|
+
// Identity and the state directory are validated before the first
|
|
1122
|
+
// launchd side effect. Loading them afterwards meant a missing
|
|
1123
|
+
// inventory or an unusable state root exited 2 "request rejected"
|
|
1124
|
+
// while the agent was already bootstrapped and looping.
|
|
1125
|
+
const config = subcommand === "install" ? (await loadIdentity()).config : undefined;
|
|
1126
|
+
const serviceDeps = {
|
|
1127
|
+
homeDir: (dependencies.serviceHomeDir ?? (() => userInfo().homedir))(),
|
|
1128
|
+
runLaunchctl: dependencies.runLaunchctl ?? defaultRunLaunchctl,
|
|
1129
|
+
env, execPath: process.execPath, cliPath: fileURLToPath(import.meta.url),
|
|
1130
|
+
uid: process.getuid(),
|
|
1131
|
+
delay: dependencies.delay ?? defaultDelay, now: dependencies.now ?? monotonicNow,
|
|
1132
|
+
probeHostLease: dependencies.probeHostLease ?? defaultProbeHostLease,
|
|
1133
|
+
};
|
|
1134
|
+
try {
|
|
1135
|
+
if (subcommand === "install") {
|
|
1136
|
+
const installed = await installServiceAgent(serviceDeps);
|
|
1137
|
+
const health = await pollServiceHealth(config, sendRequest, validateSocket, dependencies.delay ?? defaultDelay, dependencies.now ?? monotonicNow);
|
|
1138
|
+
if (!health.ok) {
|
|
1139
|
+
// The agent stays installed either way: this is a report about
|
|
1140
|
+
// the broker, not an install failure, so nothing is rolled back.
|
|
1141
|
+
const elapsed = (health.elapsedMs / 1000).toFixed(1);
|
|
1142
|
+
const decisive = SERVICE_HEALTH_DECISIVE.get(health.lastObserved);
|
|
1143
|
+
writeFailure(stdout, stderr, command, "SERVICE_HEALTH_UNAVAILABLE", {
|
|
1144
|
+
retryable: decisive?.retryable ?? true, kind: decisive?.kind ?? "unavailable",
|
|
1145
|
+
detail: { lastObserved: health.lastObserved },
|
|
1146
|
+
});
|
|
1147
|
+
stderr.write(decisive === undefined
|
|
1148
|
+
? `[embassy] Installed, but the broker did not answer within ${elapsed} s; last observed ${health.lastObserved}. Run \`embassy service status\` or \`embassy health\`; log: ${installed.logPath}.\n`
|
|
1149
|
+
: `[embassy] Installed, but the broker answered ${health.lastObserved} after ${elapsed} s. Run \`embassy health\` to diagnose it; log: ${installed.logPath}.\n`);
|
|
1150
|
+
if (decisive?.hint !== undefined)
|
|
1151
|
+
stderr.write(hintLine(decisive.hint, env));
|
|
1152
|
+
return decisive?.exitCode ?? gatewayCliExitCodes.unavailable;
|
|
1153
|
+
}
|
|
1154
|
+
success({ subcommand, ...installed, health });
|
|
1155
|
+
return gatewayCliExitCodes.ok;
|
|
1156
|
+
}
|
|
1157
|
+
if (subcommand === "uninstall") {
|
|
1158
|
+
success({ subcommand, ...(await uninstallServiceAgent(serviceDeps)) });
|
|
1159
|
+
return gatewayCliExitCodes.ok;
|
|
1160
|
+
}
|
|
1161
|
+
const status = await serviceAgentStatus(serviceDeps);
|
|
1162
|
+
success({ subcommand, ...status });
|
|
1163
|
+
if (status.state !== "unknown")
|
|
1164
|
+
return gatewayCliExitCodes.ok;
|
|
1165
|
+
stderr.write(fixedStderr("unavailable"));
|
|
1166
|
+
stderr.write(`[embassy] ${status.note}${status.launchctlStderr === undefined ? "" : ` launchctl: ${status.launchctlStderr}`}\n`);
|
|
1167
|
+
return gatewayCliExitCodes.unavailable;
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
// launchctl's own stderr and the instance lease's own message are the
|
|
1171
|
+
// whole value of these failures; the generic handler below discards
|
|
1172
|
+
// the message and reports only the code. A genuine filesystem failure
|
|
1173
|
+
// on this path (an unreadable plist, an undeletable one) is a real,
|
|
1174
|
+
// recoverable service failure, not an INTERNAL_ERROR — but only an
|
|
1175
|
+
// errno-shaped one. A string `code` alone would also match CliFault
|
|
1176
|
+
// and the lease's own spawn failures, relabelling faults that already
|
|
1177
|
+
// carry a truer code of their own.
|
|
1178
|
+
const errno = error !== null && typeof error === "object" &&
|
|
1179
|
+
(typeof error.errno === "number" ||
|
|
1180
|
+
typeof error.syscall === "string");
|
|
1181
|
+
if (!(error instanceof BridgeError) && !(errno && error instanceof Error))
|
|
1182
|
+
throw error;
|
|
1183
|
+
const failure = error instanceof BridgeError ? error : new BridgeError("SERVICE_AGENT_FILESYSTEM_FAILED", `The service command could not complete: ${boundedServiceDetail(error.message)}`, true);
|
|
1184
|
+
writeFailure(stdout, stderr, command, failure.code, {
|
|
1185
|
+
retryable: failure.recoverable,
|
|
1186
|
+
kind: failure.code === "SERVICE_AGENT_PATH_UNSAFE" ? "unsafe"
|
|
1187
|
+
: failure.recoverable ? "unavailable" : "input",
|
|
1188
|
+
});
|
|
1189
|
+
stderr.write(`[embassy] ${failure.message}\n`);
|
|
1190
|
+
return failure.recoverable ? gatewayCliExitCodes.unavailable : gatewayCliExitCodes.invalidInput;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (command === "status" || command === "watch" || command === "check") {
|
|
1194
|
+
// Options are parsed before anything is loaded or contacted, so an
|
|
1195
|
+
// unknown flag is a flat argument fault with no side effect at all.
|
|
1196
|
+
const options = command === "status" ? parseOptions(args, ["recent"], ["json"])
|
|
1197
|
+
: command === "watch" ? parseOptions(args, [], ["json"])
|
|
1198
|
+
: parseOptions(args, ["to", "timeout"]);
|
|
1199
|
+
count(options, 0, 2);
|
|
1200
|
+
const recent = boundedOption(options, "recent", STATUS_RECENT.default, STATUS_RECENT.minimum, STATUS_RECENT.maximum);
|
|
1201
|
+
const timeoutSeconds = boundedOption(options, "timeout", CHECK_TIMEOUT_DEFAULT_SECONDS, 1, CHECK_TIMEOUT_MAX_SECONDS);
|
|
1202
|
+
const target = options.to === undefined ? undefined : requireClaudeSelector(options, "to");
|
|
1203
|
+
const { config } = await loadIdentity();
|
|
1204
|
+
await validateSocket(config.stateDir, config.controlSocketPath);
|
|
1205
|
+
const ask = async (method, params) => await sendRequest({
|
|
1206
|
+
socketPath: config.controlSocketPath,
|
|
1207
|
+
request: envelope(method, params),
|
|
1208
|
+
});
|
|
1209
|
+
if (command === "status") {
|
|
1210
|
+
// `status` is read-only. It deliberately does NOT rescan: a rescan
|
|
1211
|
+
// performs the passive-discovery scan SECURITY.md reserves for an
|
|
1212
|
+
// explicit request, and journals a `discovery_refreshed` row into the
|
|
1213
|
+
// same bounded activity ring this pane exists to show — 256 status
|
|
1214
|
+
// calls would evict every route retirement it was meant to surface.
|
|
1215
|
+
// The header reports how old the scan is and offers `embassy refresh`.
|
|
1216
|
+
const response = await ask("list_snapshot", {});
|
|
1217
|
+
if (!response.ok) {
|
|
1218
|
+
writeFailure(stdout, stderr, command, response.error.code, { kind: "failure" });
|
|
1219
|
+
writeUnknownMethodHint(stderr, response.error.code);
|
|
1220
|
+
return gatewayCliExitCodes.failure;
|
|
1221
|
+
}
|
|
1222
|
+
if (options.json === true || !isTerminal(stdout)) {
|
|
1223
|
+
success(response.result);
|
|
1224
|
+
return gatewayCliExitCodes.ok;
|
|
1225
|
+
}
|
|
1226
|
+
const pid = await (dependencies.readControllerPid ?? readGatewayControllerPid)(config.stateDir);
|
|
1227
|
+
stdout.write(renderStatus(response.result, {
|
|
1228
|
+
stateDir: config.stateDir, version: EMBASSY_VERSION, recent,
|
|
1229
|
+
color: useColor(stdout, env), now: (dependencies.now ?? Date.now)(),
|
|
1230
|
+
...(pid === undefined ? {} : { pid }),
|
|
1231
|
+
}));
|
|
1232
|
+
return gatewayCliExitCodes.ok;
|
|
1233
|
+
}
|
|
1234
|
+
if (command === "watch") {
|
|
1235
|
+
const json = options.json === true;
|
|
1236
|
+
const color = !json && useColor(stdout, env);
|
|
1237
|
+
const interrupt = interruptSignal(dependencies.watchSignal);
|
|
1238
|
+
const delay = dependencies.delay ?? defaultDelay;
|
|
1239
|
+
let state;
|
|
1240
|
+
let revision;
|
|
1241
|
+
try {
|
|
1242
|
+
while (!interrupt.signal.aborted) {
|
|
1243
|
+
const observed = await ask("observe_snapshot", {});
|
|
1244
|
+
if (!observed.ok) {
|
|
1245
|
+
writeFailure(stdout, stderr, command, observed.error.code, { kind: "failure" });
|
|
1246
|
+
writeUnknownMethodHint(stderr, observed.error.code);
|
|
1247
|
+
return gatewayCliExitCodes.failure;
|
|
1248
|
+
}
|
|
1249
|
+
if (observed.result.snapshotRevision !== revision) {
|
|
1250
|
+
revision = observed.result.snapshotRevision;
|
|
1251
|
+
const diff = diffWatch(state ?? emptyWatchState, observed.result.snapshot);
|
|
1252
|
+
// The first poll only establishes the baseline: replaying the
|
|
1253
|
+
// whole retained ledger the moment a tail starts is noise.
|
|
1254
|
+
if (state !== undefined) {
|
|
1255
|
+
for (const event of diff.events) {
|
|
1256
|
+
stdout.write(json ? `${JSON.stringify(event)}\n`
|
|
1257
|
+
: `${renderWatchEvent(event, color)}\n`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
state = diff.state;
|
|
1261
|
+
}
|
|
1262
|
+
if (interrupt.signal.aborted)
|
|
1263
|
+
break;
|
|
1264
|
+
await delay(WATCH_POLL_INTERVAL_MS);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
finally {
|
|
1268
|
+
interrupt.dispose();
|
|
1269
|
+
}
|
|
1270
|
+
return gatewayCliExitCodes.ok;
|
|
1271
|
+
}
|
|
1272
|
+
const clock = dependencies.now ?? Date.now;
|
|
1273
|
+
let chosen = target;
|
|
1274
|
+
if (chosen === undefined) {
|
|
1275
|
+
const snapshot = await ask("list_snapshot", {});
|
|
1276
|
+
if (!snapshot.ok) {
|
|
1277
|
+
writeFailure(stdout, stderr, command, snapshot.error.code, { kind: "failure" });
|
|
1278
|
+
writeUnknownMethodHint(stderr, snapshot.error.code);
|
|
1279
|
+
return gatewayCliExitCodes.failure;
|
|
1280
|
+
}
|
|
1281
|
+
const candidates = snapshot.result.routes
|
|
1282
|
+
.filter((route) => route.provider === "codex" && route.enabled)
|
|
1283
|
+
.sort((left, right) => left.alias.localeCompare(right.alias));
|
|
1284
|
+
if (candidates.length === 0) {
|
|
1285
|
+
throw new CliFault("INVALID_ARGUMENTS", false, "checkNoTarget", undefined, { localHost: config.hostId });
|
|
1286
|
+
}
|
|
1287
|
+
// Eligibility is observation. A task nothing has observed — ever, or
|
|
1288
|
+
// within ten minutes — proves nothing about upstream drift; a send
|
|
1289
|
+
// into it just times out. The most recently observed eligible task is
|
|
1290
|
+
// the target, and when none qualifies every alias is named so the
|
|
1291
|
+
// operator can read its remedy.
|
|
1292
|
+
const observedAt = (route) => {
|
|
1293
|
+
const at = route.lastSeenAt === undefined ? Number.NaN : Date.parse(route.lastSeenAt);
|
|
1294
|
+
return Number.isFinite(at) ? at : undefined;
|
|
1295
|
+
};
|
|
1296
|
+
const eligible = candidates
|
|
1297
|
+
.map((route) => ({ route, observed: observedAt(route) }))
|
|
1298
|
+
.filter((row) => row.observed !== undefined && clock() - row.observed <= STATUS_ROUTE_STALE_AFTER_MS)
|
|
1299
|
+
.sort((left, right) => right.observed - left.observed || left.route.alias.localeCompare(right.route.alias));
|
|
1300
|
+
if (eligible.length === 0) {
|
|
1301
|
+
throw new CliFault("INVALID_ARGUMENTS", false, "checkAllStale", undefined, { aliases: candidates.map((route) => route.alias).join(", ") });
|
|
1302
|
+
}
|
|
1303
|
+
chosen = eligible[0].route.alias;
|
|
1304
|
+
}
|
|
1305
|
+
const checkInterrupt = interruptSignal(dependencies.watchSignal);
|
|
1306
|
+
try {
|
|
1307
|
+
return await runGatewayCheck({
|
|
1308
|
+
socketPath: config.controlSocketPath, hostId: config.hostId, target: chosen,
|
|
1309
|
+
timeoutMs: timeoutSeconds * 1_000, sendRequest, stdout, stderr,
|
|
1310
|
+
interrupt: checkInterrupt.signal, now: clock,
|
|
1311
|
+
delay: dependencies.delay ?? defaultDelay, color: useColor(stdout, env),
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
finally {
|
|
1315
|
+
if (dependencies.watchSignal === undefined && checkInterrupt.signal.aborted)
|
|
1316
|
+
interruptedCheckExit = true;
|
|
1317
|
+
checkInterrupt.dispose();
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
657
1320
|
if (command === "serve") {
|
|
1321
|
+
emptyParams(args);
|
|
658
1322
|
await (dependencies.runServer ?? runGatewayServer)({
|
|
659
|
-
env,
|
|
1323
|
+
env,
|
|
660
1324
|
...(dependencies.serverSignal === undefined ? {} : { signal: dependencies.serverSignal }),
|
|
661
1325
|
onReady: async (result) => {
|
|
662
1326
|
if (serverReady)
|
|
@@ -669,26 +1333,10 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
|
|
|
669
1333
|
fault("SERVER_NOT_READY");
|
|
670
1334
|
return gatewayCliExitCodes.ok;
|
|
671
1335
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
env, locale, port: liveDashboardPort, inventory, loadConfig, sendRequest, validateControlSocket: validateSocket,
|
|
677
|
-
...(dependencies.liveDashboardSignal === undefined ? {} : { signal: dependencies.liveDashboardSignal }),
|
|
678
|
-
onReady: async (result) => {
|
|
679
|
-
if (dashboardReady)
|
|
680
|
-
fault("LIVE_DASHBOARD_READY_ALREADY_EMITTED");
|
|
681
|
-
success(result);
|
|
682
|
-
dashboardReady = true;
|
|
683
|
-
},
|
|
684
|
-
});
|
|
685
|
-
if (!dashboardReady && outcome?.status === "cancelled")
|
|
686
|
-
return gatewayCliExitCodes.ok;
|
|
687
|
-
if (!dashboardReady)
|
|
688
|
-
throw new Error("LIVE_DASHBOARD_NOT_READY");
|
|
689
|
-
return gatewayCliExitCodes.ok;
|
|
690
|
-
}
|
|
691
|
-
const request = await buildRequest(command, common.args, env, stdin, async () => (await loadIdentity()).config.hostId);
|
|
1336
|
+
const request = await buildRequest(command, args, env, stdin, async () => {
|
|
1337
|
+
const { config, defaulted } = await loadIdentity();
|
|
1338
|
+
return { host: config.hostId, defaulted, stateDir: config.stateDir };
|
|
1339
|
+
});
|
|
692
1340
|
const { config } = await loadIdentity();
|
|
693
1341
|
await validateSocket(config.stateDir, config.controlSocketPath);
|
|
694
1342
|
let response;
|
|
@@ -708,25 +1356,25 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
|
|
|
708
1356
|
await writeComplete(stdout, current.result.frame);
|
|
709
1357
|
}
|
|
710
1358
|
catch {
|
|
711
|
-
stderr.write(fixedStderr(
|
|
1359
|
+
stderr.write(fixedStderr("failure"));
|
|
712
1360
|
return gatewayCliExitCodes.failure;
|
|
713
1361
|
}
|
|
714
1362
|
try {
|
|
715
1363
|
const receipt = await sendRequest({ socketPath: config.controlSocketPath,
|
|
716
1364
|
request: envelope("peer_receipt", { alias: request.params.alias, token: request.params.token, receipt: current.result.receipt }) });
|
|
717
1365
|
if (!receipt.ok) {
|
|
718
|
-
stderr.write(fixedStderr(
|
|
1366
|
+
stderr.write(fixedStderr("failure"));
|
|
719
1367
|
return gatewayCliExitCodes.failure;
|
|
720
1368
|
}
|
|
721
1369
|
if (isRejectedResult(receipt.result)) {
|
|
722
|
-
stderr.write(fixedStderr(
|
|
1370
|
+
stderr.write(fixedStderr("decision"));
|
|
723
1371
|
return gatewayCliExitCodes.rejected;
|
|
724
1372
|
}
|
|
725
1373
|
return gatewayCliExitCodes.ok;
|
|
726
1374
|
}
|
|
727
1375
|
catch (error) {
|
|
728
1376
|
const transport = error instanceof GatewayControlTransportError;
|
|
729
|
-
stderr.write(fixedStderr(
|
|
1377
|
+
stderr.write(fixedStderr(transport ? error.ambiguous ? "ambiguous" : "unavailable" : "failure"));
|
|
730
1378
|
return transport ? error.ambiguous ? gatewayCliExitCodes.ambiguous : gatewayCliExitCodes.unavailable : gatewayCliExitCodes.failure;
|
|
731
1379
|
}
|
|
732
1380
|
}
|
|
@@ -736,11 +1384,13 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
|
|
|
736
1384
|
fault();
|
|
737
1385
|
const outcome = await waitForDelivery(config.controlSocketPath, request, sendRequest, dependencies.now ?? Date.now, dependencies.delay ?? defaultDelay);
|
|
738
1386
|
if (outcome.kind === "unknown") {
|
|
739
|
-
writeFailure(stdout, stderr,
|
|
1387
|
+
writeFailure(stdout, stderr, command, "DELIVERY_TOKEN_UNKNOWN", { kind: "tokenUnknown" });
|
|
740
1388
|
return gatewayCliExitCodes.rejected;
|
|
741
1389
|
}
|
|
742
|
-
|
|
743
|
-
|
|
1390
|
+
// `timeout` is the only other outcome here: an interrupt needs a signal,
|
|
1391
|
+
// and `wait-delivery` passes none.
|
|
1392
|
+
if (outcome.kind !== "response") {
|
|
1393
|
+
writeFailure(stdout, stderr, command, "DELIVERY_WAIT_TIMEOUT", { retryable: true, kind: "deliveryTimeout" });
|
|
744
1394
|
return gatewayCliExitCodes.unavailable;
|
|
745
1395
|
}
|
|
746
1396
|
waited = outcome.response;
|
|
@@ -750,80 +1400,76 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
|
|
|
750
1400
|
response = await sendRequest({ socketPath: config.controlSocketPath, request });
|
|
751
1401
|
}
|
|
752
1402
|
if (!response.ok) {
|
|
753
|
-
writeFailure(stdout, stderr,
|
|
1403
|
+
writeFailure(stdout, stderr, command, response.error.code, { kind: "failure" });
|
|
1404
|
+
writeUnknownMethodHint(stderr, response.error.code);
|
|
754
1405
|
return gatewayCliExitCodes.failure;
|
|
755
1406
|
}
|
|
756
|
-
if (command === "register-peer" &&
|
|
1407
|
+
if (command === "register-peer" && args.includes("--emit-env") && "token" in response.result) {
|
|
757
1408
|
stdout.write(`export EMBASSY_PEER_TOKEN='${response.result.token}'\n`);
|
|
758
1409
|
return gatewayCliExitCodes.ok;
|
|
759
1410
|
}
|
|
760
|
-
success(
|
|
1411
|
+
success(response.result);
|
|
761
1412
|
const exitCode = waited === undefined ? responseExitCode(response) : waitDeliveryExitCode(waited);
|
|
762
1413
|
if (exitCode === gatewayCliExitCodes.rejected) {
|
|
763
|
-
stderr.write(fixedStderr(
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1414
|
+
stderr.write(fixedStderr("decision"));
|
|
1415
|
+
const hint = refusalHint(request, response.result);
|
|
1416
|
+
if (hint !== undefined)
|
|
1417
|
+
stderr.write(`[embassy] ${renderHint(hint.hint, hint.vars)}\n`);
|
|
767
1418
|
}
|
|
768
1419
|
else if (command === "wait-delivery" && exitCode === gatewayCliExitCodes.failure)
|
|
769
|
-
stderr.write(fixedStderr(
|
|
1420
|
+
stderr.write(fixedStderr("failure"));
|
|
770
1421
|
return exitCode;
|
|
771
1422
|
}
|
|
772
1423
|
catch (error) {
|
|
773
|
-
if (
|
|
774
|
-
stderr.write(fixedStderr(
|
|
1424
|
+
if (command === "serve" && serverReady) {
|
|
1425
|
+
stderr.write(fixedStderr("failure"));
|
|
775
1426
|
return gatewayCliExitCodes.failure;
|
|
776
1427
|
}
|
|
777
1428
|
if (error instanceof GatewayControlTransportError) {
|
|
778
1429
|
const ambiguous = error.ambiguous;
|
|
779
|
-
writeFailure(stdout, stderr,
|
|
1430
|
+
writeFailure(stdout, stderr, command, error.code, {
|
|
780
1431
|
ambiguous, retryable: ambiguous ? false : error.recoverable,
|
|
781
1432
|
kind: ambiguous ? "ambiguous" : "unavailable",
|
|
782
1433
|
});
|
|
783
1434
|
if (error.code === "CONTROL_VERSION_MISMATCH") {
|
|
784
|
-
stderr.write(`[embassy] ${
|
|
1435
|
+
stderr.write(`[embassy] ${CLI_HINT.controlVersionMismatch}\n`);
|
|
785
1436
|
}
|
|
786
1437
|
else if (error.code === "CONTROL_INVALID_RESPONSE") {
|
|
787
|
-
stderr.write(`[embassy] ${
|
|
1438
|
+
stderr.write(`[embassy] ${CLI_HINT.controlInvalidResponse}\n`);
|
|
788
1439
|
}
|
|
789
1440
|
if (error.code === "CONTROL_CONNECT_DENIED")
|
|
790
|
-
stderr.write(`[embassy] ${
|
|
1441
|
+
stderr.write(`[embassy] ${CLI_HINT.controlConnectDenied}\n`);
|
|
1442
|
+
if (isNoBrokerCode(error.code))
|
|
1443
|
+
stderr.write(hintLine("noBrokerRunning", env));
|
|
791
1444
|
return ambiguous ? gatewayCliExitCodes.ambiguous : gatewayCliExitCodes.unavailable;
|
|
792
1445
|
}
|
|
793
1446
|
if (error instanceof CliFault) {
|
|
794
|
-
writeFailure(stdout, stderr,
|
|
1447
|
+
writeFailure(stdout, stderr, command, error.code, {
|
|
795
1448
|
retryable: error.retryable, kind: error.kind ?? (error.retryable ? "unavailable" : "input"),
|
|
796
1449
|
});
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1450
|
+
// Every hint may name the state directory; a fault that carries its own
|
|
1451
|
+
// bounded values overrides that default with them.
|
|
1452
|
+
if (error.hint !== undefined)
|
|
1453
|
+
stderr.write(`[embassy] ${renderHint(error.hint, { stateDir: resolvedStateDirForHint(env), ...error.hintVars })}\n`);
|
|
1454
|
+
if (isNoBrokerCode(error.code))
|
|
1455
|
+
stderr.write(hintLine("noBrokerRunning", env));
|
|
803
1456
|
return error.retryable ? gatewayCliExitCodes.unavailable : gatewayCliExitCodes.invalidInput;
|
|
804
1457
|
}
|
|
805
1458
|
if (error instanceof BridgeError) {
|
|
806
1459
|
if (error.code === "GATEWAY_STATE_COMMIT_OUTCOME_UNKNOWN") {
|
|
807
|
-
writeFailure(stdout, stderr,
|
|
1460
|
+
writeFailure(stdout, stderr, command, error.code, { ambiguous: true, kind: "ambiguous" });
|
|
808
1461
|
return gatewayCliExitCodes.ambiguous;
|
|
809
1462
|
}
|
|
810
|
-
writeFailure(stdout, stderr,
|
|
1463
|
+
writeFailure(stdout, stderr, command, error.code, {
|
|
811
1464
|
retryable: error.recoverable, kind: error.recoverable ? "unavailable" : "input",
|
|
812
1465
|
});
|
|
813
|
-
writeStateResetHint(stderr,
|
|
1466
|
+
writeStateResetHint(stderr, error.code);
|
|
1467
|
+
writeBridgeErrorHint(stderr, error, env);
|
|
814
1468
|
if (error.code === "CONTROL_CONNECT_DENIED")
|
|
815
|
-
stderr.write(`[embassy] ${
|
|
816
|
-
if (error.code === "GATEWAY_NODE_INVENTORY_REQUIRED") {
|
|
817
|
-
const stateDir = env.EMBASSY_STATE_DIR ?? (env.XDG_STATE_HOME ? path.join(env.XDG_STATE_HOME, "agent-embassy") : "~/.local/state/agent-embassy");
|
|
818
|
-
stderr.write(`[embassy] ${getCliCopy(locale)["hint.nodeInventoryRequired"].replace("{stateDir}", stateDir)}\n`);
|
|
819
|
-
}
|
|
820
|
-
if (error.code === "LIVE_DASHBOARD_PORT_IN_USE" && liveDashboardPort !== undefined) {
|
|
821
|
-
const hint = getCliCopy(locale)["hint.dashboardPortInUse"].replace("{port}", String(liveDashboardPort));
|
|
822
|
-
stderr.write(`[embassy] ${hint}\n`);
|
|
823
|
-
}
|
|
1469
|
+
stderr.write(`[embassy] ${CLI_HINT[command === "serve" ? "stateAccessDenied" : "controlConnectDenied"]}\n`);
|
|
824
1470
|
return error.recoverable ? gatewayCliExitCodes.unavailable : gatewayCliExitCodes.invalidInput;
|
|
825
1471
|
}
|
|
826
|
-
writeFailure(stdout, stderr,
|
|
1472
|
+
writeFailure(stdout, stderr, command, "INTERNAL_ERROR", { kind: "failure" });
|
|
827
1473
|
return gatewayCliExitCodes.failure;
|
|
828
1474
|
}
|
|
829
1475
|
}
|
|
@@ -844,8 +1490,13 @@ function isDirectExecution() {
|
|
|
844
1490
|
if (isDirectExecution()) {
|
|
845
1491
|
void runGatewayCli().then((exitCode) => {
|
|
846
1492
|
process.exitCode = exitCode;
|
|
1493
|
+
// Bounded exit after an interrupted `check`: drain stdout, then stderr,
|
|
1494
|
+
// then leave — see `interruptedCheckExit`.
|
|
1495
|
+
if (interruptedCheckExit) {
|
|
1496
|
+
process.stdout.write("", () => process.stderr.write("", () => process.exit(exitCode)));
|
|
1497
|
+
}
|
|
847
1498
|
}, () => {
|
|
848
|
-
process.stderr.write(fixedStderr(
|
|
1499
|
+
process.stderr.write(fixedStderr("failure"));
|
|
849
1500
|
process.exitCode = gatewayCliExitCodes.failure;
|
|
850
1501
|
});
|
|
851
1502
|
}
|