@edgehero/pi-dispatch 4.3.0 → 5.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/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A terminal client of the operator console (issue #647, REQ-TERMINAL-CHAT): what `pi-dispatch chat` speaks to read a
|
|
3
|
+
* run, follow its live transcript and send the console's own actions, through the same HTTP surface a browser uses.
|
|
4
|
+
*
|
|
5
|
+
* THE CONSOLE'S AUTH MODEL, UNCHANGED. The client signs in with a token through `POST /login`, exactly as the login
|
|
6
|
+
* page does, and holds the `pdc_session` cookie it gets back in memory for the life of the process. A bearer header was
|
|
7
|
+
* the obvious alternative and is rejected: it would be a second way in, judged by code the browser path never runs, and
|
|
8
|
+
* every guard in `admin/src/console/guards.mjs` (Host, Origin, the JSON content type) would need a twin. The cookie is
|
|
9
|
+
* never written anywhere, so a finished `chat` leaves no credential behind.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THE GUARDS EXPECT, SENT AS A BROWSER WOULD. `Host` is the address dialled (`127.0.0.1:4790`, or `localhost` over
|
|
12
|
+
* a unix socket, the one loopback name a socket has); `Origin` is `http://<Host>` on every request, so a write passes
|
|
13
|
+
* the origin check the same way a page's fetch does; a write carries `Content-Type: application/json`.
|
|
14
|
+
* `Sec-Fetch-Site` is NEVER sent: `originRefusal` accepts its absence as a non-browser client and judges `Origin` alone,
|
|
15
|
+
* and a client that claimed `same-origin` would be claiming to be the console's own page, which it is not.
|
|
16
|
+
*
|
|
17
|
+
* THE STREAM. `GET /api/transcript?format=json` is Server-Sent Events. The parser here holds one event to 256 KiB
|
|
18
|
+
* (`SSE_EVENT_MAX_BYTES`); an event past that is dropped whole and reported, never half-parsed, so a hostile or broken
|
|
19
|
+
* stream cannot grow this process. A dropped connection reconnects with `Last-Event-ID` (the server resumes after it)
|
|
20
|
+
* on a jittered exponential backoff, a 429 waits its `Retry-After` (at most 60 s), and a 401 or an `end` event (the
|
|
21
|
+
* server ended the session: expiry, a revoke, a rotate) signs in again, at most once per `RELOGIN_WINDOW_MS`: a second
|
|
22
|
+
* refusal within that window of a fresh sign-in is the end, said in a sentence. The window holds over the stream's whole
|
|
23
|
+
* life, so a server that delivers one entry and then `end`, forever, cannot make the client sign in more than once a
|
|
24
|
+
* minute (an entry between the two used to clear the rule, which was exactly that loop). A restart's re-login neither
|
|
25
|
+
* meets nor starts the window (issue #737), so a second console restart inside a minute does not end the chat: a 401
|
|
26
|
+
* whose body says `restart` (the console no longer knows this sign-in), or the first 401 after a `shutdown` end that no
|
|
27
|
+
* entry has followed. Only a 401 is ever a restart: an `end` event comes from a console that knows the session, whatever
|
|
28
|
+
* its data says. And AT MOST ONE such free re-login per stream that opened (200) since the last re-login: a console
|
|
29
|
+
* that answers every stream 401 `restart`, or a hostile one, would otherwise have the chat sign in again without bound
|
|
30
|
+
* (8,182 sign-ins in 51 simulated hours). A restarted console opens the stream it is asked for, so a real restart is
|
|
31
|
+
* counted only when it comes between a re-login and the stream that re-login reopens, and ends the chat only on two
|
|
32
|
+
* counted re-logins inside the window. Every re-login is still paced by a growing pause. A body that stops moving
|
|
33
|
+
* is cut: `STREAM_IDLE_MS` on a stream (the console pings every 15 s), `timeoutMs` on any other response.
|
|
34
|
+
*
|
|
35
|
+
* AN END THAT SAYS `shutdown` IS NOT A SIGN-OUT (issue #737). The console's `end` carries `{"ended": <reason>}`;
|
|
36
|
+
* `shutdown` (`STREAM_SHUTDOWN`) is the console stopping, so the stream is treated as a dropped connection: back off and
|
|
37
|
+
* reconnect with the same cookie. A restarted console no longer knows that session and answers 401, which takes the
|
|
38
|
+
* re-login path above; the sign-in runs inside the retry loop, so a console still coming up refuses it as it refuses
|
|
39
|
+
* the stream (backed off, counted) rather than ending the chat. Treating `shutdown` as an end was the old behaviour:
|
|
40
|
+
* it signed in again at once, to a console that was going away, so a console restart ended the chat's stream with
|
|
41
|
+
* "could not be reached", and the terminal said the sign-in had ended when only the connection had.
|
|
42
|
+
*
|
|
43
|
+
* THE TOKEN LEAVES THIS MACHINE ONLY WHEN THE OPERATOR SAID SO. The console speaks plain HTTP, so a non-loopback
|
|
44
|
+
* address sends the token readable to everything on the path. `consoleAddressOf` therefore refuses a non-loopback
|
|
45
|
+
* address that came from the deployment's `.env` (a file in whatever folder the command runs from, which may be a
|
|
46
|
+
* repository someone else wrote), and marks every other address with `loopback`, so the command can warn and ask
|
|
47
|
+
* before it signs in to one that is not (`chat-cli.mjs`).
|
|
48
|
+
*
|
|
49
|
+
* NOTHING SECRET IS PRINTED OR LOGGED. The token, the cookie and any message text never reach `log`, an error message
|
|
50
|
+
* or the terminal from this module. Errors name the address, the route and the status; the console's own refusal
|
|
51
|
+
* sentences (which name no secret: `server.mjs` keeps them so) are passed through, folded onto one line (`oneLine`).
|
|
52
|
+
*/
|
|
53
|
+
import * as nodeFs from "node:fs";
|
|
54
|
+
import * as nodeHttp from "node:http";
|
|
55
|
+
import { join } from "node:path";
|
|
56
|
+
import { formatMicros } from "./money.mjs";
|
|
57
|
+
import { readDeploymentEnv } from "./service-env.mjs";
|
|
58
|
+
|
|
59
|
+
/** Where the console listens when nothing names it: its own default (`admin/src/console/listen.mjs`). */
|
|
60
|
+
export const DEFAULT_CONSOLE_ADDRESS = "127.0.0.1:4790";
|
|
61
|
+
|
|
62
|
+
/** The largest single Server-Sent Event the client keeps. A transcript entry is capped well below this server side. */
|
|
63
|
+
export const SSE_EVENT_MAX_BYTES = 256 * 1024;
|
|
64
|
+
|
|
65
|
+
/** The largest JSON response read. `/api/run` and an action's answer are a few KiB. */
|
|
66
|
+
export const RESPONSE_MAX_BYTES = 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
/** How long one request may take to answer (not a stream's life: only until its headers arrive). */
|
|
69
|
+
export const REQUEST_TIMEOUT_MS = 15_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reconnect pacing for a stream: the first wait is `baseMs`, doubling to `capMs`, each drawn from the upper half of
|
|
73
|
+
* that window (jitter, so tabs and terminals that lost one console together do not all come back in the same
|
|
74
|
+
* millisecond). After `maxFailures` connection failures in a row with nothing received the stream gives up loudly.
|
|
75
|
+
*/
|
|
76
|
+
export const BACKOFF = Object.freeze({ baseMs: 500, capMs: 30_000, maxFailures: 8 });
|
|
77
|
+
|
|
78
|
+
/** The longest `Retry-After` honoured. A larger one is clamped, so a confused server cannot park the client for long. */
|
|
79
|
+
export const RETRY_AFTER_MAX_MS = 60_000;
|
|
80
|
+
|
|
81
|
+
/** How long a stream may go without a byte before it is treated as cut. Three of the console's 15 s pings. */
|
|
82
|
+
export const STREAM_IDLE_MS = 45_000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A stream signs in again after a 401 or an `end` at most once in this window; a second one inside it is fatal. Long
|
|
86
|
+
* enough that a server ending every connection cannot drive a sign-in loop, short against a real session's life.
|
|
87
|
+
*/
|
|
88
|
+
export const RELOGIN_WINDOW_MS = 60_000;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The word a console's stream `end` carries when the console itself is stopping (`admin/src/console/session-ends.mjs`,
|
|
92
|
+
* pinned equal to it by an admin test). Written out here, not imported: the worker never imports the admin package.
|
|
93
|
+
*/
|
|
94
|
+
export const STREAM_SHUTDOWN = "shutdown";
|
|
95
|
+
|
|
96
|
+
/** A stream end's reason as the client keeps it: a short lowercase word, else null. Never printed. */
|
|
97
|
+
const ENDED_RE = /^[a-z]{1,20}$/;
|
|
98
|
+
|
|
99
|
+
/** The longest refusal sentence passed through from the console, as one line. */
|
|
100
|
+
const REFUSAL_MAX_CHARS = 1000;
|
|
101
|
+
|
|
102
|
+
const HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
|
103
|
+
const BRACKETED_RE = /^\[[0-9A-Fa-f:.]+\]$/;
|
|
104
|
+
const ACTION_NAME_RE = /^[a-z][a-z0-9-]{0,40}$/;
|
|
105
|
+
const COOKIE_RE = /^pdc_session=([^;]*)/;
|
|
106
|
+
const CODE_RE = /^[A-Z0-9_]{1,40}$/;
|
|
107
|
+
|
|
108
|
+
/** A refusal or failure this module raises: its message is a sentence an operator can act on, and names no secret. */
|
|
109
|
+
export class ConsoleError extends Error {
|
|
110
|
+
constructor(message, { status = null } = {}) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.name = "ConsoleError";
|
|
113
|
+
this.status = status;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const codeOf = (e) => (typeof e?.code === "string" && CODE_RE.test(e.code) ? e.code : "error");
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A console sentence as one line: every control character (a newline included) becomes a space, runs of space fold to
|
|
121
|
+
* one, and the result is cut to 1000 characters. A refusal is printed on one terminal line whatever the server sent.
|
|
122
|
+
*/
|
|
123
|
+
export function oneLine(text) {
|
|
124
|
+
const s = String(text ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").replace(/ {2,}/g, " ").trim();
|
|
125
|
+
return s.length > REFUSAL_MAX_CHARS ? `${s.slice(0, REFUSAL_MAX_CHARS)}...` : s;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A console address as the client dials it: `unix:/abs/path`, or `[http://]host:port` with a hostname, an IPv4 literal
|
|
130
|
+
* or a bracketed IPv6 literal. Returns `{kind: "unix", socketPath, hostHeader: "localhost", shown}` or
|
|
131
|
+
* `{kind: "tcp", host, port, hostHeader, shown}`, or `{refusal}`.
|
|
132
|
+
*
|
|
133
|
+
* `https:` is refused rather than attempted: the console speaks plain HTTP on loopback by design (TLS is the tunnel's
|
|
134
|
+
* job), and its origin check compares against `http://<Host>`, so a TLS client could read but never write.
|
|
135
|
+
* A wildcard listen (`0.0.0.0`, `[::]`, which PI_CONSOLE_LISTEN may hold with PI_CONSOLE_BIND_PUBLIC) is dialled on
|
|
136
|
+
* its loopback twin: the wildcard is where the console listens, never an address anything connects to.
|
|
137
|
+
*/
|
|
138
|
+
export function parseConsoleAddress(text) {
|
|
139
|
+
const raw = typeof text === "string" ? text.trim() : "";
|
|
140
|
+
if (raw === "") return { refusal: "the console address is empty; give host:port (127.0.0.1:4790) or unix:/abs/path" };
|
|
141
|
+
if (raw.length > 1024) return { refusal: "the console address is longer than 1024 characters" };
|
|
142
|
+
if (raw.startsWith("unix:")) {
|
|
143
|
+
const socketPath = raw.slice("unix:".length);
|
|
144
|
+
if (!socketPath.startsWith("/")) return { refusal: "a unix console address needs an absolute socket path: unix:/abs/path" };
|
|
145
|
+
return { kind: "unix", socketPath, hostHeader: "localhost", shown: raw };
|
|
146
|
+
}
|
|
147
|
+
let rest = raw;
|
|
148
|
+
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(rest);
|
|
149
|
+
if (scheme) {
|
|
150
|
+
if (scheme[1].toLowerCase() !== "http") {
|
|
151
|
+
return { refusal: `the console speaks plain http on loopback (a tunnel carries TLS), so a ${scheme[1].toLowerCase()}: address cannot be used; give http://host:port or host:port` };
|
|
152
|
+
}
|
|
153
|
+
rest = rest.slice(scheme[0].length);
|
|
154
|
+
}
|
|
155
|
+
if (rest.endsWith("/")) rest = rest.slice(0, -1);
|
|
156
|
+
if (/[/?#@\s]/.test(rest)) return { refusal: "the console address must be just host:port (no path, query, user or spaces)" };
|
|
157
|
+
let host;
|
|
158
|
+
let portText;
|
|
159
|
+
if (rest.startsWith("[")) {
|
|
160
|
+
const close = rest.indexOf("]");
|
|
161
|
+
if (close < 0 || rest[close + 1] !== ":") return { refusal: "an IPv6 console address is [address]:port, for example [::1]:4790" };
|
|
162
|
+
host = rest.slice(0, close + 1);
|
|
163
|
+
portText = rest.slice(close + 2);
|
|
164
|
+
if (!BRACKETED_RE.test(host)) return { refusal: "the bracketed console address is not an IPv6 literal" };
|
|
165
|
+
} else {
|
|
166
|
+
const colon = rest.lastIndexOf(":");
|
|
167
|
+
if (colon < 0) return { refusal: "the console address has no port; give host:port, for example 127.0.0.1:4790" };
|
|
168
|
+
host = rest.slice(0, colon);
|
|
169
|
+
portText = rest.slice(colon + 1);
|
|
170
|
+
if (!HOSTNAME_RE.test(host)) return { refusal: "the console address's host is not a hostname or an IPv4 literal" };
|
|
171
|
+
}
|
|
172
|
+
if (!/^[0-9]{1,5}$/.test(portText) || Number(portText) < 1 || Number(portText) > 65535) return { refusal: "the console address's port is not a number from 1 to 65535" };
|
|
173
|
+
if (host === "0.0.0.0") host = "127.0.0.1";
|
|
174
|
+
if (host === "[::]") host = "[::1]";
|
|
175
|
+
const port = Number(portText);
|
|
176
|
+
const hostHeader = `${host.toLowerCase()}:${port}`;
|
|
177
|
+
return { kind: "tcp", host: host.startsWith("[") ? host.slice(1, -1) : host, port, hostHeader, shown: `http://${hostHeader}` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether a parsed address stays on this machine: a unix socket, `localhost`, an IPv4 literal in 127.0.0.0/8, or the
|
|
182
|
+
* IPv6 loopback (a wildcard was already turned into its loopback twin by `parseConsoleAddress`). A hostname is judged
|
|
183
|
+
* by its spelling, never resolved: a name that happens to resolve to 127.0.0.1 today is still not loopback by
|
|
184
|
+
* construction, so it gets the warning.
|
|
185
|
+
*/
|
|
186
|
+
export function isLoopbackAddress(address) {
|
|
187
|
+
if (address?.kind === "unix") return true;
|
|
188
|
+
if (address?.kind !== "tcp" || typeof address.host !== "string") return false;
|
|
189
|
+
const h = address.host.toLowerCase();
|
|
190
|
+
if (h === "localhost") return true;
|
|
191
|
+
if (/^127(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(h)) return true;
|
|
192
|
+
return h === "::1" || h === "0:0:0:0:0:0:0:1" || /^::ffff:127\.[0-9.]+$/.test(h);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Which console to dial, in order: the `--console` flag, `PI_CONSOLE_URL` from this shell, the deployment's
|
|
197
|
+
* `PI_CONSOLE_LISTEN` (`deployment()` resolves it the way the service reads it, and is asked only when the first two
|
|
198
|
+
* are absent), else `DEFAULT_CONSOLE_ADDRESS`. Returns `{address, source, loopback}` or `{refusal}`.
|
|
199
|
+
*
|
|
200
|
+
* An address from the deployment's `.env` must be loopback or a unix socket, or it is refused: that file is read from
|
|
201
|
+
* the folder the command runs in, and a `.env` planted in a cloned repository must not be able to point the operator's
|
|
202
|
+
* token at another host. A console elsewhere is named explicitly, with `--console` or `PI_CONSOLE_URL`. And a
|
|
203
|
+
* `PI_CONSOLE_LISTEN` this shell does not set came from that file, so it is taken only when `envTrust()` returns null
|
|
204
|
+
* (`dotenvTrustRefusal`: the file is this account's or root's, no group or other account can write it or its folders,
|
|
205
|
+
* and it is not a symbolic link); loopback is not enough on its own, since another local account can listen on a
|
|
206
|
+
* loopback port and collect the token. `envTrust` defaults to trusting, for callers with no folder of their own.
|
|
207
|
+
*/
|
|
208
|
+
export function consoleAddressOf({ flag = null, env = {}, deployment = () => ({ env: {} }), envTrust = () => null } = {}) {
|
|
209
|
+
const pick = (value, source) => {
|
|
210
|
+
const parsed = parseConsoleAddress(value);
|
|
211
|
+
return parsed.refusal ? { refusal: `${source}: ${parsed.refusal}` } : { address: parsed, source, loopback: isLoopbackAddress(parsed) };
|
|
212
|
+
};
|
|
213
|
+
if (typeof flag === "string") return pick(flag, "--console");
|
|
214
|
+
// env-internal PI_CONSOLE_URL: the operator's own shell names the console a terminal dials (often a tunnel's local
|
|
215
|
+
// end); the deployment's .env is the service's, and its console address is PI_CONSOLE_LISTEN, read below.
|
|
216
|
+
if (typeof env.PI_CONSOLE_URL === "string" && env.PI_CONSOLE_URL.trim() !== "") return pick(env.PI_CONSOLE_URL, "PI_CONSOLE_URL");
|
|
217
|
+
const d = deployment();
|
|
218
|
+
if (d?.problem) return { refusal: `${d.problem}, so the console's address (PI_CONSOLE_LISTEN) cannot be told; pass --console <host:port>` };
|
|
219
|
+
const listen = typeof d?.env?.PI_CONSOLE_LISTEN === "string" ? d.env.PI_CONSOLE_LISTEN.trim() : "";
|
|
220
|
+
if (listen !== "") {
|
|
221
|
+
const fromShell = typeof env.PI_CONSOLE_LISTEN === "string" && env.PI_CONSOLE_LISTEN.trim() !== "";
|
|
222
|
+
const distrust = fromShell ? null : envTrust();
|
|
223
|
+
if (distrust) {
|
|
224
|
+
return { refusal: `PI_CONSOLE_LISTEN comes from ${distrust}, so the console token is not sent where it names; name the console with --console <host:port> or PI_CONSOLE_URL` };
|
|
225
|
+
}
|
|
226
|
+
const got = pick(listen, "PI_CONSOLE_LISTEN");
|
|
227
|
+
if (got.address && !got.loopback) {
|
|
228
|
+
return { refusal: `PI_CONSOLE_LISTEN in the deployment's .env names ${got.address.shown}, which is not a loopback address or a unix socket, so the console token is not sent there; name a console on another host explicitly with --console <host:port> or PI_CONSOLE_URL` };
|
|
229
|
+
}
|
|
230
|
+
return got;
|
|
231
|
+
}
|
|
232
|
+
return pick(DEFAULT_CONSOLE_ADDRESS, "the default");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether the `.env` in `dir` may name the console, as null or the reason, starting with its path: the panel's own rule
|
|
237
|
+
* (`readDeploymentEnv`, issue #471: owned by this account or root, writable by no group or other account, and so is
|
|
238
|
+
* every folder above it), and not a symbolic link (`lstat`), since a link is a second path whose target someone else
|
|
239
|
+
* may own or swap. Absent or unreadable is a reason too: a value that cannot be traced to a file this account controls
|
|
240
|
+
* is not trusted.
|
|
241
|
+
*/
|
|
242
|
+
export function dotenvTrustRefusal({ dir, fs = nodeFs, uid = process.geteuid?.() }) {
|
|
243
|
+
const path = join(dir, ".env");
|
|
244
|
+
let st;
|
|
245
|
+
try {
|
|
246
|
+
st = fs.lstatSync(path);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return `${path}, which could not be checked (${e?.code ?? "error"})`;
|
|
249
|
+
}
|
|
250
|
+
if (st.isSymbolicLink()) return `${path}, which is a symbolic link`;
|
|
251
|
+
const got = readDeploymentEnv({ dir, fs, uid });
|
|
252
|
+
if (got.unreadable) return `${path}, which could not be read (${got.unreadable})`;
|
|
253
|
+
if (got.untrusted) return `${path}, which ${got.untrusted}`;
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The console token: `PI_CONSOLE_TOKEN` from THIS SHELL's environment, else a hidden prompt (`askHidden`). Never the
|
|
259
|
+
* deployment's `.env`: that file is the service's, readable by whoever runs the service, and a token in it would be an
|
|
260
|
+
* operator credential sitting beside the worker's secrets. Returns `{token}` or `{refusal}`.
|
|
261
|
+
*/
|
|
262
|
+
export async function consoleToken({ env = {}, askHidden = null } = {}) {
|
|
263
|
+
// env-internal PI_CONSOLE_TOKEN: an operator credential, read from this shell alone and never from a deployment file.
|
|
264
|
+
const fromEnv = typeof env.PI_CONSOLE_TOKEN === "string" ? env.PI_CONSOLE_TOKEN.trim() : "";
|
|
265
|
+
if (fromEnv !== "") return { token: fromEnv };
|
|
266
|
+
const typed = typeof askHidden === "function" ? await askHidden("console token (input hidden): ") : null;
|
|
267
|
+
if (typeof typed === "string" && typed.trim() !== "") return { token: typed.trim() };
|
|
268
|
+
return { refusal: "no console token: set PI_CONSOLE_TOKEN in this shell, or run this in a terminal to type it (make one with pi-dispatch-console token add)" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read one line from a TTY without echoing it. Returns the line, or null when `input` is not a TTY (nothing to hide
|
|
273
|
+
* from, and nobody to ask) or the operator pressed Ctrl+C or Ctrl+D. At most 1024 characters are kept.
|
|
274
|
+
*/
|
|
275
|
+
export function askHidden(question, { input = process.stdin, output = process.stderr } = {}) {
|
|
276
|
+
if (!input?.isTTY || typeof input.setRawMode !== "function") return Promise.resolve(null);
|
|
277
|
+
return new Promise((resolve) => {
|
|
278
|
+
let text = "";
|
|
279
|
+
output.write(question);
|
|
280
|
+
input.setRawMode(true);
|
|
281
|
+
input.resume();
|
|
282
|
+
const finish = (value) => {
|
|
283
|
+
input.removeListener("data", onData);
|
|
284
|
+
input.setRawMode(false);
|
|
285
|
+
input.pause();
|
|
286
|
+
output.write("\n");
|
|
287
|
+
resolve(value);
|
|
288
|
+
};
|
|
289
|
+
const onData = (chunk) => {
|
|
290
|
+
for (const ch of String(chunk)) {
|
|
291
|
+
if (ch === "\r" || ch === "\n") return finish(text);
|
|
292
|
+
if (ch === "\u0003" || ch === "\u0004") return finish(null);
|
|
293
|
+
if (ch === "\u007f" || ch === "\b") text = text.slice(0, -1);
|
|
294
|
+
else if (ch >= " " && text.length < 1024) text += ch;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
input.on("data", onData);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Ask a yes/no question on a TTY; true only for an explicit y or yes. Not a TTY is a no: every question this command
|
|
303
|
+
* asks is a consent (a paid message, an unredacted transcript to a provider), and a pipe cannot give one.
|
|
304
|
+
*/
|
|
305
|
+
export function askYesNo(question, { input = process.stdin, output = process.stderr } = {}) {
|
|
306
|
+
if (!input?.isTTY) {
|
|
307
|
+
output.write(`${question}\nno terminal to answer on, so the answer is no\n`);
|
|
308
|
+
return Promise.resolve(false);
|
|
309
|
+
}
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
let text = "";
|
|
312
|
+
output.write(question);
|
|
313
|
+
const onData = (chunk) => {
|
|
314
|
+
text += String(chunk);
|
|
315
|
+
const nl = text.search(/[\r\n]/);
|
|
316
|
+
if (nl < 0 && text.length < 64) return;
|
|
317
|
+
input.removeListener("data", onData);
|
|
318
|
+
input.pause();
|
|
319
|
+
resolve(/^\s*(y|yes)\s*$/i.test(nl < 0 ? text : text.slice(0, nl)));
|
|
320
|
+
};
|
|
321
|
+
input.on("data", onData);
|
|
322
|
+
input.resume();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Milliseconds to wait before reconnect attempt `failures` (1 for the first): from the upper half of
|
|
328
|
+
* `min(capMs, baseMs * 2^(failures-1))`. `random` is the seam (a test pins it).
|
|
329
|
+
*/
|
|
330
|
+
export function backoffMs(failures, { baseMs = BACKOFF.baseMs, capMs = BACKOFF.capMs, random = Math.random } = {}) {
|
|
331
|
+
const n = Math.max(1, Math.min(30, Number.isSafeInteger(failures) ? failures : 1));
|
|
332
|
+
const ceiling = Math.min(capMs, baseMs * 2 ** (n - 1));
|
|
333
|
+
return Math.round(ceiling / 2 + (ceiling / 2) * Math.min(1, Math.max(0, random())));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A `Retry-After` header as milliseconds (delta-seconds only, which is all the console sends), clamped to 1 s ..
|
|
338
|
+
* `RETRY_AFTER_MAX_MS` (60 s); null when absent or not a number, so the caller falls back to its own backoff.
|
|
339
|
+
*/
|
|
340
|
+
export function retryAfterMs(header) {
|
|
341
|
+
const text = Array.isArray(header) ? header[0] : header;
|
|
342
|
+
if (typeof text !== "string" || !/^\s*[0-9]{1,9}\s*$/.test(text)) return null;
|
|
343
|
+
return Math.min(RETRY_AFTER_MAX_MS, Math.max(1000, Number(text) * 1000));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A Server-Sent Events parser (the WHATWG event-stream grammar, the parts the console uses): `push(chunk)` with bytes
|
|
348
|
+
* or text as they arrive, `onEvent({event, data, id})` per dispatched event, `onRetry(ms)` for a `retry:` line.
|
|
349
|
+
*
|
|
350
|
+
* BOUNDED. An event whose lines add up past `maxEventBytes` is dropped whole (`onOversize()` once), including a single
|
|
351
|
+
* line that never ends: the partial line is discarded as it grows, not buffered, and everything up to the event's
|
|
352
|
+
* blank line is skipped. So memory stays under the cap whatever the server sends, and a dropped event can never be
|
|
353
|
+
* misread as a smaller one made of its tail. Comments (`: ping`) are ignored; `id` updates the last event id even on
|
|
354
|
+
* an event with no data, as the grammar says.
|
|
355
|
+
*
|
|
356
|
+
* An `id` that is not printable ASCII (a NUL, any control byte, anything past 0x7e) or is longer than 1024 characters
|
|
357
|
+
* is ignored: the id goes back to the server as the `Last-Event-ID` header, and node refuses a header value with a
|
|
358
|
+
* control character by throwing, which would turn a hostile id into a crash of the reconnect. The console's ids are
|
|
359
|
+
* `<n>-<m>`. A CR that ends a chunk is remembered, so a CRLF split across two chunks is one line end, not two.
|
|
360
|
+
*/
|
|
361
|
+
export function makeSseParser({ onEvent, onRetry = () => {}, onOversize = () => {}, maxEventBytes = SSE_EVENT_MAX_BYTES } = {}) {
|
|
362
|
+
const decoder = new TextDecoder("utf-8");
|
|
363
|
+
let buf = "";
|
|
364
|
+
let skipLine = false;
|
|
365
|
+
let lastWasCR = false;
|
|
366
|
+
let over = false;
|
|
367
|
+
let size = 0;
|
|
368
|
+
let event = "";
|
|
369
|
+
let data = [];
|
|
370
|
+
let id = null;
|
|
371
|
+
let sawId = false;
|
|
372
|
+
const reset = () => {
|
|
373
|
+
over = false;
|
|
374
|
+
size = 0;
|
|
375
|
+
event = "";
|
|
376
|
+
data = [];
|
|
377
|
+
sawId = false;
|
|
378
|
+
};
|
|
379
|
+
const line = (text) => {
|
|
380
|
+
if (text === "") {
|
|
381
|
+
if (over) onOversize();
|
|
382
|
+
else if (data.length > 0) onEvent({ event: event || "message", data: data.join("\n"), id: sawId ? id : null });
|
|
383
|
+
reset();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (over) return;
|
|
387
|
+
size += Buffer.byteLength(text, "utf8") + 1;
|
|
388
|
+
if (size > maxEventBytes) {
|
|
389
|
+
over = true;
|
|
390
|
+
data = [];
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (text.startsWith(":")) return;
|
|
394
|
+
const colon = text.indexOf(":");
|
|
395
|
+
const name = colon < 0 ? text : text.slice(0, colon);
|
|
396
|
+
let value = colon < 0 ? "" : text.slice(colon + 1);
|
|
397
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
398
|
+
if (name === "event") event = value;
|
|
399
|
+
else if (name === "data") data.push(value);
|
|
400
|
+
else if (name === "id") {
|
|
401
|
+
if (/^[\x20-\x7e]{0,1024}$/.test(value)) {
|
|
402
|
+
id = value;
|
|
403
|
+
sawId = true;
|
|
404
|
+
}
|
|
405
|
+
} else if (name === "retry" && /^[0-9]{1,9}$/.test(value)) onRetry(Number(value));
|
|
406
|
+
};
|
|
407
|
+
return {
|
|
408
|
+
push(chunk) {
|
|
409
|
+
buf += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
410
|
+
for (;;) {
|
|
411
|
+
// The CR is forgotten only once the next byte is here to judge: a chunk that ends on it keeps it.
|
|
412
|
+
if (lastWasCR && buf !== "") {
|
|
413
|
+
if (buf.startsWith("\n")) buf = buf.slice(1);
|
|
414
|
+
lastWasCR = false;
|
|
415
|
+
}
|
|
416
|
+
const at = buf.search(/[\r\n]/);
|
|
417
|
+
if (at < 0) break;
|
|
418
|
+
const text = buf.slice(0, at);
|
|
419
|
+
lastWasCR = buf[at] === "\r";
|
|
420
|
+
buf = buf.slice(at + 1);
|
|
421
|
+
if (skipLine) {
|
|
422
|
+
skipLine = false;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
line(text);
|
|
426
|
+
}
|
|
427
|
+
if (skipLine) {
|
|
428
|
+
buf = "";
|
|
429
|
+
} else if (buf !== "" && (over || Buffer.byteLength(buf, "utf8") + size > maxEventBytes)) {
|
|
430
|
+
// A line still growing past the cap, or any partial line of an event already over it: the event is over,
|
|
431
|
+
// and the line's bytes are not kept.
|
|
432
|
+
over = true;
|
|
433
|
+
data = [];
|
|
434
|
+
skipLine = true;
|
|
435
|
+
buf = "";
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
/** The last `id:` seen, the value a reconnect sends as `Last-Event-ID`. */
|
|
439
|
+
get lastEventId() {
|
|
440
|
+
return id;
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The reason an `end` event's data names (`{"ended": "revoked"}`), or null: data that is not JSON, an older console's
|
|
447
|
+
* `{}`, or a value outside `ENDED_RE`. Only `STREAM_SHUTDOWN` changes what the client does; the rest ride along.
|
|
448
|
+
*/
|
|
449
|
+
export function endedOf(data) {
|
|
450
|
+
try {
|
|
451
|
+
const ended = JSON.parse(data)?.ended;
|
|
452
|
+
return typeof ended === "string" && ENDED_RE.test(ended) ? ended : null;
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The money a preview reserves, as one line, or null. A worker-side reading of the console's `reserve` object. */
|
|
459
|
+
export function reserveLine(r) {
|
|
460
|
+
if (!r || typeof r !== "object") return null;
|
|
461
|
+
if (r.unknown) return "reserve: unknown (the job's data could not be read)";
|
|
462
|
+
if (r.zeroRated) return "reserve: 1 job slot and $0.00 (every model it may call is zero-rated)";
|
|
463
|
+
if (Number.isSafeInteger(r.dollarsMicros) && r.dollarsMicros >= 0) return `reserve: 1 job slot and $${formatMicros(r.dollarsMicros)} (the per-job cap) in each dollar window that applies`;
|
|
464
|
+
return "reserve: 1 job slot (no per-job dollar cap is set)";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** An abortable sleep: resolves after `ms`, or at once (false) when `signal` aborts. */
|
|
468
|
+
function defaultSleep(ms, signal) {
|
|
469
|
+
return new Promise((resolve) => {
|
|
470
|
+
if (signal?.aborted) return resolve(false);
|
|
471
|
+
const timer = setTimeout(() => {
|
|
472
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
473
|
+
resolve(true);
|
|
474
|
+
}, ms);
|
|
475
|
+
const onAbort = () => {
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
resolve(false);
|
|
478
|
+
};
|
|
479
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* A console client for one address and one token. Every collaborator is a seam: `request` (node:http's), `sleep`,
|
|
485
|
+
* `random`, `now` (the cookie's Expires and the re-login window), `timeoutMs` (a response's headers, and the longest
|
|
486
|
+
* pause inside its body), `streamIdleMs`. Methods:
|
|
487
|
+
*
|
|
488
|
+
* - `login()` -> `{name, role}`; throws ConsoleError on a refused token, a rate limit or an unreachable console.
|
|
489
|
+
* - `getJson(path)`, `postJson(path, body)` -> `{status, json}`; signs in first when there is no session, and again
|
|
490
|
+
* ONCE on a 401 (the session expired or was ended), then retries the request once.
|
|
491
|
+
* - `whoami()` -> `{name, role, readOnly}`; `run({id} | {url})` -> `{status, json}` of `/api/run`.
|
|
492
|
+
* - `act(name, body, {confirm, print})`: the console's two-step write. Preview (its summary and reserve go to `print`),
|
|
493
|
+
* then `confirm(preview)` (a y/n the caller asks; no `confirm` is a no), then the confirm with the preview's ticket.
|
|
494
|
+
* Returns `{ok, result}`, `{declined}`, `{unchanged}` or `{refusal, status}` with the console's sentence as it was
|
|
495
|
+
* sent, on one line (`oneLine`).
|
|
496
|
+
* - `stream(path, {onEvent, signal, lastEventId, onState})`: follow an event stream until `done` (`{done: outcome}`)
|
|
497
|
+
* or the signal aborts (`{detached: true}`); reconnects as the module comment says, telling `onState` each step
|
|
498
|
+
* (`{shutdown: true}` when the console said it is stopping, `{waiting: ms}` before a wait, `{relogin: true}`).
|
|
499
|
+
* Throws ConsoleError when it gives up.
|
|
500
|
+
*/
|
|
501
|
+
export function makeConsoleClient({ address, token, request = nodeHttp.request, sleep = defaultSleep, random = Math.random, now = Date.now, timeoutMs = REQUEST_TIMEOUT_MS, streamIdleMs = STREAM_IDLE_MS, backoff = BACKOFF } = {}) {
|
|
502
|
+
if (!address || (address.kind !== "tcp" && address.kind !== "unix")) throw new TypeError("makeConsoleClient: address must come from parseConsoleAddress");
|
|
503
|
+
let cookie = null;
|
|
504
|
+
let session = null;
|
|
505
|
+
const where = address.shown;
|
|
506
|
+
|
|
507
|
+
const baseHeaders = () => ({
|
|
508
|
+
host: address.hostHeader,
|
|
509
|
+
origin: `http://${address.hostHeader}`,
|
|
510
|
+
...(cookie !== null ? { cookie: `pdc_session=${cookie}` } : {}),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Keep the session cookie a response sets, and drop it on a clear: an empty value, a Max-Age of zero or less, or an
|
|
515
|
+
* Expires already past (the two ways RFC 6265 deletes a cookie, whatever value rides along).
|
|
516
|
+
*/
|
|
517
|
+
const takeCookie = (headers) => {
|
|
518
|
+
const set = headers?.["set-cookie"];
|
|
519
|
+
for (const line of Array.isArray(set) ? set : typeof set === "string" ? [set] : []) {
|
|
520
|
+
const m = COOKIE_RE.exec(line);
|
|
521
|
+
if (!m) continue;
|
|
522
|
+
const maxAge = /;\s*max-age=(-?[0-9]+)/i.exec(line);
|
|
523
|
+
const expires = /;\s*expires=([^;]*)/i.exec(line);
|
|
524
|
+
const expired = maxAge ? Number(maxAge[1]) <= 0 : expires ? Date.parse(expires[1]) <= now() : false;
|
|
525
|
+
cookie = m[1] === "" || expired ? null : m[1];
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/** Open one request: resolves `{res}` once headers arrive, or rejects with a ConsoleError naming the address. */
|
|
530
|
+
const open = (method, path, { body = null, accept = "application/json", extra = {}, signal = null } = {}) =>
|
|
531
|
+
new Promise((resolve, reject) => {
|
|
532
|
+
const payload = body === null ? null : Buffer.from(JSON.stringify(body), "utf8");
|
|
533
|
+
const headers = { ...baseHeaders(), accept, ...extra, ...(payload ? { "content-type": "application/json", "content-length": String(payload.length) } : {}) };
|
|
534
|
+
const target = address.kind === "unix" ? { socketPath: address.socketPath } : { host: address.host, port: address.port };
|
|
535
|
+
let settled = false;
|
|
536
|
+
// `agent: false`: one connection per request, closed after it, so no idle keep-alive socket holds the command
|
|
537
|
+
// open after its last answer.
|
|
538
|
+
const req = request({ ...target, method, path, headers, agent: false });
|
|
539
|
+
const timer = setTimeout(() => {
|
|
540
|
+
if (settled) return;
|
|
541
|
+
settled = true;
|
|
542
|
+
req.destroy();
|
|
543
|
+
reject(new ConsoleError(`the console at ${where} did not answer ${method} ${path.split("?")[0]} within ${Math.round(timeoutMs / 1000)} s`));
|
|
544
|
+
}, timeoutMs);
|
|
545
|
+
const onAbort = () => {
|
|
546
|
+
if (settled) return;
|
|
547
|
+
settled = true;
|
|
548
|
+
clearTimeout(timer);
|
|
549
|
+
req.destroy();
|
|
550
|
+
resolve({ aborted: true });
|
|
551
|
+
};
|
|
552
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
553
|
+
req.on("response", (res) => {
|
|
554
|
+
if (settled) return res.destroy();
|
|
555
|
+
settled = true;
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
takeCookie(res.headers);
|
|
558
|
+
resolve({ res, req });
|
|
559
|
+
});
|
|
560
|
+
req.on("error", (e) => {
|
|
561
|
+
if (settled) return;
|
|
562
|
+
settled = true;
|
|
563
|
+
clearTimeout(timer);
|
|
564
|
+
reject(new ConsoleError(`the console at ${where} could not be reached (${codeOf(e)}); is it running (pi-dispatch-console serve)? Name another with --console or PI_CONSOLE_URL`));
|
|
565
|
+
});
|
|
566
|
+
if (payload) req.write(payload);
|
|
567
|
+
req.end();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* A response body as JSON, at most `RESPONSE_MAX_BYTES`; `{json}` or `{json: null}` for a body that is not JSON. A body
|
|
572
|
+
* that pauses longer than `timeoutMs` is cut and rejected with a sentence: headers alone are no answer.
|
|
573
|
+
*/
|
|
574
|
+
const readJson = (res, what = "a request") =>
|
|
575
|
+
new Promise((resolve, reject) => {
|
|
576
|
+
const chunks = [];
|
|
577
|
+
let n = 0;
|
|
578
|
+
let done = false;
|
|
579
|
+
let idle = null;
|
|
580
|
+
const settle = (fn, value) => {
|
|
581
|
+
if (done) return;
|
|
582
|
+
done = true;
|
|
583
|
+
clearTimeout(idle);
|
|
584
|
+
fn(value);
|
|
585
|
+
};
|
|
586
|
+
const arm = () => {
|
|
587
|
+
clearTimeout(idle);
|
|
588
|
+
idle = setTimeout(() => {
|
|
589
|
+
res.destroy();
|
|
590
|
+
settle(reject, new ConsoleError(`the console at ${where} stopped sending its answer to ${what} for ${Math.round(timeoutMs / 1000)} s`));
|
|
591
|
+
}, timeoutMs);
|
|
592
|
+
};
|
|
593
|
+
arm();
|
|
594
|
+
res.on("data", (c) => {
|
|
595
|
+
arm();
|
|
596
|
+
n += c.length;
|
|
597
|
+
if (n > RESPONSE_MAX_BYTES) {
|
|
598
|
+
res.destroy();
|
|
599
|
+
return settle(resolve, { json: null, tooLarge: true });
|
|
600
|
+
}
|
|
601
|
+
chunks.push(c);
|
|
602
|
+
});
|
|
603
|
+
res.on("end", () => {
|
|
604
|
+
try {
|
|
605
|
+
settle(resolve, { json: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
|
|
606
|
+
} catch {
|
|
607
|
+
settle(resolve, { json: null });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
res.on("error", () => settle(resolve, { json: null }));
|
|
611
|
+
res.on("close", () => settle(resolve, { json: null }));
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const errorOf = (status, json) => (typeof json?.error === "string" && oneLine(json.error) !== "" ? oneLine(json.error) : `the console answered ${status}`);
|
|
615
|
+
|
|
616
|
+
async function login() {
|
|
617
|
+
const { res } = await open("POST", "/login", { body: { token } });
|
|
618
|
+
const { json } = await readJson(res, "POST /login");
|
|
619
|
+
if (res.statusCode === 200 && cookie !== null) {
|
|
620
|
+
session = { name: typeof json?.name === "string" ? json.name : "?", role: json?.role === "operator" ? "operator" : "viewer" };
|
|
621
|
+
return session;
|
|
622
|
+
}
|
|
623
|
+
cookie = null;
|
|
624
|
+
session = null;
|
|
625
|
+
if (res.statusCode === 401) throw new ConsoleError(`the console at ${where} refused the token (${errorOf(401, json)}); check PI_CONSOLE_TOKEN`, { status: 401 });
|
|
626
|
+
if (res.statusCode === 429) throw new ConsoleError(`the console at ${where} is limiting sign-ins (${errorOf(429, json)})`, { status: 429 });
|
|
627
|
+
throw new ConsoleError(`signing in to the console at ${where} failed: ${errorOf(res.statusCode, json)}`, { status: res.statusCode });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function requestJson(method, path, body = null) {
|
|
631
|
+
if (cookie === null) await login();
|
|
632
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
633
|
+
const { res } = await open(method, path, { body });
|
|
634
|
+
const { json } = await readJson(res, `${method} ${path.split("?")[0]}`);
|
|
635
|
+
if (res.statusCode === 401 && attempt === 0) {
|
|
636
|
+
cookie = null;
|
|
637
|
+
await login();
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
return { status: res.statusCode, json };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function act(name, body, { confirm, print = () => {} } = {}) {
|
|
645
|
+
if (!ACTION_NAME_RE.test(String(name))) throw new TypeError(`act: ${JSON.stringify(name)} is not an action name`);
|
|
646
|
+
const pre = await requestJson("POST", `/api/act/${name}/preview`, body ?? {});
|
|
647
|
+
if (pre.status !== 200 || !pre.json || typeof pre.json !== "object") return { refusal: errorOf(pre.status, pre.json), status: pre.status };
|
|
648
|
+
const summary = Array.isArray(pre.json.summary) ? pre.json.summary.filter((l) => typeof l === "string") : [];
|
|
649
|
+
for (const l of summary) print(l);
|
|
650
|
+
if (pre.json.unchanged) return { unchanged: true };
|
|
651
|
+
const reserve = reserveLine(pre.json.reserve);
|
|
652
|
+
if (reserve) print(reserve);
|
|
653
|
+
if (typeof pre.json.ticket !== "string" || pre.json.ticket === "") return { refusal: "the console's preview carried no ticket, so nothing can be confirmed", status: pre.status };
|
|
654
|
+
const yes = typeof confirm === "function" ? await confirm(pre.json) : false;
|
|
655
|
+
if (!yes) return { declined: true };
|
|
656
|
+
const done = await requestJson("POST", `/api/act/${name}/confirm`, { ticket: pre.json.ticket });
|
|
657
|
+
if (done.status !== 200) return { refusal: errorOf(done.status, done.json), status: done.status };
|
|
658
|
+
return { ok: true, result: done.json ?? {} };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* One connection of a stream: `{status, retryAfter, error}` for a refused open, or how it ended once open:
|
|
663
|
+
* `{done, outcome}`, `{end: true, ended}` (the server ended the session, `ended` its reason or null),
|
|
664
|
+
* `{closed: true, received}` (with `shutdown: true` when the console said it is stopping) or `{aborted: true}`.
|
|
665
|
+
*/
|
|
666
|
+
async function connectOnce(path, lastEventId, signal, onEvent, onRetry) {
|
|
667
|
+
const extra = lastEventId !== null ? { "last-event-id": lastEventId } : {};
|
|
668
|
+
const opened = await open("GET", path, { accept: "text/event-stream", extra, signal });
|
|
669
|
+
if (opened.aborted) return { aborted: true };
|
|
670
|
+
const { res, req } = opened;
|
|
671
|
+
if (res.statusCode !== 200) {
|
|
672
|
+
const { json } = await readJson(res, `GET ${path.split("?")[0]}`);
|
|
673
|
+
// A 401's `ended` (issue #737) is kept as the end event's is: a short word or null, never printed.
|
|
674
|
+
const ended = typeof json?.ended === "string" && ENDED_RE.test(json.ended) ? json.ended : null;
|
|
675
|
+
return { status: res.statusCode, retryAfter: retryAfterMs(res.headers["retry-after"]), error: errorOf(res.statusCode, json), ended };
|
|
676
|
+
}
|
|
677
|
+
return new Promise((resolve) => {
|
|
678
|
+
let finished = false;
|
|
679
|
+
let received = 0;
|
|
680
|
+
let lastId = lastEventId;
|
|
681
|
+
let idle = null;
|
|
682
|
+
const finish = (value) => {
|
|
683
|
+
if (finished) return;
|
|
684
|
+
finished = true;
|
|
685
|
+
clearTimeout(idle);
|
|
686
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
687
|
+
req.destroy();
|
|
688
|
+
resolve({ ...value, received, lastEventId: lastId });
|
|
689
|
+
};
|
|
690
|
+
// A stream that goes quiet past three pings is a dead connection the kernel has not noticed: cut it and resume.
|
|
691
|
+
const arm = () => {
|
|
692
|
+
clearTimeout(idle);
|
|
693
|
+
idle = setTimeout(() => finish({ closed: true, idle: true }), streamIdleMs);
|
|
694
|
+
};
|
|
695
|
+
const onAbort = () => finish({ aborted: true });
|
|
696
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
697
|
+
if (signal?.aborted) return finish({ aborted: true });
|
|
698
|
+
const parser = makeSseParser({
|
|
699
|
+
onRetry,
|
|
700
|
+
onOversize: () => onEvent({ event: "oversize", data: null, id: null }),
|
|
701
|
+
onEvent: (ev) => {
|
|
702
|
+
if (finished) return;
|
|
703
|
+
if (ev.id !== null) lastId = ev.id;
|
|
704
|
+
if (ev.event === "end") {
|
|
705
|
+
const ended = endedOf(ev.data);
|
|
706
|
+
// The console stopping is a cut connection, not a session that ended: the caller backs off and reconnects.
|
|
707
|
+
return ended === STREAM_SHUTDOWN ? finish({ closed: true, shutdown: true }) : finish({ end: true, ended });
|
|
708
|
+
}
|
|
709
|
+
if (ev.event === "done") {
|
|
710
|
+
let outcome = "unknown";
|
|
711
|
+
try {
|
|
712
|
+
const parsed = JSON.parse(ev.data);
|
|
713
|
+
if (typeof parsed?.outcome === "string") outcome = parsed.outcome;
|
|
714
|
+
} catch {
|
|
715
|
+
// a done event whose body is not JSON still ends the stream
|
|
716
|
+
}
|
|
717
|
+
return finish({ done: true, outcome });
|
|
718
|
+
}
|
|
719
|
+
received += 1;
|
|
720
|
+
onEvent(ev);
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
arm();
|
|
724
|
+
res.on("data", (chunk) => {
|
|
725
|
+
arm();
|
|
726
|
+
parser.push(chunk);
|
|
727
|
+
});
|
|
728
|
+
res.on("end", () => finish({ closed: true }));
|
|
729
|
+
res.on("error", () => finish({ closed: true }));
|
|
730
|
+
res.on("close", () => finish({ closed: true }));
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function stream(path, { onEvent = () => {}, signal = null, lastEventId = null, onState = () => {} } = {}) {
|
|
735
|
+
let last = lastEventId;
|
|
736
|
+
let failures = 0;
|
|
737
|
+
let lastRelogin = null;
|
|
738
|
+
let relogins = 0;
|
|
739
|
+
// Set by a `shutdown` end: the console said it was stopping, so the next re-login is the restart's, not a session
|
|
740
|
+
// that ended again (issue #737).
|
|
741
|
+
let sinceShutdown = false;
|
|
742
|
+
// Whether a stream opened (200) since the last re-login (or the first sign-in): what one free re-login needs.
|
|
743
|
+
let openedSinceRelogin = false;
|
|
744
|
+
let baseMs = backoff.baseMs;
|
|
745
|
+
const onRetry = (ms) => {
|
|
746
|
+
baseMs = Math.min(backoff.capMs, Math.max(backoff.baseMs, ms));
|
|
747
|
+
};
|
|
748
|
+
const wait = async (ms) => {
|
|
749
|
+
onState({ waiting: ms });
|
|
750
|
+
return sleep(ms, signal);
|
|
751
|
+
};
|
|
752
|
+
for (;;) {
|
|
753
|
+
if (signal?.aborted) return { detached: true };
|
|
754
|
+
let got;
|
|
755
|
+
try {
|
|
756
|
+
// Inside the retry, so a console that is restarting refuses the sign-in's connection as it refuses the
|
|
757
|
+
// stream's: backed off and counted, not the end of the chat.
|
|
758
|
+
if (cookie === null) await login();
|
|
759
|
+
got = await connectOnce(path, last, signal, onEvent, onRetry);
|
|
760
|
+
} catch (e) {
|
|
761
|
+
if (!(e instanceof ConsoleError)) throw e;
|
|
762
|
+
// A sign-in the console ANSWERED (a refused token, a bad request) is determinate: no wait changes it. Only no
|
|
763
|
+
// answer, a busy console or one failing on its side is waited out.
|
|
764
|
+
if (e.status !== null && e.status !== 429 && e.status < 500) throw e;
|
|
765
|
+
failures += 1;
|
|
766
|
+
if (failures >= backoff.maxFailures) throw new ConsoleError(`${e.message} (gave up after ${failures} tries)`);
|
|
767
|
+
if (!(await wait(backoffMs(failures, { baseMs, capMs: backoff.capMs, random })))) return { detached: true };
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (got.aborted) return { detached: true };
|
|
771
|
+
// No status is a connection that opened: it ended, closed or was done, but the console served it.
|
|
772
|
+
if (got.status === undefined) openedSinceRelogin = true;
|
|
773
|
+
if (got.lastEventId !== undefined && got.lastEventId !== null) last = got.lastEventId;
|
|
774
|
+
if (got.done) return { done: got.outcome };
|
|
775
|
+
// A connection that streamed entries again: the console is back as it was, so a stop it announced is over.
|
|
776
|
+
if (got.received > 0) sinceShutdown = false;
|
|
777
|
+
if (got.end || got.status === 401) {
|
|
778
|
+
// A console that restarted (its 401 says `restart`: it no longer knows this sign-in), and the first 401 after it
|
|
779
|
+
// said it was stopping, are not the session ending again: they neither meet nor start the window (issue
|
|
780
|
+
// #737), so a second restart inside a minute no longer ends the chat. Never an `end` (the console that sends
|
|
781
|
+
// one knows the session), and at most once per stream that opened since the last re-login (see the header):
|
|
782
|
+
// a console that only ever answers 401 `restart` gets its one counted re-login and then ends the chat. A real
|
|
783
|
+
// end (revoked, ended everywhere, a hostile server's endless ends) is held to the window as before; the pause
|
|
784
|
+
// below still paces every re-login, these included.
|
|
785
|
+
const restarted = got.status === 401 && (got.ended === "restart" || sinceShutdown) && openedSinceRelogin;
|
|
786
|
+
sinceShutdown = false;
|
|
787
|
+
openedSinceRelogin = false;
|
|
788
|
+
// Once per window over the stream's whole life: entries in between do not reset it (that was a sign-in loop).
|
|
789
|
+
if (!restarted && lastRelogin !== null && now() - lastRelogin < RELOGIN_WINDOW_MS) {
|
|
790
|
+
throw new ConsoleError(`the console at ${where} ended this sign-in again within ${Math.round(RELOGIN_WINDOW_MS / 1000)} s of a fresh one; the token may have been revoked or rotated`, { status: 401 });
|
|
791
|
+
}
|
|
792
|
+
relogins += 1;
|
|
793
|
+
cookie = null;
|
|
794
|
+
onState({ relogin: true });
|
|
795
|
+
// A short pause before signing in again, growing with each re-login, so even the allowed ones are paced.
|
|
796
|
+
if (!(await sleep(backoffMs(relogins, { baseMs: backoff.baseMs, capMs: backoff.capMs, random }), signal))) return { detached: true };
|
|
797
|
+
if (!restarted) lastRelogin = now();
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (got.status === 429) {
|
|
801
|
+
failures += 1;
|
|
802
|
+
if (failures >= backoff.maxFailures) throw new ConsoleError(`the console at ${where} kept refusing the stream as busy: ${got.error}`, { status: 429 });
|
|
803
|
+
if (!(await wait(got.retryAfter ?? backoffMs(failures, { baseMs, capMs: backoff.capMs, random })))) return { detached: true };
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (got.status === 503 || got.status === 502 || got.status === 504) {
|
|
807
|
+
failures += 1;
|
|
808
|
+
if (failures >= backoff.maxFailures) throw new ConsoleError(`the console at ${where} could not serve the stream: ${got.error}`, { status: got.status });
|
|
809
|
+
if (!(await wait(got.retryAfter ?? backoffMs(failures, { baseMs, capMs: backoff.capMs, random })))) return { detached: true };
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (got.status !== undefined) throw new ConsoleError(got.error, { status: got.status });
|
|
813
|
+
// Open, then closed without `done`: a cut stream (a slow reader, a restart, or the console saying it is stopping).
|
|
814
|
+
// Reconnect from `last`, with the same cookie: a restarted console's 401 is what signs in again.
|
|
815
|
+
failures = got.received > 0 ? 1 : failures + 1;
|
|
816
|
+
if (failures >= backoff.maxFailures) throw new ConsoleError(`the console at ${where} kept closing the stream before it ended (gave up after ${failures} tries)`);
|
|
817
|
+
if (got.shutdown) {
|
|
818
|
+
sinceShutdown = true;
|
|
819
|
+
onState({ shutdown: true });
|
|
820
|
+
}
|
|
821
|
+
if (!(await wait(backoffMs(failures, { baseMs, capMs: backoff.capMs, random })))) return { detached: true };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
address,
|
|
827
|
+
login,
|
|
828
|
+
get session() {
|
|
829
|
+
return session;
|
|
830
|
+
},
|
|
831
|
+
getJson: (path) => requestJson("GET", path),
|
|
832
|
+
postJson: (path, body) => requestJson("POST", path, body),
|
|
833
|
+
async whoami() {
|
|
834
|
+
const got = await requestJson("GET", "/api/whoami");
|
|
835
|
+
if (got.status !== 200 || !got.json) throw new ConsoleError(`the console at ${where} did not say who is signed in: ${errorOf(got.status, got.json)}`, { status: got.status });
|
|
836
|
+
return { name: String(got.json.name ?? "?"), role: got.json.role === "operator" ? "operator" : "viewer", readOnly: got.json.readOnly === true };
|
|
837
|
+
},
|
|
838
|
+
run(query) {
|
|
839
|
+
const params = new URLSearchParams();
|
|
840
|
+
if (typeof query?.id === "string") params.set("id", query.id);
|
|
841
|
+
else if (typeof query?.url === "string") params.set("url", query.url);
|
|
842
|
+
else throw new TypeError("run: want {id} or {url}");
|
|
843
|
+
return requestJson("GET", `/api/run?${params}`);
|
|
844
|
+
},
|
|
845
|
+
act,
|
|
846
|
+
stream,
|
|
847
|
+
};
|
|
848
|
+
}
|