@lelouchhe/webagent 0.2.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -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 +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- 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/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1218 -144
- package/lib/server.js +159 -32
- 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 +94 -0
- package/lib/store.js +654 -24
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export class SanitizeError extends Error {
|
|
2
|
+
status = 400;
|
|
3
|
+
/** The event seq that triggered the hard-reject; surfaced to owner as event_id. */
|
|
4
|
+
event_id;
|
|
5
|
+
rule;
|
|
6
|
+
constructor(event_id, rule, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.event_id = event_id;
|
|
9
|
+
this.rule = rule;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// --- Layer 1c: hard-reject patterns ---
|
|
13
|
+
//
|
|
14
|
+
// Order matters only for error messages; any match aborts. Patterns are
|
|
15
|
+
// designed to be low-false-positive (we're looking for obvious leakage,
|
|
16
|
+
// not heuristic secrets — those belong to Layer 1b).
|
|
17
|
+
// Rule set aims at well-known token prefixes that would not normally
|
|
18
|
+
// appear in shared agent output. Every entry has a fixed prefix and a
|
|
19
|
+
// minimum body length — keeps false positives low (a README that
|
|
20
|
+
// mentions "ghp_" without a body passes; a real token does not).
|
|
21
|
+
// Add new entries here; share-sanitize-secrets.test.ts enumerates each.
|
|
22
|
+
const HARD_REJECT_RULES = [
|
|
23
|
+
{
|
|
24
|
+
id: "private_key",
|
|
25
|
+
// Matches OpenSSH, RSA, EC, DSA, encrypted, and PKCS8 ("BEGIN PRIVATE KEY").
|
|
26
|
+
// PGP has a different armor shape ("... BLOCK-----") — covered by pgp_private_key below.
|
|
27
|
+
pattern: /-----BEGIN (?:OPENSSH |RSA |EC |DSA |ENCRYPTED |)PRIVATE KEY-----/,
|
|
28
|
+
msg: "private key detected",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "pgp_private_key",
|
|
32
|
+
pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----/,
|
|
33
|
+
msg: "PGP private key detected",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "github_pat",
|
|
37
|
+
pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/,
|
|
38
|
+
msg: "GitHub PAT detected",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "github_ghp",
|
|
42
|
+
pattern: /\bghp_[A-Za-z0-9]{20,}\b/,
|
|
43
|
+
msg: "GitHub classic token detected",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "github_oauth",
|
|
47
|
+
// gho_/ghu_/ghs_/ghr_ — oauth + user-to-server + server-to-server + refresh.
|
|
48
|
+
pattern: /\bgh[oursw]_[A-Za-z0-9]{20,}\b/,
|
|
49
|
+
msg: "GitHub OAuth/app token detected",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "anthropic_api",
|
|
53
|
+
pattern: /\bsk-ant-(?:api|sid)[0-9]{2}-[A-Za-z0-9_-]{32,}\b/,
|
|
54
|
+
msg: "Anthropic API key detected",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "openai_api",
|
|
58
|
+
pattern: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{32,}\b/,
|
|
59
|
+
msg: "OpenAI API key detected",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "slack_token",
|
|
63
|
+
pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/,
|
|
64
|
+
msg: "Slack token detected",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "google_api",
|
|
68
|
+
pattern: /\bAIza[A-Za-z0-9_-]{35,}\b/,
|
|
69
|
+
msg: "Google API key detected",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "stripe_key",
|
|
73
|
+
pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{24,}\b/,
|
|
74
|
+
msg: "Stripe live key detected",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "aws_secret",
|
|
78
|
+
pattern: /aws_secret_access_key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{20,}/i,
|
|
79
|
+
msg: "AWS secret access key detected",
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* Escape a string for safe inclusion in a RegExp. Needed for the homedir
|
|
84
|
+
* / cwd rewrite where paths may contain regex metachars (mac paths don't
|
|
85
|
+
* in practice, but defense in depth).
|
|
86
|
+
*/
|
|
87
|
+
function escapeRegExp(s) {
|
|
88
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Layer 1a: structured rewrite on a single string.
|
|
92
|
+
*
|
|
93
|
+
* Runs homedir/cwd first (longer match wins), then internal_hosts. The
|
|
94
|
+
* order is important — cwd often starts with homedir, and rewriting
|
|
95
|
+
* homedir first would leave "<home>/rest/of/cwd" un-collapsed. We
|
|
96
|
+
* rewrite cwd before homedir to catch that.
|
|
97
|
+
*/
|
|
98
|
+
function rewriteStructured(s, cwd, homeDir, internalHosts) {
|
|
99
|
+
let out = s;
|
|
100
|
+
// cwd first (usually longer / more specific than homedir)
|
|
101
|
+
if (cwd)
|
|
102
|
+
out = out.replace(new RegExp(escapeRegExp(cwd), "g"), "<cwd>");
|
|
103
|
+
if (homeDir)
|
|
104
|
+
out = out.replace(new RegExp(escapeRegExp(homeDir), "g"), "<home>");
|
|
105
|
+
for (const host of internalHosts) {
|
|
106
|
+
if (!host)
|
|
107
|
+
continue;
|
|
108
|
+
out = out.replace(new RegExp(escapeRegExp(host), "g"), "<internal-host>");
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/** Hard-reject scan on a string. Throws SanitizeError on first match. */
|
|
113
|
+
function assertNoHardRejects(seq, s) {
|
|
114
|
+
for (const rule of HARD_REJECT_RULES) {
|
|
115
|
+
if (rule.pattern.test(s)) {
|
|
116
|
+
throw new SanitizeError(seq, rule.id, rule.msg);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Sanitize a single StoredEvent. Rewrite applied in-place on a cloned
|
|
122
|
+
* JSON shape; hard-reject check runs on the stringified raw form so
|
|
123
|
+
* nested fields are all scanned without a schema enumeration.
|
|
124
|
+
*/
|
|
125
|
+
function sanitizeEvent(event, cwd, homeDir, internalHosts) {
|
|
126
|
+
// Normalize: if .data is a JSON string (StoredEvent), parse it.
|
|
127
|
+
let parsedData;
|
|
128
|
+
if (typeof event.data === "string") {
|
|
129
|
+
try {
|
|
130
|
+
parsedData = JSON.parse(event.data);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
parsedData = {};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
parsedData = event.data;
|
|
138
|
+
}
|
|
139
|
+
// Hard-reject scan runs on pre-rewrite stringified form so the rules
|
|
140
|
+
// see the actual payload rather than the "<home>"-scrubbed version.
|
|
141
|
+
const raw = JSON.stringify(parsedData);
|
|
142
|
+
assertNoHardRejects(event.seq, raw);
|
|
143
|
+
const rewrittenData = deepRewriteStrings(parsedData, (s) => rewriteStructured(s, cwd, homeDir, internalHosts));
|
|
144
|
+
return {
|
|
145
|
+
id: "id" in event ? event.id : undefined,
|
|
146
|
+
session_id: "session_id" in event ? event.session_id : undefined,
|
|
147
|
+
seq: event.seq,
|
|
148
|
+
type: event.type,
|
|
149
|
+
data: rewrittenData,
|
|
150
|
+
created_at: "created_at" in event ? event.created_at : undefined,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function deepRewriteStrings(value, rewrite) {
|
|
154
|
+
if (typeof value === "string")
|
|
155
|
+
return rewrite(value);
|
|
156
|
+
if (Array.isArray(value))
|
|
157
|
+
return value.map((v) => deepRewriteStrings(v, rewrite));
|
|
158
|
+
if (value && typeof value === "object") {
|
|
159
|
+
const out = {};
|
|
160
|
+
for (const [k, v] of Object.entries(value)) {
|
|
161
|
+
out[k] = deepRewriteStrings(v, rewrite);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Main entry — sanitize a batch of events for share output.
|
|
169
|
+
*
|
|
170
|
+
* Throws SanitizeError with event_id on Layer-1c hard-reject (owner gets
|
|
171
|
+
* 4xx + event_id so they can jump to the offending event).
|
|
172
|
+
*/
|
|
173
|
+
export function sanitizeEventsForShare(input) {
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const ev of input.events) {
|
|
176
|
+
out.push(sanitizeEvent(ev, input.cwd, input.homeDir, input.internalHosts));
|
|
177
|
+
}
|
|
178
|
+
return { events: out, flags: [] };
|
|
179
|
+
}
|
package/lib/sse-manager.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { reSignAttachmentUrlsInJson } from "./auth.js";
|
|
3
|
+
import { enrichEventForDisplay } from "./attachment-labels.js";
|
|
4
|
+
/**
|
|
5
|
+
* SSE heartbeat frame — a NAMED event so the frontend can hook
|
|
6
|
+
* `es.addEventListener("heartbeat", ...)` and refresh its per-session
|
|
7
|
+
* visibility record on the server. Comment-line form (`: heartbeat\n\n`)
|
|
8
|
+
* is silently discarded by EventSource — no `onmessage` fires — which is
|
|
9
|
+
* why we use a named event instead. Riding the SSE connection's natural
|
|
10
|
+
* pulse means connection alive → server TTL stays fresh; connection dies
|
|
11
|
+
* → TTL expires the ghost automatically.
|
|
12
|
+
*/
|
|
13
|
+
const SSE_HEARTBEAT_FRAME = "event: heartbeat\ndata: {}\n\n";
|
|
2
14
|
/**
|
|
3
15
|
* Manages Server-Sent Event connections.
|
|
4
16
|
* Tracks connected clients, broadcasts events, handles cleanup.
|
|
@@ -8,21 +20,59 @@ export class SseManager {
|
|
|
8
20
|
heartbeatTimer = null;
|
|
9
21
|
heartbeatInterval;
|
|
10
22
|
onRemoveCallback = null;
|
|
11
|
-
|
|
23
|
+
isTokenRevoked = null;
|
|
24
|
+
attachmentSecret = null;
|
|
25
|
+
getLabelMap = null;
|
|
26
|
+
constructor(heartbeatMs = 15_000) {
|
|
12
27
|
this.heartbeatInterval = heartbeatMs;
|
|
13
28
|
}
|
|
14
29
|
/** Register a callback invoked when a client disconnects. */
|
|
15
30
|
onRemove(cb) {
|
|
16
31
|
this.onRemoveCallback = cb;
|
|
17
32
|
}
|
|
33
|
+
/** When set, every outgoing SSE message has its image URLs re-signed with
|
|
34
|
+
* a fresh exp/sig — required for stored events to render past the
|
|
35
|
+
* original 1h signature TTL. */
|
|
36
|
+
setAttachmentSecret(secret) {
|
|
37
|
+
this.attachmentSecret = secret;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Wire a label-map provider to enrich attachment uuid paths with
|
|
41
|
+
* user-friendly labels at egress (CLAUDE.md "Attachment label
|
|
42
|
+
* egress rewrite"). Applied per-event-session inside `sendEvent`,
|
|
43
|
+
* so both `broadcast()` and direct `sendEvent()` callers (e.g.
|
|
44
|
+
* SSE Last-Event-ID replay) get enriched output. DB still stores
|
|
45
|
+
* raw events.
|
|
46
|
+
*/
|
|
47
|
+
setLabelMapProvider(fn) {
|
|
48
|
+
this.getLabelMap = fn;
|
|
49
|
+
}
|
|
50
|
+
/** Install a revocation check called on every heartbeat. If it returns
|
|
51
|
+
* true the SSE connection is closed immediately (within one heartbeat
|
|
52
|
+
* interval, ≤15s by default). */
|
|
53
|
+
setRevocationCheck(check) {
|
|
54
|
+
this.isTokenRevoked = check;
|
|
55
|
+
}
|
|
18
56
|
/** Start the periodic heartbeat. Call once after construction. */
|
|
19
57
|
startHeartbeat() {
|
|
20
58
|
if (this.heartbeatTimer)
|
|
21
59
|
return;
|
|
22
60
|
this.heartbeatTimer = setInterval(() => {
|
|
23
61
|
for (const client of this.clients.values()) {
|
|
24
|
-
if (
|
|
25
|
-
|
|
62
|
+
if (client.res.writableEnded)
|
|
63
|
+
continue;
|
|
64
|
+
// Close any stream whose backing token has been revoked.
|
|
65
|
+
if (client.tokenName && this.isTokenRevoked?.(client.tokenName)) {
|
|
66
|
+
try {
|
|
67
|
+
client.res.end();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* already torn down */
|
|
71
|
+
}
|
|
72
|
+
this.remove(client.id);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
client.res.write(SSE_HEARTBEAT_FRAME);
|
|
26
76
|
}
|
|
27
77
|
}, this.heartbeatInterval);
|
|
28
78
|
this.heartbeatTimer.unref();
|
|
@@ -41,7 +91,23 @@ export class SseManager {
|
|
|
41
91
|
/** Register a new SSE client connection. */
|
|
42
92
|
add(client) {
|
|
43
93
|
this.clients.set(client.id, client);
|
|
44
|
-
client.res.on("close", () =>
|
|
94
|
+
client.res.on("close", () => {
|
|
95
|
+
this.remove(client.id);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Write a single heartbeat frame to the given client. Used right after
|
|
99
|
+
* the "connected" handshake so the frontend's heartbeat-driven
|
|
100
|
+
* /visibility refresh fires at T+0 and the server-side visibility TTL
|
|
101
|
+
* doesn't wait a full interval to reset after a reconnect. */
|
|
102
|
+
writeHeartbeat(client) {
|
|
103
|
+
if (client.res.writableEnded)
|
|
104
|
+
return;
|
|
105
|
+
try {
|
|
106
|
+
client.res.write(SSE_HEARTBEAT_FRAME);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// socket already dead; res.on("close") will clean up
|
|
110
|
+
}
|
|
45
111
|
}
|
|
46
112
|
/** Remove a client by ID. */
|
|
47
113
|
remove(id) {
|
|
@@ -52,11 +118,31 @@ export class SseManager {
|
|
|
52
118
|
sendEvent(client, event, seq) {
|
|
53
119
|
if (client.res.writableEnded)
|
|
54
120
|
return;
|
|
121
|
+
let outEvent = event;
|
|
122
|
+
if (this.getLabelMap) {
|
|
123
|
+
const sid = event.sessionId;
|
|
124
|
+
if (sid) {
|
|
125
|
+
const map = this.getLabelMap(sid);
|
|
126
|
+
if (map.size > 0)
|
|
127
|
+
outEvent = enrichEventForDisplay(event, map);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let data = JSON.stringify(outEvent);
|
|
131
|
+
if (this.attachmentSecret && data.includes("/attachments/")) {
|
|
132
|
+
data = reSignAttachmentUrlsInJson(data, this.attachmentSecret);
|
|
133
|
+
}
|
|
55
134
|
let msg = "";
|
|
56
135
|
if (seq != null)
|
|
57
136
|
msg += `id: ${seq}\n`;
|
|
58
|
-
msg += `data: ${
|
|
59
|
-
|
|
137
|
+
msg += `data: ${data}\n\n`;
|
|
138
|
+
try {
|
|
139
|
+
client.res.write(msg);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Socket torn down between writableEnded check and write.
|
|
143
|
+
// Drop the client so we stop writing to it on every broadcast.
|
|
144
|
+
this.remove(client.id);
|
|
145
|
+
}
|
|
60
146
|
}
|
|
61
147
|
/**
|
|
62
148
|
* Broadcast an event to all connected SSE clients.
|
|
@@ -64,10 +150,10 @@ export class SseManager {
|
|
|
64
150
|
*/
|
|
65
151
|
broadcast(event) {
|
|
66
152
|
const sessionId = event.sessionId;
|
|
67
|
-
|
|
153
|
+
const snapshot = [...this.clients.values()];
|
|
154
|
+
for (const client of snapshot) {
|
|
68
155
|
if (client.res.writableEnded)
|
|
69
156
|
continue;
|
|
70
|
-
// Global clients get everything; session clients only get matching events
|
|
71
157
|
if (client.sessionId && client.sessionId !== sessionId)
|
|
72
158
|
continue;
|
|
73
159
|
this.sendEvent(client, event);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { generateSseTicket } from "./tokens.js";
|
|
2
|
+
/**
|
|
3
|
+
* Short-lived single-use tickets that authenticate an SSE EventSource
|
|
4
|
+
* connection (which can't carry a Bearer header). Lifecycle:
|
|
5
|
+
* 1. Client POSTs /api/v1/sse-ticket with Bearer → mint() returns ticket.
|
|
6
|
+
* 2. Client opens EventSource(?ticket=...) → consume() validates + deletes.
|
|
7
|
+
* 3. After TTL (default 60s) any unused ticket is invalid; gc() purges.
|
|
8
|
+
*/
|
|
9
|
+
export class TicketStore {
|
|
10
|
+
tickets = new Map();
|
|
11
|
+
ttlMs;
|
|
12
|
+
now;
|
|
13
|
+
constructor(opts = {}) {
|
|
14
|
+
this.ttlMs = opts.ttlMs ?? 60_000;
|
|
15
|
+
this.now = opts.now ?? Date.now;
|
|
16
|
+
}
|
|
17
|
+
mint(principal) {
|
|
18
|
+
const ticket = generateSseTicket();
|
|
19
|
+
this.tickets.set(ticket, {
|
|
20
|
+
tokenName: principal.tokenName,
|
|
21
|
+
scope: principal.scope,
|
|
22
|
+
expiresAt: this.now() + this.ttlMs,
|
|
23
|
+
});
|
|
24
|
+
return ticket;
|
|
25
|
+
}
|
|
26
|
+
consume(ticket) {
|
|
27
|
+
const rec = this.tickets.get(ticket);
|
|
28
|
+
if (!rec)
|
|
29
|
+
return null;
|
|
30
|
+
this.tickets.delete(ticket);
|
|
31
|
+
if (rec.expiresAt <= this.now())
|
|
32
|
+
return null;
|
|
33
|
+
return { tokenName: rec.tokenName, scope: rec.scope };
|
|
34
|
+
}
|
|
35
|
+
gc() {
|
|
36
|
+
const cutoff = this.now();
|
|
37
|
+
for (const [k, v] of this.tickets) {
|
|
38
|
+
if (v.expiresAt <= cutoff)
|
|
39
|
+
this.tickets.delete(k);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
get size() {
|
|
43
|
+
return this.tickets.size;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Unified startup-time checks — preflight + auth bootstrap + first-run
|
|
2
|
+
// mint — shared by every launch path:
|
|
3
|
+
//
|
|
4
|
+
// 1. `webagent` (foreground via bin) → server.ts top-level
|
|
5
|
+
// 2. `node src/server.ts ...` (dev / source) → server.ts top-level
|
|
6
|
+
// 3. `webagent start` (daemon supervisor) → cmdStart parent process
|
|
7
|
+
//
|
|
8
|
+
// The daemon path runs these checks in the operator's foreground TTY
|
|
9
|
+
// *before* fork. On success, it sets `WEBAGENT_STARTUP_CHECKED=1` in
|
|
10
|
+
// the child env so the server skips re-running the checks (and avoids
|
|
11
|
+
// printing the same `[check]` lines twice). On failure, it never forks.
|
|
12
|
+
//
|
|
13
|
+
// Why this matters: under daemon mode the server child is detached
|
|
14
|
+
// with stdio piped to a log file. Without the parent-side gate, any
|
|
15
|
+
// failure (port busy, agent missing, no auth.json) would land in the
|
|
16
|
+
// log instead of the operator's terminal — exactly the "silent fork
|
|
17
|
+
// and die" UX we want to avoid. And the foreground first-run mint
|
|
18
|
+
// banner can't be shown by a TTY-less child either.
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { join as pathJoin } from "node:path";
|
|
21
|
+
import { runPreflight } from "./preflight.js";
|
|
22
|
+
import { AuthStore } from "./auth-store.js";
|
|
23
|
+
import { decideBootstrap, formatBootstrapBanner } from "./bootstrap.js";
|
|
24
|
+
import { log } from "./log.js";
|
|
25
|
+
/** Env var the daemon parent sets when handing off to its server child. */
|
|
26
|
+
export const STARTUP_CHECKED_ENV = "WEBAGENT_STARTUP_CHECKED";
|
|
27
|
+
/**
|
|
28
|
+
* Run the full startup gate. Prints `[check] <name>: <detail> ✓|✗`
|
|
29
|
+
* lines to stdout/stderr in the same style as preflight. Exits 78
|
|
30
|
+
* (sysexits.h EX_CONFIG) on any failure so the daemon supervisor
|
|
31
|
+
* stops its restart loop (see `decideRestart` in daemon.ts).
|
|
32
|
+
*
|
|
33
|
+
* If `WEBAGENT_STARTUP_CHECKED=1` is set in the env, returns
|
|
34
|
+
* immediately with the configured `agent_cmd` verbatim — assumes a
|
|
35
|
+
* parent process already ran the same checks for this server's data
|
|
36
|
+
* directory.
|
|
37
|
+
*/
|
|
38
|
+
export async function runStartupChecks(config) {
|
|
39
|
+
if (process.env[STARTUP_CHECKED_ENV] === "1") {
|
|
40
|
+
return { agentCmd: config.agent_cmd };
|
|
41
|
+
}
|
|
42
|
+
// 1. Preflight (node version, data_dir writable, agent resolvable, port free).
|
|
43
|
+
const preflight = await runPreflight({
|
|
44
|
+
data_dir: config.data_dir,
|
|
45
|
+
agent_cmd: config.agent_cmd,
|
|
46
|
+
port: config.port,
|
|
47
|
+
});
|
|
48
|
+
// 2. Auth bootstrap. Same `[check] auth: ...` style; first-run mint
|
|
49
|
+
// or refuse-to-serve land here.
|
|
50
|
+
const authJsonPath = pathJoin(config.data_dir, "auth.json");
|
|
51
|
+
const authStore = new AuthStore(authJsonPath);
|
|
52
|
+
await authStore.load();
|
|
53
|
+
const tokenCount = authStore.list().length;
|
|
54
|
+
const action = decideBootstrap({
|
|
55
|
+
authJsonExists: existsSync(authJsonPath),
|
|
56
|
+
tokenCount,
|
|
57
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
58
|
+
firstRunEnabled: config.auth.first_run_bootstrap,
|
|
59
|
+
});
|
|
60
|
+
if (action.kind === "exit-config") {
|
|
61
|
+
console.error(`[check] auth: no tokens in auth.json — refusing to serve ✗`);
|
|
62
|
+
console.error(` create one with: webagent --create-token <name>`);
|
|
63
|
+
console.error(` then start the server again (or send SIGHUP to the running process).`);
|
|
64
|
+
await authStore.close();
|
|
65
|
+
process.exit(78);
|
|
66
|
+
}
|
|
67
|
+
if (action.kind === "mint") {
|
|
68
|
+
try {
|
|
69
|
+
const created = await authStore.addToken("first-run", "admin");
|
|
70
|
+
console.log(`[check] auth: minted first-run admin token ✓`);
|
|
71
|
+
console.log(formatBootstrapBanner({
|
|
72
|
+
token: created.token,
|
|
73
|
+
port: config.port,
|
|
74
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
75
|
+
}));
|
|
76
|
+
log.scope("bootstrap").info("first-run admin token minted", {
|
|
77
|
+
name: created.record.name,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.error(`[check] auth: mint failed: ${String(err)} ✗`);
|
|
82
|
+
await authStore.close();
|
|
83
|
+
process.exit(78);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(`[check] auth: ${tokenCount} token(s) loaded ✓`);
|
|
88
|
+
}
|
|
89
|
+
// We don't keep this AuthStore handle — server.ts opens its own.
|
|
90
|
+
// Keeping it open here would tie the file to a process that's about
|
|
91
|
+
// to either fork (daemon) or hand off control (server.ts top-level).
|
|
92
|
+
await authStore.close();
|
|
93
|
+
return { agentCmd: preflight.agentCmd };
|
|
94
|
+
}
|