@lelouchhe/webagent 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +105 -3
- package/dist/fonts/temml/Temml.woff2 +0 -0
- package/dist/index.html +64 -41
- package/dist/js/app.3OEVQXHK.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.IJM5DBCO.js +173 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.FCSSTUVY.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +54 -0
- package/dist/styles.00xfh3e6.css +1848 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +105 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge-event-config.js +29 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +149 -0
- package/lib/config.js +130 -9
- package/lib/daemon.js +175 -41
- package/lib/event-handler.js +209 -91
- package/lib/image-dimensions.js +64 -0
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/model-picker.js +17 -0
- package/lib/preflight.js +214 -0
- package/lib/push-service.js +297 -52
- package/lib/routes.js +1315 -145
- package/lib/server.js +149 -37
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +95 -0
- package/lib/store.js +636 -30
- package/lib/title-service.js +26 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +39 -4
- package/dist/js/app.2562YGRO.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClientRegistry — in-memory tracking of connected clients.
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-client metadata that survives SSE disconnect:
|
|
5
|
+
* - capabilities advertised by the client on /hello.
|
|
6
|
+
* - visible / active / visibleSince: identity-layer visibility state used
|
|
7
|
+
* by TTS dispatch (voice branch) and push suppression (main).
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle: clients call /hello on SSE connect (register) and POST
|
|
10
|
+
* /visibility on visibilitychange + 15s heartbeat. SSE disconnect does
|
|
11
|
+
* not remove a client — it stays in the registry until an explicit
|
|
12
|
+
* /goodbye or TTL eviction (caller's responsibility). This lets visibility
|
|
13
|
+
* state outlive transient drops.
|
|
14
|
+
*/
|
|
15
|
+
export class ClientRegistry {
|
|
16
|
+
clients = new Map();
|
|
17
|
+
visibilityTtlMs;
|
|
18
|
+
now;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
|
|
21
|
+
this.now = options.now ?? Date.now;
|
|
22
|
+
}
|
|
23
|
+
register(id, data) {
|
|
24
|
+
const existing = this.clients.get(id);
|
|
25
|
+
if (existing) {
|
|
26
|
+
existing.capabilities = data.capabilities;
|
|
27
|
+
existing.lastSeen = this.now();
|
|
28
|
+
return existing;
|
|
29
|
+
}
|
|
30
|
+
const entry = {
|
|
31
|
+
id,
|
|
32
|
+
capabilities: data.capabilities,
|
|
33
|
+
visible: false,
|
|
34
|
+
active: null,
|
|
35
|
+
visibleSince: 0,
|
|
36
|
+
lastSeen: this.now(),
|
|
37
|
+
};
|
|
38
|
+
this.clients.set(id, entry);
|
|
39
|
+
return entry;
|
|
40
|
+
}
|
|
41
|
+
remove(id) {
|
|
42
|
+
this.clients.delete(id);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Atomic visibility setter. Mirrors PushService.updateClient semantics:
|
|
46
|
+
* - `visible` omitted = preserve; bool = set (and stamp/clear visibleSince).
|
|
47
|
+
* - `active` omitted = preserve; null = clear; string = replace.
|
|
48
|
+
* - Returns `becameVisibleFor=X` only on first transition into
|
|
49
|
+
* (visible:true, active:X) — heartbeat refreshes return null so
|
|
50
|
+
* callers can fire edge-triggered side effects exactly once.
|
|
51
|
+
* - Session-switch while visible (active X→Y) restarts the TTL clock
|
|
52
|
+
* even when the patch doesn't carry an explicit visible:true.
|
|
53
|
+
*
|
|
54
|
+
* No-op on unknown client.
|
|
55
|
+
*/
|
|
56
|
+
setVisibility(id, patch) {
|
|
57
|
+
const entry = this.clients.get(id);
|
|
58
|
+
if (!entry)
|
|
59
|
+
return { becameVisibleFor: null };
|
|
60
|
+
const wasVisibleForSession = entry.visible && entry.active != null ? entry.active : null;
|
|
61
|
+
if (patch.visible !== undefined) {
|
|
62
|
+
entry.visible = patch.visible;
|
|
63
|
+
entry.visibleSince = patch.visible ? this.now() : 0;
|
|
64
|
+
}
|
|
65
|
+
if (patch.active !== undefined) {
|
|
66
|
+
entry.active = patch.active;
|
|
67
|
+
}
|
|
68
|
+
const becameVisibleFor = entry.visible &&
|
|
69
|
+
entry.active != null &&
|
|
70
|
+
entry.active !== wasVisibleForSession
|
|
71
|
+
? entry.active
|
|
72
|
+
: null;
|
|
73
|
+
if (becameVisibleFor) {
|
|
74
|
+
// Any transition into "visible + active=X" restarts TTL — including
|
|
75
|
+
// session-switches that arrive without an explicit visible:true.
|
|
76
|
+
entry.visibleSince = this.now();
|
|
77
|
+
}
|
|
78
|
+
entry.lastSeen = this.now();
|
|
79
|
+
return { becameVisibleFor };
|
|
80
|
+
}
|
|
81
|
+
/** Is this specific client currently visible & viewing `sessionId` & fresh? */
|
|
82
|
+
isVisibleForSession(id, sessionId) {
|
|
83
|
+
const entry = this.clients.get(id);
|
|
84
|
+
if (!entry)
|
|
85
|
+
return false;
|
|
86
|
+
if (!entry.visible)
|
|
87
|
+
return false;
|
|
88
|
+
if (entry.active !== sessionId)
|
|
89
|
+
return false;
|
|
90
|
+
if (this.now() - entry.visibleSince > this.visibilityTtlMs)
|
|
91
|
+
return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** Is at least one fresh visible client viewing `sessionId`? */
|
|
95
|
+
isSessionVisibleToAnyClient(sessionId) {
|
|
96
|
+
const now = this.now();
|
|
97
|
+
for (const e of this.clients.values()) {
|
|
98
|
+
if (!e.visible)
|
|
99
|
+
continue;
|
|
100
|
+
if (e.active !== sessionId)
|
|
101
|
+
continue;
|
|
102
|
+
if (now - e.visibleSince > this.visibilityTtlMs)
|
|
103
|
+
continue;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/** Is this specific client currently fresh-visible (any session)? */
|
|
109
|
+
isClientVisible(id) {
|
|
110
|
+
const entry = this.clients.get(id);
|
|
111
|
+
if (!entry)
|
|
112
|
+
return false;
|
|
113
|
+
if (!entry.visible)
|
|
114
|
+
return false;
|
|
115
|
+
if (this.now() - entry.visibleSince > this.visibilityTtlMs)
|
|
116
|
+
return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
/** Is at least one fresh visible client connected (any session)? */
|
|
120
|
+
hasAnyVisibleClient() {
|
|
121
|
+
const now = this.now();
|
|
122
|
+
for (const e of this.clients.values()) {
|
|
123
|
+
if (!e.visible)
|
|
124
|
+
continue;
|
|
125
|
+
if (now - e.visibleSince > this.visibilityTtlMs)
|
|
126
|
+
continue;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
updateCapabilities(id, caps) {
|
|
132
|
+
const entry = this.clients.get(id);
|
|
133
|
+
if (!entry)
|
|
134
|
+
return;
|
|
135
|
+
entry.capabilities = caps;
|
|
136
|
+
entry.lastSeen = this.now();
|
|
137
|
+
}
|
|
138
|
+
touch(id) {
|
|
139
|
+
const entry = this.clients.get(id);
|
|
140
|
+
if (entry)
|
|
141
|
+
entry.lastSeen = this.now();
|
|
142
|
+
}
|
|
143
|
+
get(id) {
|
|
144
|
+
return this.clients.get(id);
|
|
145
|
+
}
|
|
146
|
+
list() {
|
|
147
|
+
return Array.from(this.clients.values());
|
|
148
|
+
}
|
|
149
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -1,30 +1,144 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { parse as parseTOML } from "smol-toml";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
const ConfigSchema = z.object({
|
|
4
|
+
export const ConfigSchema = z.object({
|
|
5
5
|
port: z.number().int().positive().default(6800),
|
|
6
|
+
// Network interface to bind. Default "127.0.0.1" = loopback only
|
|
7
|
+
// (no LAN exposure). Set to "0.0.0.0" to listen on all IPv4
|
|
8
|
+
// interfaces, "::" for IPv6/dual-stack, or a specific NIC IP
|
|
9
|
+
// (e.g. "192.168.1.10") to bind one interface on a multi-homed
|
|
10
|
+
// host. Note: "localhost" works but resolves via DNS and may
|
|
11
|
+
// pick IPv6 (`::1`) over IPv4 — prefer the explicit IP form.
|
|
12
|
+
host: z.string().default("127.0.0.1"),
|
|
6
13
|
data_dir: z.string().default("data"),
|
|
7
14
|
default_cwd: z.string().default(process.cwd()),
|
|
8
15
|
public_dir: z.string().default("dist"),
|
|
9
|
-
agent_cmd: z.string().default("
|
|
10
|
-
limits: z
|
|
16
|
+
agent_cmd: z.string().default("auto"),
|
|
17
|
+
limits: z
|
|
18
|
+
.object({
|
|
11
19
|
bash_output: z.number().int().positive().default(1_048_576), // 1 MB
|
|
12
20
|
image_upload: z.number().int().positive().default(10_485_760), // 10 MB
|
|
21
|
+
file_upload: z.number().int().positive().default(52_428_800), // 50 MB — non-image attachments
|
|
13
22
|
cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
|
|
14
23
|
recent_paths: z.number().int().nonnegative().default(10), // /new menu display limit; 0 = show all
|
|
15
24
|
recent_paths_ttl: z.number().int().nonnegative().default(30), // days before auto-cleanup; 0 = keep forever
|
|
16
|
-
})
|
|
25
|
+
})
|
|
26
|
+
.default({
|
|
17
27
|
bash_output: 1_048_576,
|
|
18
28
|
image_upload: 10_485_760,
|
|
29
|
+
file_upload: 52_428_800,
|
|
19
30
|
cancel_timeout: 10_000,
|
|
20
31
|
recent_paths: 10,
|
|
21
32
|
recent_paths_ttl: 30,
|
|
22
33
|
}),
|
|
23
|
-
push: z
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
push: z
|
|
35
|
+
.object({
|
|
36
|
+
vapid_subject: z.string().default("mailto:noreply@example.com"),
|
|
37
|
+
global_visibility_suppression: z.boolean().default(true),
|
|
38
|
+
})
|
|
39
|
+
.default({
|
|
40
|
+
vapid_subject: "mailto:noreply@example.com",
|
|
41
|
+
global_visibility_suppression: true,
|
|
27
42
|
}),
|
|
43
|
+
// [title] — title generation sub-session configuration.
|
|
44
|
+
//
|
|
45
|
+
// `models` is an array of case-insensitive substring patterns. When the
|
|
46
|
+
// title sub-session is created, we look at the model list the agent
|
|
47
|
+
// reports (ACP `availableModels`) and pick the first model whose id
|
|
48
|
+
// matches any pattern in order. Match → call `setConfigOption` with
|
|
49
|
+
// that model id; no match → skip the call and inherit the agent's
|
|
50
|
+
// default model (`currentModelId`).
|
|
51
|
+
//
|
|
52
|
+
// Default list targets the cheap/fast tier across major providers:
|
|
53
|
+
// - "haiku" → Anthropic (claude-haiku-*)
|
|
54
|
+
// - "flash-lite" → Google Gemini (gemini-*-flash-lite) [must precede "flash"]
|
|
55
|
+
// - "nano" → OpenAI (gpt-*-nano), Gemini Nano
|
|
56
|
+
// - "mini" → OpenAI (gpt-*-mini, 4o-mini), Mistral
|
|
57
|
+
// - "flash" → Google Gemini (gemini-*-flash)
|
|
58
|
+
// - "lite" → Cohere, generic
|
|
59
|
+
//
|
|
60
|
+
// Set `models = []` to disable substring matching entirely and always
|
|
61
|
+
// inherit the agent's default model. To pin one specific model, pass a
|
|
62
|
+
// single-element array: `models = ["claude-haiku-4.5"]`.
|
|
63
|
+
title: z
|
|
64
|
+
.object({
|
|
65
|
+
models: z
|
|
66
|
+
.array(z.string())
|
|
67
|
+
.default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
|
|
68
|
+
})
|
|
69
|
+
.default({
|
|
70
|
+
models: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
|
|
71
|
+
}),
|
|
72
|
+
// [debug] — frontend log level.
|
|
73
|
+
// level ∈ off | debug | info | warn | error. Default "off".
|
|
74
|
+
// Users can override per page-load via `?debug=<level>` in the URL,
|
|
75
|
+
// or at runtime via the /log slash command.
|
|
76
|
+
debug: z
|
|
77
|
+
.object({
|
|
78
|
+
level: z.enum(["off", "debug", "info", "warn", "error"]).default("off"),
|
|
79
|
+
})
|
|
80
|
+
.default({ level: "off" }),
|
|
81
|
+
// [messages] — external notifications primitive.
|
|
82
|
+
// `unprocessed_ttl_days` caps how long an unbound message stays in the
|
|
83
|
+
// inbox before TTL cleanup removes it. 0 = keep forever.
|
|
84
|
+
messages: z
|
|
85
|
+
.object({
|
|
86
|
+
unprocessed_ttl_days: z.number().int().nonnegative().default(30),
|
|
87
|
+
})
|
|
88
|
+
.default({ unprocessed_ttl_days: 30 }),
|
|
89
|
+
// [share] — public read-only session share links.
|
|
90
|
+
// Default: disabled. Dogfood manually flips `enabled = true` after
|
|
91
|
+
// CF Access bypass + Rate Limiting are configured. See docs/share.md.
|
|
92
|
+
// enabled — master kill switch; when false, all share routes
|
|
93
|
+
// return 410 and slash commands are hidden.
|
|
94
|
+
// ttl_hours — global default TTL for public share links. 0 =
|
|
95
|
+
// never expire (default). >0 is clamped to 168 (7d).
|
|
96
|
+
// Per-share override via `shares.ttl_hours` column.
|
|
97
|
+
// csp_enforce — true (default) emits Content-Security-Policy on
|
|
98
|
+
// /s/* and /api/v1/shared/* routes. false emits
|
|
99
|
+
// Content-Security-Policy-Report-Only for rollback.
|
|
100
|
+
// viewer_origin — public viewer URL host; empty string = same as
|
|
101
|
+
// webagent host (default). Useful if viewer is
|
|
102
|
+
// behind a different CF Worker route (e.g.
|
|
103
|
+
// "https://share.example.com").
|
|
104
|
+
// internal_hosts — sanitizer internal-domain allowlist; any token
|
|
105
|
+
// matching these substrings gets rewritten to
|
|
106
|
+
// `<internal-host>` before publishing.
|
|
107
|
+
share: z
|
|
108
|
+
.object({
|
|
109
|
+
enabled: z.boolean().default(false),
|
|
110
|
+
ttl_hours: z.number().int().nonnegative().default(0),
|
|
111
|
+
csp_enforce: z.boolean().default(true),
|
|
112
|
+
viewer_origin: z.string().default(""),
|
|
113
|
+
internal_hosts: z.array(z.string()).default([]),
|
|
114
|
+
})
|
|
115
|
+
.default({
|
|
116
|
+
enabled: false,
|
|
117
|
+
ttl_hours: 0,
|
|
118
|
+
csp_enforce: true,
|
|
119
|
+
viewer_origin: "",
|
|
120
|
+
internal_hosts: [],
|
|
121
|
+
}),
|
|
122
|
+
// [auth] — bearer-token auth knobs.
|
|
123
|
+
// first_run_bootstrap controls the zero-config first-run UX:
|
|
124
|
+
// true (default) — when auth.json file does NOT exist AND stdin
|
|
125
|
+
// is a TTY, server auto-mints a one-time admin
|
|
126
|
+
// token and prints it as part of the startup-
|
|
127
|
+
// doctor stream. Operator copies the token from
|
|
128
|
+
// terminal scrollback and pastes it into the
|
|
129
|
+
// /login form.
|
|
130
|
+
// false — refuse to serve, print `--create-token` hint,
|
|
131
|
+
// exit 78. Use this when deploying behind a
|
|
132
|
+
// supervisor that provisions auth.json
|
|
133
|
+
// out-of-band (CI, Ansible, k8s init container).
|
|
134
|
+
// Either way: if auth.json exists but list is empty (deleted/parse-
|
|
135
|
+
// error), server still exits 78 — that's a config anomaly, not a
|
|
136
|
+
// fresh install.
|
|
137
|
+
auth: z
|
|
138
|
+
.object({
|
|
139
|
+
first_run_bootstrap: z.boolean().default(true),
|
|
140
|
+
})
|
|
141
|
+
.default({ first_run_bootstrap: true }),
|
|
28
142
|
});
|
|
29
143
|
let _config = null;
|
|
30
144
|
function parseArgs() {
|
|
@@ -35,7 +149,14 @@ function parseArgs() {
|
|
|
35
149
|
return null;
|
|
36
150
|
}
|
|
37
151
|
export function loadConfig() {
|
|
38
|
-
|
|
152
|
+
return loadConfigFromPath(parseArgs());
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Load + validate config from an explicit path (or defaults if null).
|
|
156
|
+
* Lets non-CLI callers (daemon parent) load the same effective config
|
|
157
|
+
* the server would, without needing to mutate process.argv.
|
|
158
|
+
*/
|
|
159
|
+
export function loadConfigFromPath(configPath) {
|
|
39
160
|
let raw = {};
|
|
40
161
|
if (configPath) {
|
|
41
162
|
try {
|
package/lib/daemon.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
6
|
+
import { loadConfigFromPath } from "./config.js";
|
|
7
|
+
import { runStartupChecks, STARTUP_CHECKED_ENV } from "./startup-checks.js";
|
|
5
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
9
|
// ---------------------------------------------------------------------------
|
|
7
10
|
// Constants
|
|
@@ -30,13 +33,15 @@ export function readPidInfo(filePath) {
|
|
|
30
33
|
try {
|
|
31
34
|
unlinkSync(filePath);
|
|
32
35
|
}
|
|
33
|
-
catch {
|
|
36
|
+
catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
34
39
|
return null;
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
/** Write PID info to `filePath`. */
|
|
38
43
|
export function writePidInfo(filePath, info) {
|
|
39
|
-
|
|
44
|
+
atomicWriteFileSync(filePath, JSON.stringify(info) + "\n");
|
|
40
45
|
}
|
|
41
46
|
// ---------------------------------------------------------------------------
|
|
42
47
|
// Arg helpers
|
|
@@ -48,35 +53,116 @@ export function isSubcommand(arg) {
|
|
|
48
53
|
export function resolveArgs(args) {
|
|
49
54
|
const result = [...args];
|
|
50
55
|
for (let i = 0; i < result.length; i++) {
|
|
51
|
-
if (result[i] === "--config" &&
|
|
56
|
+
if (result[i] === "--config" &&
|
|
57
|
+
i + 1 < result.length &&
|
|
58
|
+
!isAbsolute(result[i + 1])) {
|
|
52
59
|
result[i + 1] = resolve(result[i + 1]);
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
return result;
|
|
56
63
|
}
|
|
57
64
|
// ---------------------------------------------------------------------------
|
|
65
|
+
// Config-path extraction for parent-side checks
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Pull the `--config <path>` value out of the daemon's argv (resolving
|
|
69
|
+
* relative paths against cwd). Used by `cmdStart` to load the same
|
|
70
|
+
* config the server child would, so the parent process can run the
|
|
71
|
+
* unified startup checks (preflight + auth bootstrap) in the operator's
|
|
72
|
+
* TTY before forking. Returns null if no `--config` was passed.
|
|
73
|
+
*/
|
|
74
|
+
export function extractConfigPath(args, cwd) {
|
|
75
|
+
const idx = args.indexOf("--config");
|
|
76
|
+
if (idx < 0 || idx + 1 >= args.length)
|
|
77
|
+
return null;
|
|
78
|
+
const v = args[idx + 1];
|
|
79
|
+
return isAbsolute(v) ? v : resolve(cwd, v);
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Restart decision (pure)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
/**
|
|
85
|
+
* Sysexits.h EX_CONFIG. Server exits 78 when configuration is bad
|
|
86
|
+
* (missing auth.json + non-TTY, preflight failures, etc.). Restarting
|
|
87
|
+
* cannot fix configuration — supervisor must surface and stop.
|
|
88
|
+
*/
|
|
89
|
+
const EX_CONFIG = 78;
|
|
90
|
+
/**
|
|
91
|
+
* Pure decision: should the supervisor restart the child, and with what
|
|
92
|
+
* delay? Side effects (logging, scheduling) live in `runSupervisor`.
|
|
93
|
+
*/
|
|
94
|
+
export function decideRestart(code, _signal, ctx) {
|
|
95
|
+
if (ctx.stopping) {
|
|
96
|
+
return { kind: "stop", reason: "supervisor shutting down" };
|
|
97
|
+
}
|
|
98
|
+
if (code === EX_CONFIG) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "stop",
|
|
101
|
+
reason: `child exited with EX_CONFIG (${EX_CONFIG}) — not restarting`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const stable = ctx.now - ctx.lastStart > STABLE_THRESHOLD_MS;
|
|
105
|
+
const delay = stable
|
|
106
|
+
? RESTART_DELAY_INITIAL
|
|
107
|
+
: Math.min(ctx.currentDelay * 2, RESTART_DELAY_MAX);
|
|
108
|
+
return { kind: "restart", delayMs: delay };
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// PID file location
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
//
|
|
114
|
+
// PID file lives in `data_dir`, NOT cwd. This lets multiple instances
|
|
115
|
+
// (e.g. one per agent / port) coexist on the same machine launched from
|
|
116
|
+
// the same shell — each `--config` points at its own data_dir, so each
|
|
117
|
+
// gets its own pid file. The log file stays in cwd (operator-facing).
|
|
118
|
+
function loadDaemonContext(args, cwd) {
|
|
119
|
+
const cfgPath = extractConfigPath(args, cwd);
|
|
120
|
+
const config = loadConfigFromPath(cfgPath);
|
|
121
|
+
const dataDir = isAbsolute(config.data_dir)
|
|
122
|
+
? config.data_dir
|
|
123
|
+
: resolve(cwd, config.data_dir);
|
|
124
|
+
try {
|
|
125
|
+
mkdirSync(dataDir, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* best-effort — start will surface real errors via startup checks */
|
|
129
|
+
}
|
|
130
|
+
return { config, pidFile: join(dataDir, PID_FILE) };
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
58
133
|
// Command dispatch
|
|
59
134
|
// ---------------------------------------------------------------------------
|
|
60
135
|
export async function run(command, args) {
|
|
61
|
-
const pidFile =
|
|
136
|
+
const { config, pidFile } = loadDaemonContext(args, process.cwd());
|
|
62
137
|
const logFile = join(process.cwd(), LOG_FILE);
|
|
63
138
|
switch (command) {
|
|
64
|
-
case "start":
|
|
65
|
-
|
|
66
|
-
case "
|
|
67
|
-
|
|
139
|
+
case "start":
|
|
140
|
+
return cmdStart(pidFile, logFile, args, config);
|
|
141
|
+
case "stop":
|
|
142
|
+
return cmdStop(pidFile);
|
|
143
|
+
case "status":
|
|
144
|
+
return cmdStatus(pidFile, logFile);
|
|
145
|
+
case "restart":
|
|
146
|
+
return cmdRestart(pidFile, logFile);
|
|
68
147
|
}
|
|
69
148
|
}
|
|
70
149
|
// ---------------------------------------------------------------------------
|
|
71
150
|
// Commands
|
|
72
151
|
// ---------------------------------------------------------------------------
|
|
73
|
-
async function cmdStart(pidFile, logFile, args) {
|
|
152
|
+
async function cmdStart(pidFile, logFile, args, config) {
|
|
74
153
|
const existing = readPidInfo(pidFile);
|
|
75
154
|
if (existing) {
|
|
76
155
|
console.log(`webagent is already running (pid ${existing.pid})`);
|
|
77
156
|
process.exitCode = 1;
|
|
78
157
|
return;
|
|
79
158
|
}
|
|
159
|
+
// Run the unified startup gate in this (foreground) process before
|
|
160
|
+
// forking. This is the operator's TTY, so first-run mint banners
|
|
161
|
+
// and `[check]` failures land where they can actually be seen and
|
|
162
|
+
// copy-pasted, instead of being entombed in the daemon log file.
|
|
163
|
+
// Pass WEBAGENT_STARTUP_CHECKED=1 to the supervisor child so it (and
|
|
164
|
+
// the server it spawns) skip re-running the gate.
|
|
165
|
+
await runStartupChecks(config);
|
|
80
166
|
const serverJs = join(__dirname, "server.js");
|
|
81
167
|
if (!existsSync(serverJs)) {
|
|
82
168
|
console.error(`server not found: ${serverJs}`);
|
|
@@ -94,10 +180,18 @@ async function cmdStart(pidFile, logFile, args) {
|
|
|
94
180
|
writeFileSync(logFile, lines.slice(-LOG_MAX_LINES).join("\n"));
|
|
95
181
|
}
|
|
96
182
|
}
|
|
97
|
-
catch {
|
|
183
|
+
catch {
|
|
184
|
+
/* best-effort */
|
|
185
|
+
}
|
|
98
186
|
}
|
|
99
187
|
const log = openSync(logFile, "a");
|
|
100
|
-
const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], {
|
|
188
|
+
const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], {
|
|
189
|
+
detached: true,
|
|
190
|
+
stdio: ["ignore", log, log],
|
|
191
|
+
cwd: process.cwd(),
|
|
192
|
+
windowsHide: true,
|
|
193
|
+
env: { ...process.env, [STARTUP_CHECKED_ENV]: "1" },
|
|
194
|
+
});
|
|
101
195
|
child.unref();
|
|
102
196
|
closeSync(log);
|
|
103
197
|
// Poll for PID file (supervisor writes it on startup)
|
|
@@ -128,7 +222,9 @@ async function cmdStop(pidFile) {
|
|
|
128
222
|
try {
|
|
129
223
|
unlinkSync(pidFile);
|
|
130
224
|
}
|
|
131
|
-
catch {
|
|
225
|
+
catch {
|
|
226
|
+
/* ignore */
|
|
227
|
+
}
|
|
132
228
|
return;
|
|
133
229
|
}
|
|
134
230
|
// Wait for exit
|
|
@@ -143,7 +239,9 @@ async function cmdStop(pidFile) {
|
|
|
143
239
|
try {
|
|
144
240
|
unlinkSync(pidFile);
|
|
145
241
|
}
|
|
146
|
-
catch {
|
|
242
|
+
catch {
|
|
243
|
+
/* ignore */
|
|
244
|
+
}
|
|
147
245
|
console.log("webagent stopped");
|
|
148
246
|
return;
|
|
149
247
|
}
|
|
@@ -177,7 +275,8 @@ async function cmdRestart(pidFile, logFile) {
|
|
|
177
275
|
if (process.platform === "win32") {
|
|
178
276
|
// No SIGHUP on Windows — fall back to stop + start (non-atomic)
|
|
179
277
|
await cmdStop(pidFile);
|
|
180
|
-
|
|
278
|
+
const { config } = loadDaemonContext(info.args, process.cwd());
|
|
279
|
+
await cmdStart(pidFile, logFile, info.args, config);
|
|
181
280
|
return;
|
|
182
281
|
}
|
|
183
282
|
// Unix: atomic restart via SIGHUP to supervisor
|
|
@@ -206,8 +305,12 @@ async function cmdRestart(pidFile, logFile) {
|
|
|
206
305
|
// ---------------------------------------------------------------------------
|
|
207
306
|
function runSupervisor(serverArgs) {
|
|
208
307
|
const serverJs = join(__dirname, "server.js");
|
|
209
|
-
const pidFile =
|
|
210
|
-
writePidInfo(pidFile, {
|
|
308
|
+
const { pidFile } = loadDaemonContext(serverArgs, process.cwd());
|
|
309
|
+
writePidInfo(pidFile, {
|
|
310
|
+
pid: process.pid,
|
|
311
|
+
args: serverArgs,
|
|
312
|
+
started: new Date().toISOString(),
|
|
313
|
+
});
|
|
211
314
|
let child = null;
|
|
212
315
|
let stopping = false;
|
|
213
316
|
let lastStart = 0;
|
|
@@ -215,19 +318,33 @@ function runSupervisor(serverArgs) {
|
|
|
215
318
|
let timer = null;
|
|
216
319
|
function spawnServer() {
|
|
217
320
|
lastStart = Date.now();
|
|
218
|
-
child = spawn(process.execPath, [serverJs, ...serverArgs], {
|
|
321
|
+
child = spawn(process.execPath, [serverJs, ...serverArgs], {
|
|
322
|
+
stdio: "inherit",
|
|
323
|
+
windowsHide: true,
|
|
324
|
+
});
|
|
219
325
|
child.on("exit", onChildExit);
|
|
220
326
|
}
|
|
221
327
|
function onChildExit(code, signal) {
|
|
222
328
|
child = null;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
329
|
+
const action = decideRestart(code, signal, {
|
|
330
|
+
stopping,
|
|
331
|
+
lastStart,
|
|
332
|
+
now: Date.now(),
|
|
333
|
+
currentDelay: delay,
|
|
334
|
+
});
|
|
335
|
+
if (action.kind === "stop") {
|
|
336
|
+
if (stopping)
|
|
337
|
+
return;
|
|
338
|
+
console.error(`[supervisor] ${action.reason} (code=${code} signal=${signal})`);
|
|
339
|
+
try {
|
|
340
|
+
unlinkSync(pidFile);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* ignore */
|
|
344
|
+
}
|
|
345
|
+
process.exit(code ?? 1);
|
|
230
346
|
}
|
|
347
|
+
delay = action.delayMs;
|
|
231
348
|
console.log(`[supervisor] server exited (code=${code} signal=${signal}), restarting in ${delay}ms`);
|
|
232
349
|
timer = setTimeout(spawnServer, delay);
|
|
233
350
|
}
|
|
@@ -236,18 +353,27 @@ function runSupervisor(serverArgs) {
|
|
|
236
353
|
clearTimeout(timer);
|
|
237
354
|
timer = null;
|
|
238
355
|
}
|
|
239
|
-
return new Promise((
|
|
356
|
+
return new Promise((innerResolve) => {
|
|
240
357
|
if (!child) {
|
|
241
|
-
|
|
358
|
+
innerResolve();
|
|
242
359
|
return;
|
|
243
360
|
}
|
|
244
361
|
const c = child;
|
|
245
|
-
|
|
362
|
+
// Take ownership of this exit — don't let the auto-restart listener
|
|
363
|
+
// race with the explicit respawn (SIGHUP path) or shutdown.
|
|
364
|
+
c.removeListener("exit", onChildExit);
|
|
365
|
+
c.once("exit", () => {
|
|
366
|
+
innerResolve();
|
|
367
|
+
});
|
|
246
368
|
c.kill("SIGTERM");
|
|
247
|
-
setTimeout(() => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
369
|
+
setTimeout(() => {
|
|
370
|
+
try {
|
|
371
|
+
c.kill("SIGKILL");
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
/* ignore */
|
|
375
|
+
}
|
|
376
|
+
}, KILL_GRACE_MS);
|
|
251
377
|
});
|
|
252
378
|
}
|
|
253
379
|
async function shutdown() {
|
|
@@ -258,18 +384,26 @@ function runSupervisor(serverArgs) {
|
|
|
258
384
|
try {
|
|
259
385
|
unlinkSync(pidFile);
|
|
260
386
|
}
|
|
261
|
-
catch {
|
|
387
|
+
catch {
|
|
388
|
+
/* ignore */
|
|
389
|
+
}
|
|
262
390
|
process.exit(0);
|
|
263
391
|
}
|
|
264
|
-
process.on("SIGTERM", () => {
|
|
265
|
-
|
|
392
|
+
process.on("SIGTERM", () => {
|
|
393
|
+
void shutdown();
|
|
394
|
+
});
|
|
395
|
+
process.on("SIGINT", () => {
|
|
396
|
+
void shutdown();
|
|
397
|
+
});
|
|
266
398
|
if (process.platform !== "win32") {
|
|
267
|
-
process.on("SIGHUP",
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
399
|
+
process.on("SIGHUP", () => {
|
|
400
|
+
void (async () => {
|
|
401
|
+
console.log("[supervisor] SIGHUP received, restarting server");
|
|
402
|
+
delay = RESTART_DELAY_INITIAL;
|
|
403
|
+
await killChild();
|
|
404
|
+
if (!stopping)
|
|
405
|
+
spawnServer();
|
|
406
|
+
})();
|
|
273
407
|
});
|
|
274
408
|
}
|
|
275
409
|
console.log(`[supervisor] started (pid ${process.pid})`);
|