@knpkv/herdr-approvals 0.2.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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -0
  3. package/dist/activity-history.d.ts +36 -0
  4. package/dist/activity-history.d.ts.map +1 -0
  5. package/dist/activity-history.js +233 -0
  6. package/dist/activity-history.js.map +1 -0
  7. package/dist/approval-app-view.d.ts +26 -0
  8. package/dist/approval-app-view.d.ts.map +1 -0
  9. package/dist/approval-app-view.js +79 -0
  10. package/dist/approval-app-view.js.map +1 -0
  11. package/dist/approval-client.d.ts +2 -0
  12. package/dist/approval-client.d.ts.map +1 -0
  13. package/dist/approval-client.js +520 -0
  14. package/dist/approval-client.js.map +1 -0
  15. package/dist/approval-sw.js +28 -0
  16. package/dist/approval-sw.js.map +7 -0
  17. package/dist/approval-url.d.ts +37 -0
  18. package/dist/approval-url.d.ts.map +1 -0
  19. package/dist/approval-url.js +37 -0
  20. package/dist/approval-url.js.map +1 -0
  21. package/dist/approval.js +188 -0
  22. package/dist/approval.js.map +7 -0
  23. package/dist/auth.d.ts +10 -0
  24. package/dist/auth.d.ts.map +1 -0
  25. package/dist/auth.js +14 -0
  26. package/dist/auth.js.map +1 -0
  27. package/dist/bin.d.ts +3 -0
  28. package/dist/bin.d.ts.map +1 -0
  29. package/dist/bin.js +47 -0
  30. package/dist/bin.js.map +1 -0
  31. package/dist/connect-entry.d.ts +2 -0
  32. package/dist/connect-entry.d.ts.map +1 -0
  33. package/dist/connect-entry.js +2 -0
  34. package/dist/connect-entry.js.map +1 -0
  35. package/dist/connect.js +188 -0
  36. package/dist/connect.js.map +7 -0
  37. package/dist/dashboard-model.d.ts +719 -0
  38. package/dist/dashboard-model.d.ts.map +1 -0
  39. package/dist/dashboard-model.js +83 -0
  40. package/dist/dashboard-model.js.map +1 -0
  41. package/dist/dashboard-view.d.ts +42 -0
  42. package/dist/dashboard-view.d.ts.map +1 -0
  43. package/dist/dashboard-view.js +194 -0
  44. package/dist/dashboard-view.js.map +1 -0
  45. package/dist/errors.d.ts +33 -0
  46. package/dist/errors.d.ts.map +1 -0
  47. package/dist/errors.js +12 -0
  48. package/dist/errors.js.map +1 -0
  49. package/dist/fleetctl-submission.d.ts +68 -0
  50. package/dist/fleetctl-submission.d.ts.map +1 -0
  51. package/dist/fleetctl-submission.js +31 -0
  52. package/dist/fleetctl-submission.js.map +1 -0
  53. package/dist/fleetctl.d.ts +22 -0
  54. package/dist/fleetctl.d.ts.map +1 -0
  55. package/dist/fleetctl.js +264 -0
  56. package/dist/fleetctl.js.map +1 -0
  57. package/dist/geist-latin-wght-normal.woff2 +0 -0
  58. package/dist/geist-mono-latin-wght-normal.woff2 +0 -0
  59. package/dist/http.d.ts +1512 -0
  60. package/dist/http.d.ts.map +1 -0
  61. package/dist/http.js +1653 -0
  62. package/dist/http.js.map +1 -0
  63. package/dist/index.css +2 -0
  64. package/dist/index.css.map +7 -0
  65. package/dist/index.d.ts +12 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +12 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/internal/config-path.d.ts +3 -0
  70. package/dist/internal/config-path.d.ts.map +1 -0
  71. package/dist/internal/config-path.js +3 -0
  72. package/dist/internal/config-path.js.map +1 -0
  73. package/dist/internal/dashboard-pending-state.d.ts +49 -0
  74. package/dist/internal/dashboard-pending-state.d.ts.map +1 -0
  75. package/dist/internal/dashboard-pending-state.js +98 -0
  76. package/dist/internal/dashboard-pending-state.js.map +1 -0
  77. package/dist/internal/fleet-follow.d.ts +54 -0
  78. package/dist/internal/fleet-follow.d.ts.map +1 -0
  79. package/dist/internal/fleet-follow.js +10 -0
  80. package/dist/internal/fleet-follow.js.map +1 -0
  81. package/dist/internal/fleet-request.d.ts +4 -0
  82. package/dist/internal/fleet-request.d.ts.map +1 -0
  83. package/dist/internal/fleet-request.js +11 -0
  84. package/dist/internal/fleet-request.js.map +1 -0
  85. package/dist/internal/html.d.ts +3 -0
  86. package/dist/internal/html.d.ts.map +1 -0
  87. package/dist/internal/html.js +8 -0
  88. package/dist/internal/html.js.map +1 -0
  89. package/dist/internal/websocket.d.ts +4 -0
  90. package/dist/internal/websocket.d.ts.map +1 -0
  91. package/dist/internal/websocket.js +12 -0
  92. package/dist/internal/websocket.js.map +1 -0
  93. package/dist/model.d.ts +40 -0
  94. package/dist/model.d.ts.map +1 -0
  95. package/dist/model.js +53 -0
  96. package/dist/model.js.map +1 -0
  97. package/dist/operations.d.ts +72 -0
  98. package/dist/operations.d.ts.map +1 -0
  99. package/dist/operations.js +284 -0
  100. package/dist/operations.js.map +1 -0
  101. package/dist/push-policy.d.ts +3 -0
  102. package/dist/push-policy.d.ts.map +1 -0
  103. package/dist/push-policy.js +3 -0
  104. package/dist/push-policy.js.map +1 -0
  105. package/dist/push-sender.d.ts +18 -0
  106. package/dist/push-sender.d.ts.map +1 -0
  107. package/dist/push-sender.js +33 -0
  108. package/dist/push-sender.js.map +1 -0
  109. package/dist/push-subscription.d.ts +17 -0
  110. package/dist/push-subscription.d.ts.map +1 -0
  111. package/dist/push-subscription.js +62 -0
  112. package/dist/push-subscription.js.map +1 -0
  113. package/dist/push-worker.d.ts +20 -0
  114. package/dist/push-worker.d.ts.map +1 -0
  115. package/dist/push-worker.js +32 -0
  116. package/dist/push-worker.js.map +1 -0
  117. package/dist/pwa.d.ts +39 -0
  118. package/dist/pwa.d.ts.map +1 -0
  119. package/dist/pwa.js +51 -0
  120. package/dist/pwa.js.map +1 -0
  121. package/dist/shell-view.d.ts +20 -0
  122. package/dist/shell-view.d.ts.map +1 -0
  123. package/dist/shell-view.js +86 -0
  124. package/dist/shell-view.js.map +1 -0
  125. package/dist/store.d.ts +46 -0
  126. package/dist/store.d.ts.map +1 -0
  127. package/dist/store.js +291 -0
  128. package/dist/store.js.map +1 -0
  129. package/dist/work-checkpoint.d.ts +107 -0
  130. package/dist/work-checkpoint.d.ts.map +1 -0
  131. package/dist/work-checkpoint.js +37 -0
  132. package/dist/work-checkpoint.js.map +1 -0
  133. package/package.json +90 -0
package/dist/http.js ADDED
@@ -0,0 +1,1653 @@
1
+ import { NodeHttpClient, NodeServices } from "@effect/platform-node";
2
+ import { AgentActivityStore, AgentRelationshipStore, ConnectAgentCursor, fleetConnectAgents, localConnectAgents, makeHerdrTerminalConnector, pageFleetConnectAgents, TerminalClientCommand, terminalCommandMaxPayloadBytes, terminalFrameMaxEncodedBytes, TerminalSelection, TerminalServerSignal } from "@knpkv/herdr-connect";
3
+ import { ChatRequest, ChatStore, makeCoordinatorChat } from "@knpkv/herdr-coordinator";
4
+ import { decodeBoundedResponseJson, FleetAuthorizationError, FleetJobNotFoundError, FleetOperationError, fleetResponseBodyMaxBytes, FleetValidationError, JobHash, JobIdentifier, JobRequest, PeerPendingDecodeError, PeerPendingHostMismatchError, PeerPendingStatusError, PeerPendingTimeoutError, PeerPendingTransportError, PeerPendingUnavailableError, PendingApprovalCursor } from "@knpkv/herdr-fleet";
5
+ import { authorizeWhois, discoverFleetPeers, layer as tailscaleLayer, Tailscale } from "@knpkv/herdr-tailscale";
6
+ import { makeWorkService, WorkStore } from "@knpkv/herdr-work";
7
+ import { WorkGoalCheckpoint } from "@knpkv/herdr-work/model";
8
+ import { Cause, Clock, Crypto, Effect, Exit, Fiber, FileSystem, Layer, ManagedRuntime, Queue, Result, Schema, Scope, Semaphore, Stream } from "effect";
9
+ import * as HttpClient from "effect/unstable/http/HttpClient";
10
+ import { createServer } from "node:http";
11
+ import { createServer as createSecureServer } from "node:https";
12
+ import { createElement } from "react";
13
+ import { renderToStaticMarkup } from "react-dom/server";
14
+ import WebSocketClient, { WebSocketServer } from "ws";
15
+ import { authorize, authorizeLoopback } from "./auth.js";
16
+ import { PendingApprovalContinuation, PendingApprovalSummary } from "./dashboard-model.js";
17
+ import { DashboardView } from "./dashboard-view.js";
18
+ import { DashboardResponseBudgetError } from "./errors.js";
19
+ import { dashboardDocumentTitle } from "./internal/html.js";
20
+ import { relayTerminalCloseCode, terminalBufferCanAccept } from "./internal/websocket.js";
21
+ import { ApprovalNotificationCandidate, PushSubscriptionRecord, PushSubscriptionRemoval } from "./model.js";
22
+ import { generateVapidKeys, makePushSender } from "./push-sender.js";
23
+ import { validatePushEndpoint } from "./push-subscription.js";
24
+ import { makePushWorker } from "./push-worker.js";
25
+ import { ApprovalAppStore } from "./store.js";
26
+ import { workCheckpointPath, workSnapshotPath } from "./work-checkpoint.js";
27
+ const Approval = Schema.Struct({ hash: JobHash, nonce: Schema.String });
28
+ const TcpAddress = Schema.Struct({ address: Schema.String, port: Schema.Number });
29
+ const decodeJobPathSegment = Effect.fn("ApprovalHttp.decodeJobPathSegment")((segment) => Effect.try({
30
+ try: () => decodeURIComponent(segment),
31
+ catch: () => new FleetValidationError({ detail: "invalid job identifier path segment" })
32
+ }).pipe(Effect.flatMap((value) => Schema.decodeUnknownEffect(JobIdentifier)(value).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid job identifier path segment" }))))));
33
+ const peerPendingTimeoutMs = 1_500;
34
+ const terminalFrameMaxPayload = terminalFrameMaxEncodedBytes;
35
+ const json = (response, status, value) => {
36
+ response.writeHead(status, {
37
+ "content-type": "application/json; charset=utf-8"
38
+ });
39
+ response.end(`${JSON.stringify(value)}\n`);
40
+ };
41
+ const apiError = (error) => {
42
+ switch (error._tag) {
43
+ case "FleetAuthorizationError":
44
+ return { status: 403, body: { error: error._tag, actor: error.actor } };
45
+ case "FleetJobNotFoundError":
46
+ return { status: 404, body: { error: error._tag, jobId: error.jobId } };
47
+ case "FleetJobConflictError":
48
+ return { status: 409, body: { error: error._tag, jobId: error.jobId } };
49
+ case "FleetApprovalError":
50
+ return { status: 409, body: { error: error._tag, detail: error.detail } };
51
+ case "FleetTransitionConflictError":
52
+ return { status: 409, body: { error: error._tag, jobId: error.jobId } };
53
+ case "FleetValidationError":
54
+ return { status: 400, body: { error: error._tag, detail: error.detail } };
55
+ case "PushEndpointNotAllowedError":
56
+ return { status: 400, body: { error: error._tag, origin: error.origin } };
57
+ case "ConnectPeerError":
58
+ return {
59
+ status: 503,
60
+ body: { error: error._tag, host: error.host, reason: error.reason }
61
+ };
62
+ case "FleetOperationError":
63
+ case "TerminalTransportError":
64
+ return { status: 503, body: { error: error._tag, detail: error.detail } };
65
+ case "FleetStoreError":
66
+ return { status: 500, body: { error: error._tag, detail: error.detail } };
67
+ case "ApprovalAppStoreError":
68
+ case "ChatHistoryError":
69
+ case "WorkProjectionError":
70
+ return { status: 500, body: { error: error._tag, detail: error.detail } };
71
+ case "WorkStoreError":
72
+ return { status: 500, body: { error: error._tag, detail: error.operation } };
73
+ case "WorkCheckpointConflictError":
74
+ return { status: 409, body: { error: error._tag, eventId: error.eventId } };
75
+ case "DashboardResponseBudgetError":
76
+ return {
77
+ status: 413,
78
+ body: {
79
+ error: error._tag,
80
+ encodedBytes: error.encodedBytes,
81
+ maximumBytes: error.maximumBytes
82
+ }
83
+ };
84
+ }
85
+ };
86
+ const header = (request, name) => {
87
+ const value = request.headers[name];
88
+ return Array.isArray(value) ? value[0] : value;
89
+ };
90
+ const actor = (request, config, allowLocal) => authorize({
91
+ remoteAddress: request.socket.remoteAddress,
92
+ login: header(request, "tailscale-user-login")
93
+ }, config.allowedUsers, allowLocal);
94
+ const sameOrigin = (request, expected) => {
95
+ const received = header(request, "origin");
96
+ return received === expected
97
+ ? Effect.void
98
+ : Effect.fail(new FleetAuthorizationError({ actor: received ?? "missing-origin" }));
99
+ };
100
+ export const listenerAuthority = (address, port) => new URL(`http://${address}:${port}/`).host.toLowerCase();
101
+ const approvalIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#111418"/><path d="M146 264l72 72 148-160" fill="none" stroke="#a6e3a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="52"/></svg>`;
102
+ const approvalManifest = JSON.stringify({
103
+ id: "/",
104
+ name: "Fleet approvals",
105
+ short_name: "Approvals",
106
+ start_url: "/",
107
+ scope: "/",
108
+ display: "standalone",
109
+ background_color: "#111418",
110
+ theme_color: "#111418",
111
+ icons: [
112
+ {
113
+ src: "/assets/approval-icon.svg",
114
+ sizes: "any",
115
+ type: "image/svg+xml",
116
+ purpose: "any maskable"
117
+ }
118
+ ]
119
+ });
120
+ const tailnetActor = Effect.fn("ApprovalHttp.tailnetActor")(function* (request, config, requiredNodeIds) {
121
+ const address = request.socket.remoteAddress;
122
+ if (address === undefined) {
123
+ return yield* new FleetAuthorizationError({ actor: "unknown" });
124
+ }
125
+ const tailscale = yield* Tailscale;
126
+ const identity = yield* tailscale.whois(address).pipe(Effect.mapError((cause) => new FleetOperationError({
127
+ cause,
128
+ detail: String(cause),
129
+ operation: "tailscale.whois"
130
+ })));
131
+ return yield* authorizeWhois(identity, config.allowedUsers, requiredNodeIds).pipe(Effect.mapError((error) => new FleetAuthorizationError({ actor: error.actor })));
132
+ });
133
+ const readBody = (request) => Effect.tryPromise({
134
+ try: async () => {
135
+ const chunks = [];
136
+ let size = 0;
137
+ for await (const chunk of request) {
138
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
139
+ size += buffer.length;
140
+ if (size > 1024 * 1024)
141
+ throw new Error("request body exceeds 1 MiB");
142
+ chunks.push(buffer);
143
+ }
144
+ return Buffer.concat(chunks).toString("utf8");
145
+ },
146
+ catch: (cause) => new FleetValidationError({ detail: String(cause) })
147
+ });
148
+ const readJson = Effect.fn("ApprovalHttp.readJson")(function* (request, schema) {
149
+ const mediaType = (header(request, "content-type") ?? "")
150
+ .split(";", 1)[0]
151
+ ?.trim()
152
+ .toLowerCase();
153
+ if (mediaType !== "application/json") {
154
+ return yield* new FleetValidationError({
155
+ detail: "content-type must be application/json"
156
+ });
157
+ }
158
+ const text = yield* readBody(request);
159
+ const unknown = yield* Effect.try({
160
+ try: () => JSON.parse(text),
161
+ catch: (cause) => new FleetValidationError({ detail: `invalid JSON: ${String(cause)}` })
162
+ });
163
+ return yield* Schema.decodeUnknownEffect(schema, {
164
+ onExcessProperty: "error"
165
+ })(unknown).pipe(Effect.mapError((error) => new FleetValidationError({
166
+ detail: `invalid request: ${String(error)}`
167
+ })));
168
+ });
169
+ const authorizeOriginlessMutation = (request) => {
170
+ const origin = header(request, "origin");
171
+ const fetchSite = header(request, "sec-fetch-site");
172
+ return origin === undefined && fetchSite !== "cross-site"
173
+ ? Effect.void
174
+ : Effect.fail(new FleetAuthorizationError({ actor: origin ?? fetchSite ?? "browser" }));
175
+ };
176
+ export const recordWorkCheckpointRequest = Effect.fn("ApprovalHttp.recordWorkCheckpointRequest")(function* (authorization, decode, work) {
177
+ yield* authorization;
178
+ const checkpoint = yield* decode;
179
+ return yield* work.record(checkpoint);
180
+ });
181
+ const pathOf = (request) => {
182
+ const url = new URL(request.url ?? "/", "http://hostd.local");
183
+ if (url.pathname === "/fleet")
184
+ url.pathname = "/";
185
+ if (url.pathname.startsWith("/fleet/")) {
186
+ url.pathname = url.pathname.slice("/fleet".length);
187
+ }
188
+ return url;
189
+ };
190
+ const fleetPeers = Effect.fn("HostHttp.fleetPeers")(function* (config) {
191
+ const peers = yield* discoverFleetPeers(config.host, config.machines).pipe(Effect.mapError((cause) => new FleetOperationError({
192
+ operation: "tailscale.status",
193
+ detail: String(cause),
194
+ cause
195
+ })));
196
+ return peers.map((peer) => {
197
+ const address = peer.ipv4 ?? undefined;
198
+ const approvalHub = peer.host.toLowerCase() ===
199
+ config.approvalHub.host.toLowerCase() &&
200
+ config.machines.some((machine) => machine.host.toLowerCase() === peer.host.toLowerCase() &&
201
+ machine.nodeId === config.approvalHub.nodeId);
202
+ return {
203
+ host: peer.host,
204
+ online: peer.online,
205
+ approvalUrl: address === undefined
206
+ ? null
207
+ : approvalHub
208
+ ? config.approvalHub.url
209
+ : `http://${address}:${config.approvalPort}/`,
210
+ pendingUrl: address === undefined
211
+ ? null
212
+ : `http://${address}:${config.port}/v1/pending-approvals`,
213
+ connectAgentsUrl: address === undefined
214
+ ? null
215
+ : `http://${address}:${config.port}/v1/connect/agents/local`,
216
+ terminalUrl: address === undefined
217
+ ? null
218
+ : `ws://${address}:${config.port}/v1/connect/terminal`
219
+ };
220
+ });
221
+ });
222
+ const pendingApproval = (record) => ({
223
+ id: record.id,
224
+ createdAt: record.createdAt,
225
+ actor: record.actor,
226
+ approvalExpiresAt: record.approvalExpiresAt ?? null,
227
+ status: "pending_approval",
228
+ payload: record.payload
229
+ });
230
+ const dashboardHistoryMaxBytes = 512 * 1024;
231
+ export const dashboardSnapshotBytes = (snapshot) => new TextEncoder().encode(JSON.stringify(snapshot)).byteLength + 1;
232
+ export const budgetDashboardSnapshot = Effect.fn("HostHttp.budgetDashboardSnapshot")(function* (snapshot) {
233
+ const originalRecords = snapshot.records;
234
+ const records = [...originalRecords];
235
+ while (records.length > 0 &&
236
+ dashboardSnapshotBytes({ ...snapshot, records }) > fleetResponseBodyMaxBytes) {
237
+ records.pop();
238
+ }
239
+ const first = originalRecords.at(0);
240
+ if (first !== undefined && records.length === 0) {
241
+ return yield* new DashboardResponseBudgetError({
242
+ encodedBytes: dashboardSnapshotBytes({
243
+ ...snapshot,
244
+ records: [first]
245
+ }),
246
+ maximumBytes: fleetResponseBodyMaxBytes
247
+ });
248
+ }
249
+ const last = records.at(-1);
250
+ const candidate = records.length === originalRecords.length
251
+ ? snapshot
252
+ : {
253
+ ...snapshot,
254
+ records,
255
+ historyNextCursor: last === undefined
256
+ ? snapshot.historyNextCursor
257
+ : { createdAt: last.createdAt, id: last.id }
258
+ };
259
+ const encodedBytes = dashboardSnapshotBytes(candidate);
260
+ if (encodedBytes > fleetResponseBodyMaxBytes) {
261
+ return yield* new DashboardResponseBudgetError({
262
+ encodedBytes,
263
+ maximumBytes: fleetResponseBodyMaxBytes
264
+ });
265
+ }
266
+ return candidate;
267
+ });
268
+ const dashboardHistory = Effect.fn("HostHttp.dashboardHistory")(function* (service, cursor) {
269
+ const candidates = yield* service.historyAfter(cursor, 51);
270
+ const records = [];
271
+ for (const candidate of candidates.slice(0, 50)) {
272
+ const projected = { ...candidate, error: null, result: null };
273
+ const bytes = new TextEncoder().encode(JSON.stringify([...records, projected])).byteLength;
274
+ if (bytes > dashboardHistoryMaxBytes)
275
+ break;
276
+ records.push(projected);
277
+ }
278
+ const last = records.at(-1);
279
+ return {
280
+ records,
281
+ nextCursor: records.length < candidates.length && last !== undefined
282
+ ? { createdAt: last.createdAt, id: last.id }
283
+ : null
284
+ };
285
+ });
286
+ const localPendingSummary = Effect.fn("HostHttp.localPendingSummary")(function* (host, service, cursor) {
287
+ const page = yield* service.pendingApprovalPage(cursor);
288
+ return {
289
+ host,
290
+ approvals: page.records.map(pendingApproval),
291
+ nextCursor: page.nextCursor
292
+ };
293
+ });
294
+ const decodePendingApprovalCursor = Effect.fn("HostHttp.decodePendingApprovalCursor")(function* (url) {
295
+ const createdAt = url.searchParams.get("cursorCreatedAt");
296
+ const id = url.searchParams.get("cursorId");
297
+ if (createdAt === null && id === null)
298
+ return null;
299
+ if (createdAt === null || id === null) {
300
+ return yield* new FleetValidationError({
301
+ detail: "pending approval cursor requires cursorCreatedAt and cursorId"
302
+ });
303
+ }
304
+ return yield* Schema.decodeUnknownEffect(PendingApprovalCursor)({
305
+ createdAt: Number(createdAt),
306
+ id
307
+ }).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid pending approval cursor" })));
308
+ });
309
+ const decodePendingApprovalContinuation = Effect.fn("HostHttp.decodePendingApprovalContinuation")(function* (url) {
310
+ const host = url.searchParams.get("cursorHost");
311
+ const cursor = yield* decodePendingApprovalCursor(url);
312
+ if (host === null || cursor === null) {
313
+ return yield* new FleetValidationError({
314
+ detail: "dashboard pending continuation requires cursorHost, cursorCreatedAt and cursorId"
315
+ });
316
+ }
317
+ return yield* Schema.decodeUnknownEffect(PendingApprovalContinuation)({
318
+ host,
319
+ cursor
320
+ }).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid dashboard pending continuation" })));
321
+ });
322
+ const decodeConnectAgentCursor = Effect.fn("HostHttp.decodeConnectAgentCursor")(function* (url) {
323
+ const host = url.searchParams.get("cursorHost");
324
+ const id = url.searchParams.get("cursorId");
325
+ if (host === null && id === null)
326
+ return null;
327
+ if (host === null || id === null) {
328
+ return yield* new FleetValidationError({
329
+ detail: "Connect agent cursor requires cursorHost and cursorId"
330
+ });
331
+ }
332
+ return yield* Schema.decodeUnknownEffect(ConnectAgentCursor)({ host, id }).pipe(Effect.mapError(() => new FleetValidationError({ detail: "invalid Connect agent cursor" })));
333
+ });
334
+ const fetchPeerPending = Effect.fn("HostHttp.fetchPeerPending")(function* (peer, cursor) {
335
+ const pendingUrl = peer.pendingUrl;
336
+ if (pendingUrl === null || peer.approvalUrl === null) {
337
+ return yield* new PeerPendingUnavailableError({
338
+ host: peer.host,
339
+ reason: "unavailable"
340
+ });
341
+ }
342
+ if (!peer.online) {
343
+ return yield* new PeerPendingUnavailableError({
344
+ host: peer.host,
345
+ reason: "offline"
346
+ });
347
+ }
348
+ const client = yield* HttpClient.HttpClient;
349
+ const pageUrl = new URL(pendingUrl);
350
+ if (cursor !== null) {
351
+ pageUrl.searchParams.set("cursorCreatedAt", String(cursor.createdAt));
352
+ pageUrl.searchParams.set("cursorId", cursor.id);
353
+ }
354
+ const response = yield* client.get(pageUrl.toString()).pipe(Effect.mapError((cause) => new PeerPendingTransportError({
355
+ host: peer.host,
356
+ detail: String(cause),
357
+ cause
358
+ })));
359
+ if (response.status < 200 || response.status >= 300) {
360
+ return yield* new PeerPendingStatusError({
361
+ host: peer.host,
362
+ status: response.status
363
+ });
364
+ }
365
+ const summary = yield* decodeBoundedResponseJson(response, PendingApprovalSummary).pipe(Effect.mapError((cause) => new PeerPendingDecodeError({
366
+ host: peer.host,
367
+ detail: String(cause),
368
+ cause
369
+ })));
370
+ if (summary.host.toLowerCase() !== peer.host.toLowerCase()) {
371
+ return yield* new PeerPendingHostMismatchError({
372
+ expectedHost: peer.host,
373
+ receivedHost: summary.host
374
+ });
375
+ }
376
+ return summary;
377
+ }, (effect, peer) => effect.pipe(Effect.timeoutOrElse({
378
+ duration: peerPendingTimeoutMs,
379
+ orElse: () => Effect.fail(new PeerPendingTimeoutError({
380
+ host: peer.host,
381
+ timeoutMs: peerPendingTimeoutMs
382
+ }))
383
+ })));
384
+ const fetchAllPeerPending = Effect.fn("HostHttp.fetchAllPeerPending")(function* (peer) {
385
+ const approvals = [];
386
+ const seen = new Set();
387
+ let cursor = null;
388
+ do {
389
+ const page = yield* fetchPeerPending(peer, cursor);
390
+ for (const approval of page.approvals)
391
+ approvals.push(approval);
392
+ cursor = page.nextCursor;
393
+ if (cursor !== null) {
394
+ const key = `${cursor.createdAt}\u0000${cursor.id}`;
395
+ if (seen.has(key)) {
396
+ return yield* new PeerPendingDecodeError({
397
+ cause: key,
398
+ detail: "peer repeated a pending approval cursor",
399
+ host: peer.host
400
+ });
401
+ }
402
+ seen.add(key);
403
+ }
404
+ } while (cursor !== null);
405
+ return { approvals, host: peer.host, nextCursor: null };
406
+ });
407
+ const pendingFailureReason = (error) => {
408
+ switch (error._tag) {
409
+ case "PeerPendingUnavailableError":
410
+ return error.reason;
411
+ case "PeerPendingTimeoutError":
412
+ return "timeout";
413
+ case "PeerPendingDecodeError":
414
+ case "PeerPendingHostMismatchError":
415
+ return "invalid_response";
416
+ case "PeerPendingStatusError":
417
+ case "PeerPendingTransportError":
418
+ return "request_failed";
419
+ }
420
+ };
421
+ const aggregatePeerPending = Effect.fn("HostHttp.aggregatePeerPending")(function* (peers, pagination = "first") {
422
+ const results = yield* Effect.all(peers.map((peer) => Effect.result(pagination === "all"
423
+ ? fetchAllPeerPending(peer)
424
+ : fetchPeerPending(peer, null)).pipe(Effect.map((result) => ({ peer, result })))), { concurrency: 4 });
425
+ const remote = [];
426
+ const failures = [];
427
+ const nextCursors = [];
428
+ for (const { peer, result } of results) {
429
+ if (Result.isFailure(result)) {
430
+ failures.push({
431
+ host: peer.host,
432
+ reason: pendingFailureReason(result.failure)
433
+ });
434
+ continue;
435
+ }
436
+ if (peer.approvalUrl === null) {
437
+ failures.push({ host: peer.host, reason: "unavailable" });
438
+ continue;
439
+ }
440
+ for (const approval of result.success.approvals) {
441
+ remote.push({
442
+ host: peer.host,
443
+ approvalUrl: peer.approvalUrl,
444
+ approval
445
+ });
446
+ }
447
+ if (result.success.nextCursor !== null) {
448
+ nextCursors.push({ host: peer.host, cursor: result.success.nextCursor });
449
+ }
450
+ }
451
+ return { remote, failures, nextCursors };
452
+ });
453
+ const dashboardPendingPage = Effect.fn("HostHttp.dashboardPendingPage")(function* (config, service, continuation) {
454
+ if (continuation.host.toLowerCase() === config.host.toLowerCase()) {
455
+ const page = yield* service.pendingApprovalPage(continuation.cursor);
456
+ return {
457
+ local: page.records,
458
+ remote: [],
459
+ failures: [],
460
+ nextCursors: page.nextCursor === null
461
+ ? []
462
+ : [{ host: config.host, cursor: page.nextCursor }]
463
+ };
464
+ }
465
+ const peers = yield* fleetPeers(config);
466
+ const peer = peers.find(({ host }) => host.toLowerCase() === continuation.host.toLowerCase());
467
+ if (peer === undefined || peer.approvalUrl === null) {
468
+ return yield* new FleetValidationError({
469
+ detail: `unknown pending approval host ${continuation.host}`
470
+ });
471
+ }
472
+ const approvalUrl = peer.approvalUrl;
473
+ const page = yield* fetchPeerPending(peer, continuation.cursor).pipe(Effect.mapError((cause) => new FleetOperationError({
474
+ cause,
475
+ detail: `could not continue pending approvals on ${peer.host}`,
476
+ operation: "fleet.pending_approvals"
477
+ })));
478
+ return {
479
+ local: [],
480
+ remote: page.approvals.map((approval) => ({
481
+ approval,
482
+ approvalUrl,
483
+ host: peer.host
484
+ })),
485
+ failures: [],
486
+ nextCursors: page.nextCursor === null
487
+ ? []
488
+ : [{ host: peer.host, cursor: page.nextCursor }]
489
+ };
490
+ });
491
+ const resolvePendingApprovalTarget = Effect.fn("HostHttp.resolvePendingApprovalTarget")(function* (config, service, target) {
492
+ if (target.host.toLowerCase() === config.host.toLowerCase()) {
493
+ const record = yield* service.get(target.jobId);
494
+ if (record.status !== "pending_approval") {
495
+ return yield* new FleetJobNotFoundError({ jobId: target.jobId });
496
+ }
497
+ return { _tag: "local", record };
498
+ }
499
+ const peers = yield* fleetPeers(config);
500
+ const peer = peers.find(({ host }) => host.toLowerCase() === target.host.toLowerCase());
501
+ if (peer === undefined || peer.approvalUrl === null) {
502
+ return yield* new FleetJobNotFoundError({ jobId: target.jobId });
503
+ }
504
+ const seen = new Set();
505
+ let cursor = null;
506
+ do {
507
+ const page = yield* fetchPeerPending(peer, cursor).pipe(Effect.mapError((cause) => new FleetOperationError({
508
+ cause,
509
+ detail: `could not resolve pending approval on ${peer.host}`,
510
+ operation: "fleet.pending_approval_target"
511
+ })));
512
+ const approval = page.approvals.find(({ id }) => id === target.jobId);
513
+ if (approval !== undefined) {
514
+ return {
515
+ _tag: "remote",
516
+ remote: { approval, approvalUrl: peer.approvalUrl, host: peer.host }
517
+ };
518
+ }
519
+ cursor = page.nextCursor;
520
+ if (cursor !== null) {
521
+ const key = `${cursor.createdAt}\u0000${cursor.id}`;
522
+ if (seen.has(key)) {
523
+ return yield* new FleetOperationError({
524
+ cause: key,
525
+ detail: `peer ${peer.host} repeated a pending approval cursor`,
526
+ operation: "fleet.pending_approval_target"
527
+ });
528
+ }
529
+ seen.add(key);
530
+ }
531
+ } while (cursor !== null);
532
+ return yield* new FleetJobNotFoundError({ jobId: target.jobId });
533
+ });
534
+ export const notificationCandidates = Effect.fn("HostHttp.notificationCandidates")(function* (config, service) {
535
+ const local = yield* service.pendingApprovals();
536
+ const localCandidates = local.map((record) => ({
537
+ host: config.host,
538
+ jobId: record.id
539
+ }));
540
+ const peers = yield* Effect.result(fleetPeers(config));
541
+ if (Result.isFailure(peers)) {
542
+ yield* Effect.logWarning("PushWorker.peer_directory_unavailable", peers.failure);
543
+ return {
544
+ candidates: localCandidates,
545
+ pendingCount: null
546
+ };
547
+ }
548
+ const aggregated = yield* aggregatePeerPending(peers.success, "all");
549
+ if (aggregated.failures.length > 0) {
550
+ yield* Effect.logWarning("PushWorker.peer_pending_partial_failure", aggregated.failures);
551
+ }
552
+ const candidates = [
553
+ ...localCandidates,
554
+ ...aggregated.remote.map((remote) => ({
555
+ host: remote.host,
556
+ jobId: remote.approval.id
557
+ }))
558
+ ];
559
+ return {
560
+ candidates,
561
+ pendingCount: aggregated.failures.length === 0 ? candidates.length : null
562
+ };
563
+ });
564
+ const dashboardPage = (snapshot) => {
565
+ const markup = snapshot.approvalApp.canonical ? "" : renderToStaticMarkup(createElement("div", { className: "dashboard-gesture" }, createElement(DashboardView, {
566
+ busyJobId: null,
567
+ chatBusy: false,
568
+ notificationState: "loading",
569
+ onChatSubmit: undefined,
570
+ onDecision: () => undefined,
571
+ onDisableNotifications: undefined,
572
+ onEnableNotifications: undefined,
573
+ onRefresh: () => undefined,
574
+ pull: { distance: 0, ready: false, refreshing: false },
575
+ snapshot
576
+ })));
577
+ const data = JSON.stringify(snapshot).replaceAll("<", "\\u003c");
578
+ return `<!doctype html>
579
+ <html lang="en">
580
+ <head>
581
+ <meta charset="utf-8">
582
+ <meta name="viewport" content="width=device-width,initial-scale=1">
583
+ <meta name="color-scheme" content="dark">
584
+ <meta name="theme-color" content="#111418">
585
+ <meta name="apple-mobile-web-app-capable" content="yes">
586
+ <meta name="apple-mobile-web-app-title" content="Approvals">
587
+ <title>${dashboardDocumentTitle(snapshot.host)}</title>
588
+ <link rel="manifest" href="/manifest.webmanifest">
589
+ <link rel="icon" href="/assets/approval-icon.svg" type="image/svg+xml">
590
+ <link rel="stylesheet" href="/assets/index.css">
591
+ </head>
592
+ <body data-rly-root data-rly-theme="dark">
593
+ <div id="fleet-dashboard-root">${markup}</div>
594
+ <script id="fleet-dashboard-data" type="application/json">${data}</script>
595
+ <script src="/assets/approval.js" defer></script>
596
+ </body>
597
+ </html>`;
598
+ };
599
+ const connectPage = () => `<!doctype html>
600
+ <html lang="en">
601
+ <head>
602
+ <meta charset="utf-8">
603
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,viewport-fit=cover">
604
+ <meta name="color-scheme" content="dark">
605
+ <meta name="theme-color" content="#0b0d10">
606
+ <title>Fleet connect</title>
607
+ <link rel="stylesheet" href="/assets/index.css">
608
+ </head>
609
+ <body data-rly-root data-rly-theme="dark" class="connect-body">
610
+ <div id="fleet-connect-root"></div>
611
+ <script src="/assets/connect.js" defer></script>
612
+ </body>
613
+ </html>`;
614
+ const rejectUpgrade = (socket, status) => {
615
+ socket.end(`HTTP/1.1 ${status} ${status === 403 ? "Forbidden" : "Bad Request"}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
616
+ };
617
+ const terminalSelectionFromUrl = (url) => Schema.decodeUnknownEffect(TerminalSelection)({
618
+ host: url.searchParams.get("host"),
619
+ agentId: url.searchParams.get("agent"),
620
+ cols: Number(url.searchParams.get("cols")),
621
+ rows: Number(url.searchParams.get("rows"))
622
+ }).pipe(Effect.mapError((cause) => new FleetValidationError({
623
+ detail: `invalid terminal selection: ${String(cause)}`
624
+ })));
625
+ const terminalCloseCode = (error) => {
626
+ switch (error._tag) {
627
+ case "TerminalAgentNotFoundError":
628
+ return 4404;
629
+ case "TerminalBusyError":
630
+ return 4423;
631
+ case "TerminalProtocolError":
632
+ return 4400;
633
+ case "TerminalTransportError":
634
+ return 4503;
635
+ }
636
+ };
637
+ const readApproval = Effect.fn("ApprovalHttp.readApproval")(function* (request) {
638
+ const contentType = header(request, "content-type") ?? "";
639
+ if (contentType.startsWith("application/json")) {
640
+ return yield* readJson(request, Approval);
641
+ }
642
+ const body = yield* readBody(request);
643
+ const form = new URLSearchParams(body);
644
+ return yield* Schema.decodeUnknownEffect(Approval)({
645
+ hash: form.get("hash"),
646
+ nonce: form.get("nonce")
647
+ }).pipe(Effect.mapError((error) => new FleetValidationError({
648
+ detail: `invalid approval: ${String(error)}`
649
+ })));
650
+ });
651
+ export const makeRunner = Effect.fn("HostRunner.make")(function* (runJob) {
652
+ const scope = yield* Scope.make();
653
+ const serial = yield* Semaphore.make(1);
654
+ const runPromise = Effect.runPromiseWith(yield* Effect.context());
655
+ let accepting = true;
656
+ const enqueue = (jobId) => {
657
+ if (!accepting)
658
+ return Promise.resolve(false);
659
+ const run = serial.withPermits(1)(runJob(jobId).pipe(Effect.tapError((error) => Effect.logError("HostRunner.job_failed", error).pipe(Effect.annotateLogs({ jobId }))), Effect.ignore));
660
+ return runPromise(Effect.forkIn(run, scope)).then(() => true);
661
+ };
662
+ const close = async () => {
663
+ if (!accepting)
664
+ return;
665
+ accepting = false;
666
+ await runPromise(Scope.close(scope, Exit.void));
667
+ };
668
+ return { close, enqueue };
669
+ });
670
+ export const startHttpServer = async (config, service, uiAssets, options = {}) => {
671
+ const isHub = config.crossHost &&
672
+ config.host.toLowerCase() === config.approvalHub.host.toLowerCase();
673
+ const approvalTls = config.approvalTls;
674
+ if (isHub && approvalTls === null) {
675
+ throw new FleetValidationError({
676
+ detail: "approval hub requires direct TLS certificate and private key paths"
677
+ });
678
+ }
679
+ const configuredTailscale = tailscaleLayer(config.tailscaleCommand).pipe(Layer.provide(NodeServices.layer));
680
+ const httpRuntime = ManagedRuntime.make(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerNodeHttp, configuredTailscale));
681
+ const activeServers = new Set();
682
+ const activeHttpSockets = new Set();
683
+ const activeRequestControllers = new Set();
684
+ const terminalControllers = new Set();
685
+ const terminalTasks = new Set();
686
+ const webSockets = new Set();
687
+ const finalizers = [
688
+ () => httpRuntime.dispose()
689
+ ];
690
+ let acceptingRequests = false;
691
+ let closed = false;
692
+ const runRequest = (effect) => {
693
+ const controller = new AbortController();
694
+ activeRequestControllers.add(controller);
695
+ return httpRuntime.runPromise(effect, { signal: controller.signal }).finally(() => activeRequestControllers.delete(controller));
696
+ };
697
+ const shutdown = async () => {
698
+ if (closed)
699
+ return;
700
+ closed = true;
701
+ acceptingRequests = false;
702
+ for (const controller of activeRequestControllers)
703
+ controller.abort();
704
+ for (const socket of webSockets)
705
+ socket.terminate();
706
+ for (const socket of activeHttpSockets)
707
+ socket.destroy();
708
+ const failures = [];
709
+ const serverResults = await Promise.allSettled([...activeServers].map((server) => new Promise((resolve, reject) => {
710
+ server.close((error) => error === undefined ? resolve() : reject(error));
711
+ })));
712
+ for (const result of serverResults) {
713
+ if (result.status === "rejected")
714
+ failures.push(result.reason);
715
+ }
716
+ for (const socket of webSockets)
717
+ socket.terminate();
718
+ const terminalResults = await Promise.allSettled(terminalTasks);
719
+ for (const result of terminalResults) {
720
+ if (result.status === "rejected")
721
+ failures.push(result.reason);
722
+ }
723
+ for (const finalize of finalizers) {
724
+ try {
725
+ await finalize();
726
+ }
727
+ catch (error) {
728
+ failures.push(error);
729
+ }
730
+ }
731
+ if (failures.length > 0) {
732
+ throw new AggregateError(failures, "hostd cleanup failed");
733
+ }
734
+ };
735
+ try {
736
+ const tlsCredentials = isHub && approvalTls !== null
737
+ ? await httpRuntime.runPromise(Effect.gen(function* () {
738
+ const fileSystem = yield* FileSystem.FileSystem;
739
+ const read = (path, operation) => fileSystem.readFileString(path).pipe(Effect.mapError((cause) => new FleetOperationError({
740
+ cause,
741
+ detail: `cannot read approval TLS material at ${path}`,
742
+ operation
743
+ })));
744
+ return {
745
+ certificate: yield* read(approvalTls.certificatePath, "hostd.approval_tls.read_certificate"),
746
+ privateKey: yield* read(approvalTls.privateKeyPath, "hostd.approval_tls.read_private_key")
747
+ };
748
+ }))
749
+ : null;
750
+ const respond = async (response, effect, status = 200) => {
751
+ const result = await runRequest(Effect.result(effect));
752
+ if (Result.isSuccess(result)) {
753
+ json(response, status, result.success);
754
+ return;
755
+ }
756
+ const mapped = apiError(result.failure);
757
+ json(response, mapped.status, mapped.body);
758
+ };
759
+ const statePath = `${config.stateDirectory}/approval-app.sqlite`;
760
+ const approvalStore = await httpRuntime.runPromise(ApprovalAppStore.open(statePath));
761
+ finalizers.unshift(() => Promise.resolve().then(() => approvalStore.close()));
762
+ const cryptoService = await httpRuntime.runPromise(Crypto.Crypto);
763
+ const chatStore = await httpRuntime.runPromise(ChatStore.open(statePath));
764
+ finalizers.unshift(() => Promise.resolve().then(() => chatStore.close()));
765
+ const activityStore = await httpRuntime.runPromise(AgentActivityStore.open(statePath));
766
+ finalizers.unshift(() => Promise.resolve().then(() => activityStore.close()));
767
+ const relationshipStore = await httpRuntime.runPromise(AgentRelationshipStore.open(statePath));
768
+ finalizers.unshift(() => Promise.resolve().then(() => relationshipStore.close()));
769
+ const workStore = await httpRuntime.runPromise(WorkStore.open(statePath));
770
+ finalizers.unshift(() => Promise.resolve().then(() => workStore.close()));
771
+ const work = await httpRuntime.runPromise(makeWorkService(workStore));
772
+ const now = options.now ?? (() => httpRuntime.runSync(Clock.currentTimeMillis));
773
+ const chat = await httpRuntime.runPromise(makeCoordinatorChat({
774
+ config,
775
+ fleet: service,
776
+ store: chatStore
777
+ }));
778
+ const runJob = Effect.fn("HostRunner.runJob")(function* (jobId) {
779
+ const record = yield* service.get(jobId);
780
+ return yield* record.payload.kind === "agent.delegate" &&
781
+ record.payload.channel === "coordinator_chat"
782
+ ? chat.run(jobId)
783
+ : service.run(jobId);
784
+ });
785
+ const runner = await Effect.runPromise(makeRunner(runJob));
786
+ finalizers.unshift(() => runner.close());
787
+ const enqueueJob = (jobId) => Effect.promise(() => runner.enqueue(jobId)).pipe(Effect.flatMap((accepted) => accepted
788
+ ? Effect.void
789
+ : Effect.fail(new FleetOperationError({
790
+ cause: jobId,
791
+ detail: "job runner is closed",
792
+ operation: "hostd.runner.enqueue"
793
+ }))));
794
+ const vapidKeys = isHub
795
+ ? await Effect.runPromise(approvalStore.getOrCreateVapidKeys(generateVapidKeys))
796
+ : null;
797
+ const pushSender = vapidKeys === null
798
+ ? null
799
+ : (options.pushSender ??
800
+ makePushSender(vapidKeys, config.pushSubject, approvalStore, config.pushAllowedOrigins));
801
+ const workerScope = await Effect.runPromise(Scope.make());
802
+ finalizers.unshift(() => Effect.runPromise(Scope.close(workerScope, Exit.void)));
803
+ const terminalConnector = options.terminalConnector ??
804
+ (await httpRuntime.runPromise(makeHerdrTerminalConnector(config, service)));
805
+ const webSocketServer = new WebSocketServer({
806
+ noServer: true,
807
+ maxPayload: terminalCommandMaxPayloadBytes,
808
+ perMessageDeflate: false
809
+ });
810
+ const watchSocket = (socket) => {
811
+ let alive = true;
812
+ socket.on("error", () => socket.terminate());
813
+ const heartbeat = setInterval(() => {
814
+ if (!alive) {
815
+ socket.terminate();
816
+ return;
817
+ }
818
+ alive = false;
819
+ socket.ping();
820
+ }, 25_000);
821
+ heartbeat.unref();
822
+ socket.on("pong", () => {
823
+ alive = true;
824
+ });
825
+ socket.once("close", () => clearInterval(heartbeat));
826
+ };
827
+ const closeSocket = (socket, code, reason) => {
828
+ if (socket.readyState === WebSocketClient.OPEN)
829
+ socket.close(code, reason);
830
+ };
831
+ const attachTerminal = async (socket, selection) => {
832
+ if (terminalControllers.size >= 4) {
833
+ closeSocket(socket, 4429, "terminal connection limit reached");
834
+ return;
835
+ }
836
+ const key = `${selection.host.toLowerCase()}:${selection.agentId}`;
837
+ if (terminalControllers.has(key)) {
838
+ closeSocket(socket, 4423, "agent already controlled");
839
+ return;
840
+ }
841
+ terminalControllers.add(key);
842
+ const scope = await Effect.runPromise(Scope.make());
843
+ let finalized = false;
844
+ let finalizePromise;
845
+ let idleTimer;
846
+ const finalize = async () => {
847
+ if (finalizePromise !== undefined)
848
+ return finalizePromise;
849
+ finalized = true;
850
+ if (idleTimer !== undefined)
851
+ clearTimeout(idleTimer);
852
+ finalizePromise = Effect.runPromise(Scope.close(scope, Exit.void)).finally(() => terminalControllers.delete(key));
853
+ return finalizePromise;
854
+ };
855
+ const finalizeFromSocket = () => {
856
+ void finalize().catch((error) => Effect.runPromise(Effect.logError("HostHttp.terminal_finalize_failed", error)));
857
+ };
858
+ socket.once("close", finalizeFromSocket);
859
+ socket.once("error", finalizeFromSocket);
860
+ const openFiber = await Effect.runPromise(Effect.forkIn(Effect.result(Scope.provide(scope)(terminalConnector.open(selection))), scope));
861
+ const openExit = await Effect.runPromise(Fiber.await(openFiber));
862
+ if (Exit.isFailure(openExit)) {
863
+ await finalize();
864
+ if (Cause.hasInterruptsOnly(openExit.cause))
865
+ return;
866
+ await Effect.runPromise(Effect.failCause(openExit.cause));
867
+ return;
868
+ }
869
+ const opened = openExit.value;
870
+ if (Result.isFailure(opened)) {
871
+ await finalize();
872
+ closeSocket(socket, terminalCloseCode(opened.failure), opened.failure._tag);
873
+ return;
874
+ }
875
+ if (finalized || socket.readyState !== WebSocketClient.OPEN) {
876
+ await finalize();
877
+ return;
878
+ }
879
+ const session = opened.success;
880
+ const inputQueue = await Effect.runPromise(Queue.dropping(64));
881
+ const inputLoop = Stream.fromQueue(inputQueue).pipe(Stream.runForEach(session.send), Effect.catch((error) => Effect.sync(() => closeSocket(socket, terminalCloseCode(error), error._tag))));
882
+ await Effect.runPromise(Effect.forkIn(inputLoop, scope));
883
+ const resetIdle = () => {
884
+ if (idleTimer !== undefined)
885
+ clearTimeout(idleTimer);
886
+ idleTimer = setTimeout(() => closeSocket(socket, 4408, "terminal idle timeout"), 30 * 60 * 1_000);
887
+ idleTimer.unref();
888
+ };
889
+ resetIdle();
890
+ const readySignal = JSON.stringify(Schema.decodeUnknownSync(TerminalServerSignal)({
891
+ type: "terminal.ready"
892
+ }));
893
+ setImmediate(() => {
894
+ if (socket.readyState === WebSocketClient.OPEN)
895
+ socket.send(readySignal);
896
+ });
897
+ socket.on("message", (data, isBinary) => {
898
+ if (isBinary) {
899
+ closeSocket(socket, 4400, "binary client frames are forbidden");
900
+ return;
901
+ }
902
+ resetIdle();
903
+ try {
904
+ const command = Schema.decodeUnknownSync(TerminalClientCommand)(JSON.parse(data.toString()));
905
+ if (!Queue.offerUnsafe(inputQueue, command)) {
906
+ closeSocket(socket, 4429, "terminal input queue limit reached");
907
+ }
908
+ }
909
+ catch {
910
+ closeSocket(socket, 4400, "invalid terminal command");
911
+ }
912
+ });
913
+ const eventLoop = Stream.runForEach(session.events, (event) => Effect.sync(() => {
914
+ if (socket.readyState !== WebSocketClient.OPEN)
915
+ return;
916
+ if (event.type === "terminal.frame") {
917
+ const payload = Buffer.from(event.bytes, "base64");
918
+ if (!terminalBufferCanAccept(socket.bufferedAmount, payload.byteLength)) {
919
+ closeSocket(socket, 4429, "terminal output backpressure limit hit");
920
+ return;
921
+ }
922
+ socket.send(payload, { binary: true });
923
+ }
924
+ else {
925
+ const payload = JSON.stringify(Schema.decodeUnknownSync(TerminalServerSignal)({
926
+ type: "terminal.closed",
927
+ reason: "terminal ended"
928
+ }));
929
+ if (!terminalBufferCanAccept(socket.bufferedAmount, Buffer.byteLength(payload))) {
930
+ closeSocket(socket, 4429, "terminal output backpressure limit hit");
931
+ return;
932
+ }
933
+ socket.send(payload);
934
+ closeSocket(socket, 1000, "terminal closed");
935
+ }
936
+ })).pipe(Effect.tap(() => Effect.sync(() => closeSocket(socket, 1000, "terminal ended"))), Effect.catch((error) => Effect.sync(() => closeSocket(socket, terminalCloseCode(error), error._tag))));
937
+ const eventFiber = await Effect.runPromise(Effect.forkIn(eventLoop, scope));
938
+ await Effect.runPromise(Fiber.await(eventFiber));
939
+ await finalize();
940
+ };
941
+ const proxyTerminal = async (socket, selection) => {
942
+ const peers = await httpRuntime.runPromise(fleetPeers(config));
943
+ const peer = peers.find((candidate) => candidate.host.toLowerCase() === selection.host.toLowerCase());
944
+ if (peer?.terminalUrl === null || peer?.terminalUrl === undefined) {
945
+ closeSocket(socket, 4404, "host unavailable");
946
+ return;
947
+ }
948
+ const remoteUrl = new URL(peer.terminalUrl);
949
+ remoteUrl.searchParams.set("host", selection.host);
950
+ remoteUrl.searchParams.set("agent", selection.agentId);
951
+ remoteUrl.searchParams.set("cols", String(selection.cols));
952
+ remoteUrl.searchParams.set("rows", String(selection.rows));
953
+ const remote = new WebSocketClient(remoteUrl, {
954
+ headers: { host: remoteUrl.host },
955
+ maxPayload: terminalFrameMaxPayload,
956
+ perMessageDeflate: false
957
+ });
958
+ const closed = new Promise((resolve) => remote.once("close", () => resolve()));
959
+ webSockets.add(remote);
960
+ const connectTimeout = setTimeout(() => {
961
+ remote.terminate();
962
+ closeSocket(socket, 4408, "remote terminal connection timeout");
963
+ }, 5_000);
964
+ connectTimeout.unref();
965
+ remote.once("open", () => {
966
+ clearTimeout(connectTimeout);
967
+ watchSocket(remote);
968
+ socket.on("message", (data, isBinary) => {
969
+ if (isBinary || remote.readyState !== WebSocketClient.OPEN) {
970
+ closeSocket(socket, 4400, "invalid terminal command");
971
+ return;
972
+ }
973
+ try {
974
+ const decoded = Schema.decodeUnknownSync(TerminalClientCommand)(JSON.parse(data.toString()));
975
+ const encoded = JSON.stringify(decoded);
976
+ if (!terminalBufferCanAccept(remote.bufferedAmount, Buffer.byteLength(encoded))) {
977
+ closeSocket(socket, 4429, "remote input backpressure limit hit");
978
+ remote.close(4429, "remote input backpressure limit hit");
979
+ return;
980
+ }
981
+ remote.send(encoded);
982
+ }
983
+ catch {
984
+ closeSocket(socket, 4400, "invalid terminal command");
985
+ }
986
+ });
987
+ });
988
+ remote.on("message", (data, isBinary) => {
989
+ if (socket.readyState === WebSocketClient.OPEN) {
990
+ const payloadBytes = Array.isArray(data)
991
+ ? data.reduce((bytes, part) => bytes + part.byteLength, 0)
992
+ : data.byteLength;
993
+ if (!terminalBufferCanAccept(socket.bufferedAmount, payloadBytes)) {
994
+ closeSocket(socket, 4429, "terminal output backpressure limit hit");
995
+ if (remote.readyState === WebSocketClient.OPEN) {
996
+ remote.close(4429, "browser backpressure limit hit");
997
+ }
998
+ return;
999
+ }
1000
+ socket.send(data, { binary: isBinary });
1001
+ }
1002
+ });
1003
+ remote.once("unexpected-response", (_request, response) => {
1004
+ clearTimeout(connectTimeout);
1005
+ response.resume();
1006
+ closeSocket(socket, 4403, "remote host rejected connection");
1007
+ remote.terminate();
1008
+ });
1009
+ remote.once("error", () => {
1010
+ clearTimeout(connectTimeout);
1011
+ closeSocket(socket, 4503, "remote host unavailable");
1012
+ });
1013
+ remote.once("close", (code, reason) => {
1014
+ clearTimeout(connectTimeout);
1015
+ webSockets.delete(remote);
1016
+ const relayedCode = relayTerminalCloseCode(code);
1017
+ closeSocket(socket, relayedCode, relayedCode === code
1018
+ ? reason.toString()
1019
+ : "remote terminal closed abnormally");
1020
+ });
1021
+ socket.once("close", () => {
1022
+ webSockets.delete(remote);
1023
+ if (remote.readyState === WebSocketClient.CONNECTING) {
1024
+ remote.terminate();
1025
+ }
1026
+ else if (remote.readyState === WebSocketClient.OPEN) {
1027
+ remote.close(1000, "browser disconnected");
1028
+ }
1029
+ });
1030
+ await closed;
1031
+ };
1032
+ const listen = async (address, port, mode, tls = null) => {
1033
+ let expectedHost = mode === "serve"
1034
+ ? new URL(config.approvalHub.url).host.toLowerCase()
1035
+ : "";
1036
+ const expectedOrigin = () => mode === "serve"
1037
+ ? new URL(config.approvalHub.url).origin
1038
+ : `http://${expectedHost}`;
1039
+ const requestHandler = async (request, response) => {
1040
+ try {
1041
+ const url = pathOf(request);
1042
+ if (header(request, "host")?.toLowerCase() !== expectedHost) {
1043
+ json(response, 403, {
1044
+ error: "FleetAuthorizationError",
1045
+ actor: "host"
1046
+ });
1047
+ return;
1048
+ }
1049
+ if (!acceptingRequests) {
1050
+ json(response, 503, { error: "starting" });
1051
+ return;
1052
+ }
1053
+ if (request.method === "GET" && url.pathname === "/assets/index.css") {
1054
+ response.writeHead(200, {
1055
+ "cache-control": "no-cache, must-revalidate",
1056
+ "content-type": "text/css; charset=utf-8"
1057
+ });
1058
+ response.end(uiAssets.stylesheet);
1059
+ return;
1060
+ }
1061
+ if (request.method === "GET" &&
1062
+ url.pathname === "/assets/approval.js") {
1063
+ response.writeHead(200, {
1064
+ "cache-control": "no-cache, must-revalidate",
1065
+ "content-type": "text/javascript; charset=utf-8"
1066
+ });
1067
+ response.end(uiAssets.script);
1068
+ return;
1069
+ }
1070
+ if (request.method === "GET" && url.pathname === "/assets/connect.js") {
1071
+ response.writeHead(200, {
1072
+ "cache-control": "no-cache, must-revalidate",
1073
+ "content-type": "text/javascript; charset=utf-8"
1074
+ });
1075
+ response.end(uiAssets.connectScript);
1076
+ return;
1077
+ }
1078
+ if (request.method === "GET" &&
1079
+ url.pathname === "/assets/approval-sw.js") {
1080
+ response.writeHead(200, {
1081
+ "cache-control": "no-cache, must-revalidate",
1082
+ "content-type": "text/javascript; charset=utf-8",
1083
+ "service-worker-allowed": "/"
1084
+ });
1085
+ response.end(uiAssets.worker);
1086
+ return;
1087
+ }
1088
+ if (request.method === "GET" &&
1089
+ url.pathname === "/manifest.webmanifest") {
1090
+ response.writeHead(200, {
1091
+ "cache-control": "no-cache, must-revalidate",
1092
+ "content-type": "application/manifest+json; charset=utf-8"
1093
+ });
1094
+ response.end(approvalManifest);
1095
+ return;
1096
+ }
1097
+ if (request.method === "GET" &&
1098
+ url.pathname === "/assets/approval-icon.svg") {
1099
+ response.writeHead(200, {
1100
+ "cache-control": "no-cache, must-revalidate",
1101
+ "content-type": "image/svg+xml; charset=utf-8"
1102
+ });
1103
+ response.end(approvalIcon);
1104
+ return;
1105
+ }
1106
+ const fontMatch = /^\/assets\/([^/]+\.woff2)$/.exec(url.pathname);
1107
+ const font = fontMatch?.[1] === undefined
1108
+ ? undefined
1109
+ : uiAssets.fonts.get(fontMatch[1]);
1110
+ if (request.method === "GET" && font !== undefined) {
1111
+ response.writeHead(200, {
1112
+ "cache-control": "public, max-age=31536000, immutable",
1113
+ "content-type": "font/woff2"
1114
+ });
1115
+ response.end(font);
1116
+ return;
1117
+ }
1118
+ const authorized = mode === "approval"
1119
+ ? tailnetActor(request, config, config.approvalNodes)
1120
+ : mode === "tailnet" || mode === "serve"
1121
+ ? tailnetActor(request, config, null)
1122
+ : actor(request, config, true);
1123
+ const loopbackAuthorized = authorizeLoopback({
1124
+ login: header(request, "tailscale-user-login"),
1125
+ remoteAddress: request.socket.remoteAddress
1126
+ });
1127
+ const approvalSurface = mode === "approval" || mode === "serve";
1128
+ const dashboard = Effect.gen(function* () {
1129
+ const observedAt = now();
1130
+ const resolvedLocalPage = approvalSurface
1131
+ ? yield* service.pendingApprovalPage(null)
1132
+ : { records: [], nextCursor: null };
1133
+ const state = yield* Effect.all({
1134
+ history: dashboardHistory(service, null),
1135
+ status: service.status()
1136
+ });
1137
+ let directory = null;
1138
+ let pendingApprovals = {
1139
+ local: approvalSurface
1140
+ ? resolvedLocalPage.records
1141
+ : state.history.records.filter((record) => record.status === "pending_approval"),
1142
+ remote: [],
1143
+ failures: [],
1144
+ nextCursors: approvalSurface && resolvedLocalPage.nextCursor !== null
1145
+ ? [{ host: config.host, cursor: resolvedLocalPage.nextCursor }]
1146
+ : []
1147
+ };
1148
+ if (approvalSurface) {
1149
+ const currentUrl = mode === "serve"
1150
+ ? config.approvalHub.url
1151
+ : `http://${expectedHost}/`;
1152
+ const peersResult = yield* Effect.result(fleetPeers(config));
1153
+ directory = Result.isSuccess(peersResult)
1154
+ ? {
1155
+ currentUrl,
1156
+ links: peersResult.success.map((peer) => ({
1157
+ host: peer.host,
1158
+ online: peer.online,
1159
+ url: peer.approvalUrl
1160
+ })),
1161
+ error: null
1162
+ }
1163
+ : {
1164
+ currentUrl,
1165
+ links: [],
1166
+ error: peersResult.failure.detail
1167
+ };
1168
+ if (Result.isSuccess(peersResult)) {
1169
+ const aggregated = yield* aggregatePeerPending(peersResult.success);
1170
+ pendingApprovals = {
1171
+ local: resolvedLocalPage.records,
1172
+ ...aggregated,
1173
+ nextCursors: resolvedLocalPage.nextCursor === null
1174
+ ? aggregated.nextCursors
1175
+ : [
1176
+ { host: config.host, cursor: resolvedLocalPage.nextCursor },
1177
+ ...aggregated.nextCursors
1178
+ ]
1179
+ };
1180
+ }
1181
+ else {
1182
+ pendingApprovals = {
1183
+ local: resolvedLocalPage.records,
1184
+ remote: [],
1185
+ nextCursors: resolvedLocalPage.nextCursor === null
1186
+ ? []
1187
+ : [{ host: config.host, cursor: resolvedLocalPage.nextCursor }],
1188
+ failures: config.machines
1189
+ .filter(({ host }) => host.toLowerCase() !== config.host.toLowerCase())
1190
+ .map(({ host }) => ({
1191
+ host,
1192
+ reason: "unavailable"
1193
+ }))
1194
+ };
1195
+ }
1196
+ }
1197
+ return yield* budgetDashboardSnapshot({
1198
+ host: config.host,
1199
+ observedAt,
1200
+ approvalsEnabled: approvalSurface,
1201
+ approvalApp: {
1202
+ canonical: mode === "serve",
1203
+ canonicalUrl: config.approvalHub.url,
1204
+ chatEnabled: mode === "serve",
1205
+ pushEnabled: mode === "serve"
1206
+ },
1207
+ chat: null,
1208
+ work: null,
1209
+ status: state.status,
1210
+ records: state.history.records,
1211
+ historyNextCursor: state.history.nextCursor,
1212
+ directory,
1213
+ pendingApprovals
1214
+ });
1215
+ });
1216
+ if (request.method === "GET" && url.pathname === "/v1/dashboard") {
1217
+ await respond(response, Effect.andThen(authorized, dashboard));
1218
+ return;
1219
+ }
1220
+ if (approvalSurface &&
1221
+ request.method === "GET" &&
1222
+ url.pathname === "/v1/dashboard-history") {
1223
+ const effect = Effect.gen(function* () {
1224
+ yield* authorized;
1225
+ const cursor = yield* decodePendingApprovalCursor(url);
1226
+ return yield* dashboardHistory(service, cursor);
1227
+ });
1228
+ await respond(response, effect);
1229
+ return;
1230
+ }
1231
+ if (approvalSurface &&
1232
+ request.method === "GET" &&
1233
+ url.pathname === "/v1/pending-approval") {
1234
+ const effect = Effect.gen(function* () {
1235
+ yield* authorized;
1236
+ const target = yield* Schema.decodeUnknownEffect(ApprovalNotificationCandidate)({
1237
+ host: url.searchParams.get("host"),
1238
+ jobId: url.searchParams.get("jobId")
1239
+ }).pipe(Effect.mapError(() => new FleetValidationError({
1240
+ detail: "pending approval target requires valid host and jobId"
1241
+ })));
1242
+ return yield* resolvePendingApprovalTarget(config, service, target);
1243
+ });
1244
+ await respond(response, effect);
1245
+ return;
1246
+ }
1247
+ if ((mode === "local" || mode === "serve") &&
1248
+ request.method === "GET" &&
1249
+ url.pathname === workSnapshotPath) {
1250
+ const workAuthorization = mode === "local" ? loopbackAuthorized : authorized;
1251
+ await respond(response, Effect.andThen(workAuthorization, work.snapshots(now())));
1252
+ return;
1253
+ }
1254
+ if (mode === "local" &&
1255
+ request.method === "POST" &&
1256
+ url.pathname === workCheckpointPath) {
1257
+ const effect = recordWorkCheckpointRequest(loopbackAuthorized, authorizeOriginlessMutation(request).pipe(Effect.andThen(readJson(request, WorkGoalCheckpoint))), work);
1258
+ await respond(response, effect, 201);
1259
+ return;
1260
+ }
1261
+ if (mode === "serve" &&
1262
+ request.method === "GET" &&
1263
+ url.pathname === "/v1/push/config") {
1264
+ await respond(response, Effect.andThen(authorized, Effect.succeed({
1265
+ canonicalUrl: config.approvalHub.url,
1266
+ enabled: vapidKeys !== null,
1267
+ publicKey: vapidKeys?.publicKey ?? null
1268
+ })));
1269
+ return;
1270
+ }
1271
+ if (mode === "serve" &&
1272
+ request.method === "GET" &&
1273
+ url.pathname === "/v1/push/subscriptions") {
1274
+ const effect = Effect.gen(function* () {
1275
+ const who = yield* authorized;
1276
+ const removal = yield* Schema.decodeUnknownEffect(PushSubscriptionRemoval)({ endpoint: url.searchParams.get("endpoint") }).pipe(Effect.mapError((cause) => new FleetValidationError({
1277
+ detail: `invalid subscription endpoint: ${String(cause)}`
1278
+ })));
1279
+ return {
1280
+ subscribed: yield* approvalStore.hasSubscription(removal.endpoint, who)
1281
+ };
1282
+ });
1283
+ await respond(response, effect);
1284
+ return;
1285
+ }
1286
+ if (mode === "serve" &&
1287
+ request.method === "POST" &&
1288
+ url.pathname === "/v1/push/subscriptions") {
1289
+ const effect = Effect.gen(function* () {
1290
+ const who = yield* authorized;
1291
+ yield* sameOrigin(request, expectedOrigin());
1292
+ const subscription = yield* readJson(request, PushSubscriptionRecord);
1293
+ yield* validatePushEndpoint(subscription.endpoint, config.pushAllowedOrigins);
1294
+ yield* approvalStore.putSubscription(subscription, who);
1295
+ return { subscribed: true };
1296
+ });
1297
+ await respond(response, effect, 201);
1298
+ return;
1299
+ }
1300
+ if (mode === "serve" &&
1301
+ request.method === "DELETE" &&
1302
+ url.pathname === "/v1/push/subscriptions") {
1303
+ const effect = Effect.gen(function* () {
1304
+ const who = yield* authorized;
1305
+ yield* sameOrigin(request, expectedOrigin());
1306
+ const removal = yield* readJson(request, PushSubscriptionRemoval);
1307
+ yield* approvalStore.deleteOwnedSubscription(removal.endpoint, who);
1308
+ return { subscribed: false };
1309
+ });
1310
+ await respond(response, effect);
1311
+ return;
1312
+ }
1313
+ if (mode === "serve" &&
1314
+ request.method === "GET" &&
1315
+ url.pathname === "/v1/chat") {
1316
+ await respond(response, Effect.andThen(authorized, chat.history()));
1317
+ return;
1318
+ }
1319
+ if (mode === "serve" &&
1320
+ request.method === "POST" &&
1321
+ url.pathname === "/v1/chat") {
1322
+ const effect = Effect.gen(function* () {
1323
+ const who = yield* authorized;
1324
+ yield* sameOrigin(request, expectedOrigin());
1325
+ const input = yield* readJson(request, ChatRequest);
1326
+ const submitted = yield* chat.submit(input, who);
1327
+ if (submitted.queued)
1328
+ yield* enqueueJob(submitted.jobId);
1329
+ return submitted.entry;
1330
+ });
1331
+ await respond(response, effect, 202);
1332
+ return;
1333
+ }
1334
+ if (approvalSurface &&
1335
+ request.method === "GET" &&
1336
+ url.pathname === "/v1/dashboard-pending") {
1337
+ const effect = Effect.gen(function* () {
1338
+ yield* authorized;
1339
+ const continuation = yield* decodePendingApprovalContinuation(url);
1340
+ return yield* dashboardPendingPage(config, service, continuation);
1341
+ });
1342
+ await respond(response, effect);
1343
+ return;
1344
+ }
1345
+ if (mode === "tailnet" &&
1346
+ request.method === "GET" &&
1347
+ url.pathname === "/v1/pending-approvals") {
1348
+ const effect = Effect.gen(function* () {
1349
+ yield* authorized;
1350
+ const cursor = yield* decodePendingApprovalCursor(url);
1351
+ return yield* localPendingSummary(config.host, service, cursor);
1352
+ });
1353
+ await respond(response, effect);
1354
+ return;
1355
+ }
1356
+ if (mode === "tailnet" &&
1357
+ request.method === "GET" &&
1358
+ url.pathname === "/v1/connect/agents/local") {
1359
+ await respond(response, Effect.andThen(tailnetActor(request, config, [config.approvalHub.nodeId]), localConnectAgents(config, service, activityStore, relationshipStore, now()).pipe(Effect.provideService(Crypto.Crypto, cryptoService))));
1360
+ return;
1361
+ }
1362
+ if (mode === "serve" &&
1363
+ request.method === "GET" &&
1364
+ url.pathname === "/v1/connect/agents") {
1365
+ await respond(response, Effect.andThen(authorized, Effect.gen(function* () {
1366
+ const cursor = yield* decodeConnectAgentCursor(url);
1367
+ const peers = yield* fleetPeers(config);
1368
+ const directory = yield* fleetConnectAgents(localConnectAgents(config, service, activityStore, relationshipStore, now()).pipe(Effect.provideService(Crypto.Crypto, cryptoService)), peers.map((peer) => ({
1369
+ agentsUrl: peer.connectAgentsUrl,
1370
+ host: peer.host,
1371
+ online: peer.online,
1372
+ terminalUrl: peer.terminalUrl
1373
+ })));
1374
+ return yield* pageFleetConnectAgents(directory, cursor);
1375
+ })));
1376
+ return;
1377
+ }
1378
+ if (!approvalSurface &&
1379
+ request.method === "GET" &&
1380
+ url.pathname === "/v1/status") {
1381
+ await respond(response, Effect.andThen(authorized, service.status()));
1382
+ return;
1383
+ }
1384
+ if (!approvalSurface &&
1385
+ request.method === "GET" &&
1386
+ url.pathname === "/v1/history") {
1387
+ const rawLimit = Number(url.searchParams.get("limit") ?? "50");
1388
+ const limit = Number.isInteger(rawLimit)
1389
+ ? Math.min(Math.max(rawLimit, 1), 500)
1390
+ : 50;
1391
+ const effect = Effect.gen(function* () {
1392
+ yield* authorized;
1393
+ const cursor = yield* decodePendingApprovalCursor(url);
1394
+ return yield* service.historyPage(cursor, limit);
1395
+ });
1396
+ await respond(response, effect);
1397
+ return;
1398
+ }
1399
+ const jobMatch = /^\/v1\/jobs\/([^/]+)$/.exec(url.pathname);
1400
+ const jobIdSegment = jobMatch?.[1];
1401
+ if (!approvalSurface &&
1402
+ request.method === "GET" &&
1403
+ jobIdSegment !== undefined) {
1404
+ await respond(response, Effect.gen(function* () {
1405
+ yield* authorized;
1406
+ const jobId = yield* decodeJobPathSegment(jobIdSegment);
1407
+ return yield* service.get(jobId);
1408
+ }));
1409
+ return;
1410
+ }
1411
+ if (!approvalSurface &&
1412
+ request.method === "POST" &&
1413
+ url.pathname === "/v1/jobs") {
1414
+ const effect = Effect.gen(function* () {
1415
+ const who = yield* authorized;
1416
+ yield* authorizeOriginlessMutation(request);
1417
+ const input = yield* readJson(request, JobRequest);
1418
+ const record = yield* service.submit(input, who);
1419
+ if (record.status === "queued")
1420
+ yield* enqueueJob(record.id);
1421
+ return record;
1422
+ });
1423
+ await respond(response, effect, 202);
1424
+ return;
1425
+ }
1426
+ const approvalMatch = /^\/v1\/jobs\/([^/]+)\/(approve|reject)$/.exec(url.pathname);
1427
+ const approvalJobId = approvalMatch?.[1];
1428
+ const decision = approvalMatch?.[2];
1429
+ if (approvalSurface &&
1430
+ request.method === "POST" &&
1431
+ approvalJobId !== undefined &&
1432
+ (decision === "approve" || decision === "reject")) {
1433
+ const effect = Effect.gen(function* () {
1434
+ const who = yield* authorized;
1435
+ yield* sameOrigin(request, expectedOrigin());
1436
+ const jobId = yield* decodeJobPathSegment(approvalJobId);
1437
+ const approval = yield* readApproval(request);
1438
+ const record = decision === "approve"
1439
+ ? yield* service.approve(jobId, approval, who)
1440
+ : yield* service.reject(jobId, approval, who);
1441
+ if (record.status === "queued")
1442
+ yield* enqueueJob(record.id);
1443
+ return record;
1444
+ });
1445
+ if (header(request, "content-type")?.startsWith("application/x-www-form-urlencoded") === true) {
1446
+ const result = await runRequest(Effect.result(effect));
1447
+ if (Result.isFailure(result)) {
1448
+ const mapped = apiError(result.failure);
1449
+ json(response, mapped.status, mapped.body);
1450
+ }
1451
+ else {
1452
+ response.writeHead(303, { location: "/" });
1453
+ response.end();
1454
+ }
1455
+ return;
1456
+ }
1457
+ await respond(response, effect);
1458
+ return;
1459
+ }
1460
+ if (request.method === "GET" && url.pathname === "/") {
1461
+ const result = await runRequest(Effect.result(Effect.andThen(authorized, dashboard)));
1462
+ if (Result.isFailure(result)) {
1463
+ const mapped = apiError(result.failure);
1464
+ json(response, mapped.status, mapped.body);
1465
+ return;
1466
+ }
1467
+ response.writeHead(200, {
1468
+ "cache-control": "no-cache, must-revalidate",
1469
+ "content-security-policy": "frame-ancestors 'none'",
1470
+ "content-type": "text/html; charset=utf-8",
1471
+ "x-frame-options": "DENY"
1472
+ });
1473
+ response.end(dashboardPage(result.success));
1474
+ return;
1475
+ }
1476
+ if (mode === "serve" &&
1477
+ request.method === "GET" &&
1478
+ url.pathname === "/connect/") {
1479
+ const result = await runRequest(Effect.result(authorized));
1480
+ if (Result.isFailure(result)) {
1481
+ const mapped = apiError(result.failure);
1482
+ json(response, mapped.status, mapped.body);
1483
+ return;
1484
+ }
1485
+ response.writeHead(200, {
1486
+ "cache-control": "no-cache, must-revalidate",
1487
+ "content-security-policy": "frame-ancestors 'none'",
1488
+ "content-type": "text/html; charset=utf-8",
1489
+ "x-frame-options": "DENY"
1490
+ });
1491
+ response.end(connectPage());
1492
+ return;
1493
+ }
1494
+ json(response, 404, { error: "not_found" });
1495
+ }
1496
+ catch (error) {
1497
+ if (closed || response.destroyed)
1498
+ return;
1499
+ json(response, 500, { error: "internal", detail: String(error) });
1500
+ }
1501
+ };
1502
+ const server = tls === null
1503
+ ? createServer(requestHandler)
1504
+ : await httpRuntime.runPromise(Effect.try({
1505
+ try: () => createSecureServer({ cert: tls.certificate, key: tls.privateKey }, requestHandler),
1506
+ catch: (cause) => new FleetOperationError({
1507
+ cause,
1508
+ detail: "approval TLS certificate or private key is invalid",
1509
+ operation: "hostd.approval_tls.create_server"
1510
+ })
1511
+ }));
1512
+ server.on("connection", (connection) => {
1513
+ activeHttpSockets.add(connection);
1514
+ connection.once("close", () => activeHttpSockets.delete(connection));
1515
+ });
1516
+ server.on("upgrade", async (request, socket, head) => {
1517
+ const url = pathOf(request);
1518
+ const isServeTerminal = mode === "serve" && url.pathname === "/v1/connect/session";
1519
+ const isTailnetTerminal = mode === "tailnet" && url.pathname === "/v1/connect/terminal";
1520
+ if (!isServeTerminal && !isTailnetTerminal) {
1521
+ rejectUpgrade(socket, 404);
1522
+ return;
1523
+ }
1524
+ if (header(request, "host")?.toLowerCase() !== expectedHost) {
1525
+ rejectUpgrade(socket, 403);
1526
+ return;
1527
+ }
1528
+ if (!acceptingRequests) {
1529
+ rejectUpgrade(socket, 503);
1530
+ return;
1531
+ }
1532
+ const access = Effect.gen(function* () {
1533
+ if (isServeTerminal) {
1534
+ yield* tailnetActor(request, config, null);
1535
+ yield* sameOrigin(request, expectedOrigin());
1536
+ }
1537
+ else {
1538
+ yield* tailnetActor(request, config, [config.approvalHub.nodeId]);
1539
+ }
1540
+ return yield* terminalSelectionFromUrl(url);
1541
+ });
1542
+ const result = await httpRuntime.runPromise(Effect.result(access)).catch(() => null);
1543
+ if (result === null) {
1544
+ socket.destroy();
1545
+ return;
1546
+ }
1547
+ if (Result.isFailure(result)) {
1548
+ rejectUpgrade(socket, 403);
1549
+ return;
1550
+ }
1551
+ if (closed || !acceptingRequests) {
1552
+ rejectUpgrade(socket, 503);
1553
+ return;
1554
+ }
1555
+ webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
1556
+ watchSocket(webSocket);
1557
+ webSockets.add(webSocket);
1558
+ webSocket.once("close", () => webSockets.delete(webSocket));
1559
+ const task = isTailnetTerminal ||
1560
+ result.success.host.toLowerCase() === config.host.toLowerCase()
1561
+ ? attachTerminal(webSocket, result.success)
1562
+ : proxyTerminal(webSocket, result.success);
1563
+ terminalTasks.add(task);
1564
+ void task.then(() => terminalTasks.delete(task), () => {
1565
+ terminalTasks.delete(task);
1566
+ closeSocket(webSocket, 4503, "terminal unavailable");
1567
+ });
1568
+ });
1569
+ });
1570
+ await new Promise((resolve, reject) => {
1571
+ server.once("error", reject);
1572
+ server.listen(port, address, resolve);
1573
+ });
1574
+ const decodedAddress = Schema.decodeUnknownResult(TcpAddress)(server.address());
1575
+ if (Result.isFailure(decodedAddress)) {
1576
+ throw new FleetOperationError({
1577
+ cause: decodedAddress.failure,
1578
+ detail: "hostd did not receive a TCP address",
1579
+ operation: "hostd.listen.address"
1580
+ });
1581
+ }
1582
+ const bound = decodedAddress.success;
1583
+ if (mode !== "serve") {
1584
+ expectedHost = listenerAuthority(bound.address, bound.port);
1585
+ }
1586
+ activeServers.add(server);
1587
+ return {
1588
+ server,
1589
+ url: `${tls === null ? "http" : "https"}://${bound.address}:${bound.port}`
1590
+ };
1591
+ };
1592
+ const tailscaleIp = config.crossHost
1593
+ ? await httpRuntime.runPromise(Effect.flatMap(Tailscale, (tailscale) => tailscale.ipv4))
1594
+ : null;
1595
+ if (tailscaleIp === "") {
1596
+ throw new FleetOperationError({
1597
+ cause: tailscaleIp,
1598
+ detail: "tailscale ip -4 returned no address",
1599
+ operation: "hostd.tailscale.ipv4"
1600
+ });
1601
+ }
1602
+ const recoveredJobIds = await Effect.runPromise(service.recover());
1603
+ const local = await listen("127.0.0.1", config.localPort, "local");
1604
+ if (tailscaleIp === null) {
1605
+ for (const jobId of recoveredJobIds)
1606
+ await Effect.runPromise(enqueueJob(jobId));
1607
+ acceptingRequests = true;
1608
+ return {
1609
+ url: local.url,
1610
+ tailnetUrl: null,
1611
+ approvalUrl: null,
1612
+ serveUrl: null,
1613
+ close: shutdown
1614
+ };
1615
+ }
1616
+ const remote = await listen(tailscaleIp, config.port, "tailnet");
1617
+ const approval = isHub
1618
+ ? null
1619
+ : await listen(tailscaleIp, config.approvalPort, "approval");
1620
+ const serve = isHub
1621
+ ? await listen(tailscaleIp, config.approvalPort, "serve", tlsCredentials)
1622
+ : null;
1623
+ for (const jobId of recoveredJobIds)
1624
+ await Effect.runPromise(enqueueJob(jobId));
1625
+ if (pushSender !== null) {
1626
+ await httpRuntime.runPromise(Effect.forkIn(makePushWorker({
1627
+ allowedPushOrigins: config.pushAllowedOrigins,
1628
+ allowedUsers: config.allowedUsers,
1629
+ loadCandidates: () => notificationCandidates(config, service),
1630
+ send: pushSender,
1631
+ store: approvalStore
1632
+ }), workerScope));
1633
+ }
1634
+ acceptingRequests = true;
1635
+ return {
1636
+ url: local.url,
1637
+ tailnetUrl: remote.url,
1638
+ approvalUrl: approval?.url ?? serve?.url ?? null,
1639
+ serveUrl: serve?.url ?? null,
1640
+ close: shutdown
1641
+ };
1642
+ }
1643
+ catch (error) {
1644
+ try {
1645
+ await shutdown();
1646
+ }
1647
+ catch (cleanupError) {
1648
+ throw new AggregateError([error, cleanupError], "hostd startup and cleanup both failed", { cause: cleanupError });
1649
+ }
1650
+ throw error;
1651
+ }
1652
+ };
1653
+ //# sourceMappingURL=http.js.map