@lelouchhe/webagent 0.3.0 → 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.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +96 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +123 -9
  31. package/lib/daemon.js +175 -41
  32. package/lib/event-handler.js +209 -91
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1202 -144
  40. package/lib/server.js +149 -33
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +624 -30
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.2562YGRO.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. 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
+ }
@@ -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
- constructor(heartbeatMs = 20_000) {
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 (!client.res.writableEnded)
25
- client.res.write(": heartbeat\n\n");
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", () => this.remove(client.id));
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: ${JSON.stringify(event)}\n\n`;
59
- client.res.write(msg);
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
- for (const client of this.clients.values()) {
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
+ }