@lelouchhe/webagent 0.4.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 +1 -0
- package/config.toml +12 -3
- package/dist/fonts/temml/Temml.woff2 +0 -0
- package/dist/index.html +4 -4
- package/dist/js/app.3OEVQXHK.js +4 -0
- package/dist/js/chunk.IJM5DBCO.js +173 -0
- package/dist/js/viewer.FCSSTUVY.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +4 -3
- package/dist/{styles.012p32dz.css → styles.00xfh3e6.css} +408 -3
- package/lib/auth-middleware.js +3 -0
- package/lib/bridge-event-config.js +29 -0
- package/lib/client-registry.js +104 -15
- package/lib/config.js +12 -5
- package/lib/image-dimensions.js +64 -0
- package/lib/model-picker.js +17 -0
- package/lib/preflight.js +41 -22
- package/lib/push-service.js +21 -69
- package/lib/routes.js +124 -12
- package/lib/server.js +19 -23
- package/lib/startup-checks.js +1 -0
- package/lib/store.js +15 -3
- package/lib/title-service.js +3 -19
- package/package.json +2 -1
- package/dist/js/app.GSAIYHML.js +0 -4
- package/dist/js/chunk.CGWFHJI2.js +0 -76
- package/dist/js/viewer.6DT53STL.js +0 -1
package/lib/config.js
CHANGED
|
@@ -3,6 +3,13 @@ import { parse as parseTOML } from "smol-toml";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
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"),
|
|
@@ -35,7 +42,7 @@ export const ConfigSchema = z.object({
|
|
|
35
42
|
}),
|
|
36
43
|
// [title] — title generation sub-session configuration.
|
|
37
44
|
//
|
|
38
|
-
// `
|
|
45
|
+
// `models` is an array of case-insensitive substring patterns. When the
|
|
39
46
|
// title sub-session is created, we look at the model list the agent
|
|
40
47
|
// reports (ACP `availableModels`) and pick the first model whose id
|
|
41
48
|
// matches any pattern in order. Match → call `setConfigOption` with
|
|
@@ -50,17 +57,17 @@ export const ConfigSchema = z.object({
|
|
|
50
57
|
// - "flash" → Google Gemini (gemini-*-flash)
|
|
51
58
|
// - "lite" → Cohere, generic
|
|
52
59
|
//
|
|
53
|
-
// Set `
|
|
60
|
+
// Set `models = []` to disable substring matching entirely and always
|
|
54
61
|
// inherit the agent's default model. To pin one specific model, pass a
|
|
55
|
-
// single-element array: `
|
|
62
|
+
// single-element array: `models = ["claude-haiku-4.5"]`.
|
|
56
63
|
title: z
|
|
57
64
|
.object({
|
|
58
|
-
|
|
65
|
+
models: z
|
|
59
66
|
.array(z.string())
|
|
60
67
|
.default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
|
|
61
68
|
})
|
|
62
69
|
.default({
|
|
63
|
-
|
|
70
|
+
models: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
|
|
64
71
|
}),
|
|
65
72
|
// [debug] — frontend log level.
|
|
66
73
|
// level ∈ off | debug | info | warn | error. Default "off".
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function valid(width, height) {
|
|
2
|
+
if (!Number.isInteger(width) || !Number.isInteger(height))
|
|
3
|
+
return null;
|
|
4
|
+
if (width <= 0 || height <= 0)
|
|
5
|
+
return null;
|
|
6
|
+
return { width, height };
|
|
7
|
+
}
|
|
8
|
+
export function readImageDimensions(buf) {
|
|
9
|
+
return (readPngDimensions(buf) ?? readGifDimensions(buf) ?? readJpegDimensions(buf));
|
|
10
|
+
}
|
|
11
|
+
function readPngDimensions(buf) {
|
|
12
|
+
// PNG: signature + IHDR width/height at fixed offsets.
|
|
13
|
+
if (buf.length >= 24 &&
|
|
14
|
+
buf[0] === 0x89 &&
|
|
15
|
+
buf[1] === 0x50 &&
|
|
16
|
+
buf[2] === 0x4e &&
|
|
17
|
+
buf[3] === 0x47 &&
|
|
18
|
+
buf.toString("ascii", 12, 16) === "IHDR") {
|
|
19
|
+
return valid(buf.readUInt32BE(16), buf.readUInt32BE(20));
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function readGifDimensions(buf) {
|
|
24
|
+
// GIF87a/GIF89a: logical screen width/height, little-endian.
|
|
25
|
+
if (buf.length >= 10 &&
|
|
26
|
+
(buf.toString("ascii", 0, 6) === "GIF87a" ||
|
|
27
|
+
buf.toString("ascii", 0, 6) === "GIF89a")) {
|
|
28
|
+
return valid(buf.readUInt16LE(6), buf.readUInt16LE(8));
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function isJpegSofMarker(marker) {
|
|
33
|
+
return ((marker >= 0xc0 && marker <= 0xc3) ||
|
|
34
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
35
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
36
|
+
(marker >= 0xcd && marker <= 0xcf));
|
|
37
|
+
}
|
|
38
|
+
function readJpegDimensions(buf) {
|
|
39
|
+
// JPEG: scan marker segments until a SOF marker carrying dimensions.
|
|
40
|
+
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
41
|
+
let offset = 2;
|
|
42
|
+
while (offset + 3 < buf.length) {
|
|
43
|
+
if (buf[offset] !== 0xff) {
|
|
44
|
+
offset++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
while (offset < buf.length && buf[offset] === 0xff)
|
|
48
|
+
offset++;
|
|
49
|
+
const marker = buf[offset++];
|
|
50
|
+
if (marker === 0xd9 || marker === 0xda)
|
|
51
|
+
break;
|
|
52
|
+
if (offset + 1 >= buf.length)
|
|
53
|
+
break;
|
|
54
|
+
const length = buf.readUInt16BE(offset);
|
|
55
|
+
if (length < 2 || offset + length > buf.length)
|
|
56
|
+
break;
|
|
57
|
+
if (isJpegSofMarker(marker) && length >= 7) {
|
|
58
|
+
return valid(buf.readUInt16BE(offset + 5), buf.readUInt16BE(offset + 3));
|
|
59
|
+
}
|
|
60
|
+
offset += length;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Find the first available model whose id matches any pattern. */
|
|
2
|
+
export function pickModelByPatterns(configOptions, patterns) {
|
|
3
|
+
const normalized = patterns
|
|
4
|
+
.map((p) => p.trim().toLowerCase())
|
|
5
|
+
.filter((p) => p.length > 0);
|
|
6
|
+
if (normalized.length === 0)
|
|
7
|
+
return null;
|
|
8
|
+
const modelOpt = configOptions.find((c) => c.id === "model");
|
|
9
|
+
if (!modelOpt || modelOpt.options.length === 0)
|
|
10
|
+
return null;
|
|
11
|
+
for (const pattern of normalized) {
|
|
12
|
+
const hit = modelOpt.options.find((o) => o.value.toLowerCase().includes(pattern));
|
|
13
|
+
if (hit)
|
|
14
|
+
return hit.value;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
package/lib/preflight.js
CHANGED
|
@@ -120,52 +120,71 @@ function checkAgent(agentCmd) {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
/**
|
|
123
|
-
* Probe whether `port` can be bound
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
123
|
+
* Probe whether `host:port` can be bound (matching what `server.listen`
|
|
124
|
+
* actually uses). Listens, then closes immediately. There's a tiny
|
|
125
|
+
* race window between close and the real server.listen() — that's
|
|
126
|
+
* fine for diagnostics: the goal is a friendly hint, not a hard
|
|
127
|
+
* guarantee.
|
|
128
128
|
*
|
|
129
129
|
* Port 0 means "let the OS pick"; we treat it as always-free.
|
|
130
|
+
*
|
|
131
|
+
* Probing the same host as the real server matters: a foreign
|
|
132
|
+
* listener on 0.0.0.0:PORT occupies 127.0.0.1:PORT too (more-specific
|
|
133
|
+
* bind fails when wildcard already bound), so binding the configured
|
|
134
|
+
* host catches conflicts and surfaces EADDRNOTAVAIL when the user
|
|
135
|
+
* typo'd an IP that isn't on any local interface.
|
|
130
136
|
*/
|
|
131
|
-
async function checkPort(port) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
// (server.ts uses "0.0.0.0"). Probing 127.0.0.1 lets a foreign
|
|
137
|
-
// listener on 0.0.0.0:PORT slip past preflight and only surface as
|
|
138
|
-
// EADDRINUSE during the real server.listen() — exactly the case
|
|
139
|
-
// we're trying to catch.
|
|
137
|
+
async function checkPort(port, host) {
|
|
138
|
+
const label = `${host}:${port}`;
|
|
139
|
+
// port=0 still gets probed: the OS picks any free port, but bind can
|
|
140
|
+
// still fail with EADDRNOTAVAIL if `host` isn't on any local
|
|
141
|
+
// interface — that's exactly the typo we want to catch.
|
|
140
142
|
const result = await new Promise((settle) => {
|
|
141
143
|
const probe = createServer();
|
|
142
144
|
probe.once("error", (err) => {
|
|
143
145
|
settle({ code: err.code ?? "unknown" });
|
|
144
146
|
});
|
|
145
|
-
probe.listen(port,
|
|
147
|
+
probe.listen(port, host, () => {
|
|
148
|
+
const addr = probe.address();
|
|
149
|
+
const assigned = typeof addr === "object" && addr ? addr.port : port;
|
|
146
150
|
probe.close(() => {
|
|
147
|
-
settle({});
|
|
151
|
+
settle({ assigned });
|
|
148
152
|
});
|
|
149
153
|
});
|
|
150
154
|
});
|
|
151
155
|
if (!result.code) {
|
|
152
|
-
|
|
156
|
+
if (port === 0) {
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
name: "port",
|
|
160
|
+
detail: `${host}:0 (OS-assigned → ${result.assigned})`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, name: "port", detail: label };
|
|
153
164
|
}
|
|
154
165
|
if (result.code === "EADDRINUSE") {
|
|
155
166
|
return {
|
|
156
167
|
ok: false,
|
|
157
168
|
name: "port",
|
|
158
|
-
detail: `${
|
|
159
|
-
hint:
|
|
169
|
+
detail: `${label} (in use)`,
|
|
170
|
+
hint: `${label} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
|
|
160
171
|
? `netstat -ano | findstr :${port}`
|
|
161
172
|
: `lsof -nP -iTCP:${port} -sTCP:LISTEN`}\nor change \`port\` in config.toml to a free port.`,
|
|
162
173
|
};
|
|
163
174
|
}
|
|
175
|
+
if (result.code === "EADDRNOTAVAIL") {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
name: "port",
|
|
179
|
+
detail: `${label} (${result.code})`,
|
|
180
|
+
hint: `cannot bind ${label}: ${result.code}\nthe host '${host}' is not assigned to any local interface.\ncheck \`host\` in config.toml — typical values are "127.0.0.1" (loopback) or "0.0.0.0" (all interfaces).`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
164
183
|
return {
|
|
165
184
|
ok: false,
|
|
166
185
|
name: "port",
|
|
167
|
-
detail: `${
|
|
168
|
-
hint: `cannot bind
|
|
186
|
+
detail: `${label} (${result.code})`,
|
|
187
|
+
hint: `cannot bind ${label}: ${result.code}\ncheck firewall / permissions, or change \`host\`/\`port\` in config.toml.`,
|
|
169
188
|
};
|
|
170
189
|
}
|
|
171
190
|
/**
|
|
@@ -179,7 +198,7 @@ export async function runPreflight(opts) {
|
|
|
179
198
|
checks.push(checkDataDir(opts.data_dir));
|
|
180
199
|
const agent = checkAgent(opts.agent_cmd);
|
|
181
200
|
checks.push(agent);
|
|
182
|
-
checks.push(await checkPort(opts.port));
|
|
201
|
+
checks.push(await checkPort(opts.port, opts.host));
|
|
183
202
|
for (const c of checks) {
|
|
184
203
|
if (c.ok)
|
|
185
204
|
printOk(c);
|
package/lib/push-service.js
CHANGED
|
@@ -46,18 +46,14 @@ export function isAppleEndpoint(endpoint) {
|
|
|
46
46
|
return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
|
|
47
47
|
}
|
|
48
48
|
function emptyClientState() {
|
|
49
|
-
return {
|
|
49
|
+
return { endpoint: null };
|
|
50
50
|
}
|
|
51
51
|
export class PushService {
|
|
52
52
|
store;
|
|
53
53
|
vapidKeys;
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* partial updates. v2 merges them; `visibleSince` stamps when the client
|
|
58
|
-
* transitioned to visible so the server can TTL-expire "ghost" visibility
|
|
59
|
-
* records left by iOS PWA process suspension (where the client never gets
|
|
60
|
-
* to POST visible:false).
|
|
55
|
+
* Per-client transport state — only the push endpoint. Visibility lives
|
|
56
|
+
* in ClientRegistry as of Plan C Step 4.
|
|
61
57
|
*/
|
|
62
58
|
clients = new Map();
|
|
63
59
|
/** endpoint → consecutive failure count (absent or 0 = healthy) */
|
|
@@ -65,7 +61,8 @@ export class PushService {
|
|
|
65
61
|
globalVisibilitySuppression;
|
|
66
62
|
visibilityTtlMs;
|
|
67
63
|
now;
|
|
68
|
-
|
|
64
|
+
clientRegistry;
|
|
65
|
+
constructor(store, dataDir, vapidSubject, options) {
|
|
69
66
|
this.store = store;
|
|
70
67
|
this.vapidKeys = this.loadOrGenerateKeys(dataDir);
|
|
71
68
|
webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
|
|
@@ -73,6 +70,7 @@ export class PushService {
|
|
|
73
70
|
options.globalVisibilitySuppression ?? true;
|
|
74
71
|
this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
|
|
75
72
|
this.now = options.now ?? (() => Date.now());
|
|
73
|
+
this.clientRegistry = options.clientRegistry;
|
|
76
74
|
}
|
|
77
75
|
// ---------------------------------------------------------------------------
|
|
78
76
|
// VAPID keys
|
|
@@ -123,43 +121,18 @@ export class PushService {
|
|
|
123
121
|
return { kind: "notify", title, body, tag, data: { sessionId } };
|
|
124
122
|
}
|
|
125
123
|
// ---------------------------------------------------------------------------
|
|
126
|
-
//
|
|
124
|
+
// Endpoint mapping (transport-only as of Plan C Step 4)
|
|
127
125
|
// ---------------------------------------------------------------------------
|
|
128
126
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* by omitting the key vs passing `null`. Returns an edge flag so the
|
|
132
|
-
* caller can fire edge-triggered side effects (e.g. sendClose) without
|
|
133
|
-
* double-firing on every heartbeat refresh.
|
|
127
|
+
* Set the push endpoint for a client. Identity-layer state (visibility,
|
|
128
|
+
* active session) goes through ClientRegistry.setVisibility, not here.
|
|
134
129
|
*/
|
|
135
130
|
updateClient(clientId, patch) {
|
|
136
131
|
const prev = this.clients.get(clientId) ?? emptyClientState();
|
|
137
|
-
const wasVisibleForSession = prev.visible && prev.sessionId != null ? prev.sessionId : null;
|
|
138
132
|
const next = { ...prev };
|
|
139
|
-
if (patch.visible !== undefined) {
|
|
140
|
-
next.visible = patch.visible;
|
|
141
|
-
next.visibleSince = patch.visible ? this.now() : 0;
|
|
142
|
-
}
|
|
143
|
-
if (patch.sessionId !== undefined)
|
|
144
|
-
next.sessionId = patch.sessionId;
|
|
145
133
|
if (patch.endpoint !== undefined)
|
|
146
134
|
next.endpoint = patch.endpoint;
|
|
147
|
-
const becameVisibleForSession = next.visible &&
|
|
148
|
-
next.sessionId != null &&
|
|
149
|
-
next.sessionId !== wasVisibleForSession
|
|
150
|
-
? next.sessionId
|
|
151
|
-
: null;
|
|
152
|
-
// Any transition into "visible + session X" restarts the TTL clock,
|
|
153
|
-
// including a session-switch that arrives without an explicit
|
|
154
|
-
// visible:true in the patch (e.g. a session_created POST that only
|
|
155
|
-
// carries sessionId). Otherwise the TTL would keep counting from the
|
|
156
|
-
// previous session's first-visible moment and could prematurely
|
|
157
|
-
// declare the newly-focused session "stale".
|
|
158
|
-
if (becameVisibleForSession) {
|
|
159
|
-
next.visibleSince = this.now();
|
|
160
|
-
}
|
|
161
135
|
this.clients.set(clientId, next);
|
|
162
|
-
return { becameVisibleForSession };
|
|
163
136
|
}
|
|
164
137
|
/**
|
|
165
138
|
* Read-only snapshot for tests and diagnostics. Do NOT mutate the
|
|
@@ -168,40 +141,29 @@ export class PushService {
|
|
|
168
141
|
getClientState(clientId) {
|
|
169
142
|
return this.clients.get(clientId) ?? null;
|
|
170
143
|
}
|
|
171
|
-
/** @deprecated Shim: prefer `updateClient({ visible })`. */
|
|
172
|
-
setClientVisibility(clientId, visible) {
|
|
173
|
-
this.updateClient(clientId, { visible });
|
|
174
|
-
}
|
|
175
|
-
/** @deprecated Shim: prefer `updateClient({ sessionId })`. */
|
|
176
|
-
setClientSession(clientId, sessionId) {
|
|
177
|
-
this.updateClient(clientId, { sessionId });
|
|
178
|
-
}
|
|
179
|
-
/** @deprecated Shim: prefer `updateClient({ endpoint })`. */
|
|
180
144
|
registerClient(clientId, endpoint) {
|
|
181
145
|
this.updateClient(clientId, { endpoint });
|
|
182
146
|
}
|
|
183
147
|
removeClient(clientId) {
|
|
184
148
|
this.clients.delete(clientId);
|
|
149
|
+
// Disconnect also wipes identity-layer state so visibility queries don't
|
|
150
|
+
// leak past the SSE lifetime. Production calls removeClient on SSE close
|
|
151
|
+
// (see sse-manager); tests expect the same.
|
|
152
|
+
this.clientRegistry.remove(clientId);
|
|
185
153
|
}
|
|
186
154
|
hasVisibleClient() {
|
|
187
|
-
|
|
188
|
-
for (const s of this.clients.values()) {
|
|
189
|
-
if (s.visible && now - s.visibleSince <= this.visibilityTtlMs)
|
|
190
|
-
return true;
|
|
191
|
-
}
|
|
192
|
-
return false;
|
|
155
|
+
return this.clientRegistry.hasAnyVisibleClient();
|
|
193
156
|
}
|
|
194
157
|
/** Check if a specific endpoint has at least one visible (non-stale) client. */
|
|
195
158
|
isEndpointVisible(endpoint) {
|
|
196
|
-
|
|
197
|
-
|
|
159
|
+
// Identity (visible) comes from registry, transport (endpoint↔clientId)
|
|
160
|
+
// stays with pushService.clients.
|
|
161
|
+
const reg = this.clientRegistry;
|
|
162
|
+
for (const [clientId, s] of this.clients) {
|
|
198
163
|
if (s.endpoint !== endpoint)
|
|
199
164
|
continue;
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
if (now - s.visibleSince > this.visibilityTtlMs)
|
|
203
|
-
continue;
|
|
204
|
-
return true;
|
|
165
|
+
if (reg.isClientVisible(clientId))
|
|
166
|
+
return true;
|
|
205
167
|
}
|
|
206
168
|
return false;
|
|
207
169
|
}
|
|
@@ -219,17 +181,7 @@ export class PushService {
|
|
|
219
181
|
isSessionVisibleToAnyClient(sessionId) {
|
|
220
182
|
if (!this.globalVisibilitySuppression)
|
|
221
183
|
return false;
|
|
222
|
-
|
|
223
|
-
for (const s of this.clients.values()) {
|
|
224
|
-
if (!s.visible)
|
|
225
|
-
continue;
|
|
226
|
-
if (s.sessionId !== sessionId)
|
|
227
|
-
continue;
|
|
228
|
-
if (now - s.visibleSince > this.visibilityTtlMs)
|
|
229
|
-
continue;
|
|
230
|
-
return true;
|
|
231
|
-
}
|
|
232
|
-
return false;
|
|
184
|
+
return this.clientRegistry.isSessionVisibleToAnyClient(sessionId);
|
|
233
185
|
}
|
|
234
186
|
// ---------------------------------------------------------------------------
|
|
235
187
|
// High-level: decide whether to push, and if so, send
|
package/lib/routes.js
CHANGED
|
@@ -17,6 +17,7 @@ const slog = rlog.scope("session");
|
|
|
17
17
|
const mlog = rlog.scope("msg");
|
|
18
18
|
import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
|
|
19
19
|
import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
|
|
20
|
+
import { readImageDimensions } from "./image-dimensions.js";
|
|
20
21
|
const IS_WIN = process.platform === "win32";
|
|
21
22
|
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
22
23
|
const MIME = {
|
|
@@ -30,6 +31,8 @@ const MIME = {
|
|
|
30
31
|
".jpeg": "image/jpeg",
|
|
31
32
|
".gif": "image/gif",
|
|
32
33
|
".webp": "image/webp",
|
|
34
|
+
".woff2": "font/woff2",
|
|
35
|
+
".wasm": "application/wasm",
|
|
33
36
|
};
|
|
34
37
|
/**
|
|
35
38
|
* HTML entrypoints served by this app. Any new HTML page MUST be registered
|
|
@@ -51,7 +54,10 @@ export const HTML_ENTRYPOINTS = [
|
|
|
51
54
|
*
|
|
52
55
|
* - default-src 'self': everything same-origin only
|
|
53
56
|
* - img-src adds data: + blob: for image-upload preview
|
|
54
|
-
* - script-src 'self' (no inline; theme bootstrap is
|
|
57
|
+
* - script-src 'self' 'wasm-unsafe-eval' (no inline; theme bootstrap is
|
|
58
|
+
* /theme-init.js. 'wasm-unsafe-eval' is the narrow CSP3 token that
|
|
59
|
+
* permits WebAssembly.instantiate() WITHOUT re-enabling eval()/Function;
|
|
60
|
+
* needed by any wasm consumer.)
|
|
55
61
|
* - style-src 'self' (no inline; login styles live in /styles.css)
|
|
56
62
|
* - object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'
|
|
57
63
|
* - connect-src 'self' for fetch + EventSource
|
|
@@ -59,7 +65,7 @@ export const HTML_ENTRYPOINTS = [
|
|
|
59
65
|
export const CSP_POLICY = [
|
|
60
66
|
"default-src 'self'",
|
|
61
67
|
"img-src 'self' data: blob:",
|
|
62
|
-
"script-src 'self'",
|
|
68
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
63
69
|
"style-src 'self'",
|
|
64
70
|
"connect-src 'self'",
|
|
65
71
|
"object-src 'none'",
|
|
@@ -91,6 +97,20 @@ function getClientOpId(req) {
|
|
|
91
97
|
return v;
|
|
92
98
|
return null;
|
|
93
99
|
}
|
|
100
|
+
function logPromptRejectBeforeSave(fields) {
|
|
101
|
+
plog.warn("rejected before save", {
|
|
102
|
+
sessionId: fields.sessionId.slice(0, 8),
|
|
103
|
+
status: fields.status,
|
|
104
|
+
reason: fields.reason,
|
|
105
|
+
...(fields.opId ? { opId: fields.opId } : {}),
|
|
106
|
+
...(fields.textLength != null ? { textLength: fields.textLength } : {}),
|
|
107
|
+
...(fields.attachmentCount != null
|
|
108
|
+
? { attachmentCount: fields.attachmentCount }
|
|
109
|
+
: {}),
|
|
110
|
+
...(fields.busyKind ? { busyKind: fields.busyKind } : {}),
|
|
111
|
+
...(fields.error ? { error: fields.error } : {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
94
114
|
function tryReplayClientOp(req, res, store, sessionId) {
|
|
95
115
|
const opId = getClientOpId(req);
|
|
96
116
|
if (!opId)
|
|
@@ -317,6 +337,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
317
337
|
finalPath = join(dir, `${uploadId}.${fileExt}`);
|
|
318
338
|
}
|
|
319
339
|
}
|
|
340
|
+
const imageDimensions = kind === "image" ? readImageDimensions(head) : null;
|
|
320
341
|
await rename(tmpPath, finalPath);
|
|
321
342
|
const rp = await realpath(finalPath);
|
|
322
343
|
const row = store.insertAttachment({
|
|
@@ -327,6 +348,8 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
327
348
|
mime: fileMime,
|
|
328
349
|
size: bytesWritten,
|
|
329
350
|
realpath: rp,
|
|
351
|
+
width: imageDimensions?.width ?? null,
|
|
352
|
+
height: imageDimensions?.height ?? null,
|
|
330
353
|
});
|
|
331
354
|
// Invalidate the per-session attachment label cache so the
|
|
332
355
|
// next egress (SSE broadcast or replay) sees this new row.
|
|
@@ -344,6 +367,8 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
344
367
|
displayName: row.name,
|
|
345
368
|
mimeType: row.mime,
|
|
346
369
|
size: row.size,
|
|
370
|
+
width: row.width,
|
|
371
|
+
height: row.height,
|
|
347
372
|
kind: row.kind,
|
|
348
373
|
path: `sessions/${sessionId}/attachments/${fileName}`,
|
|
349
374
|
url: fileUrl,
|
|
@@ -779,17 +804,36 @@ export function createRequestHandler(deps) {
|
|
|
779
804
|
const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
|
|
780
805
|
if (promptMatch && req.method === "POST") {
|
|
781
806
|
const sessionId = decodeURIComponent(promptMatch[1]);
|
|
807
|
+
const requestOpId = getClientOpId(req);
|
|
782
808
|
const session = store.getSession(sessionId);
|
|
783
809
|
if (!session) {
|
|
810
|
+
logPromptRejectBeforeSave({
|
|
811
|
+
sessionId,
|
|
812
|
+
status: 404,
|
|
813
|
+
reason: "session_not_found",
|
|
814
|
+
opId: requestOpId,
|
|
815
|
+
});
|
|
784
816
|
json(res, 404, { error: "Session not found" });
|
|
785
817
|
return;
|
|
786
818
|
}
|
|
787
819
|
const bridge = getBridge?.();
|
|
788
820
|
if (!bridge) {
|
|
821
|
+
logPromptRejectBeforeSave({
|
|
822
|
+
sessionId,
|
|
823
|
+
status: 503,
|
|
824
|
+
reason: "agent_not_ready",
|
|
825
|
+
opId: requestOpId,
|
|
826
|
+
});
|
|
789
827
|
json(res, 503, { error: "Agent not ready yet" });
|
|
790
828
|
return;
|
|
791
829
|
}
|
|
792
830
|
if (!sessions) {
|
|
831
|
+
logPromptRejectBeforeSave({
|
|
832
|
+
sessionId,
|
|
833
|
+
status: 503,
|
|
834
|
+
reason: "session_manager_unavailable",
|
|
835
|
+
opId: requestOpId,
|
|
836
|
+
});
|
|
793
837
|
json(res, 503, { error: "Session manager not available" });
|
|
794
838
|
return;
|
|
795
839
|
}
|
|
@@ -801,6 +845,13 @@ export function createRequestHandler(deps) {
|
|
|
801
845
|
await sessions.ensureResumed(bridge, sessionId);
|
|
802
846
|
}
|
|
803
847
|
catch (err) {
|
|
848
|
+
logPromptRejectBeforeSave({
|
|
849
|
+
sessionId,
|
|
850
|
+
status: 500,
|
|
851
|
+
reason: "resume_failed",
|
|
852
|
+
opId,
|
|
853
|
+
error: errorMessage(err),
|
|
854
|
+
});
|
|
804
855
|
json(res, 500, {
|
|
805
856
|
error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
|
|
806
857
|
});
|
|
@@ -809,6 +860,13 @@ export function createRequestHandler(deps) {
|
|
|
809
860
|
// Check if session is busy
|
|
810
861
|
const busyKind = sessions.getBusyKind(sessionId);
|
|
811
862
|
if (busyKind) {
|
|
863
|
+
logPromptRejectBeforeSave({
|
|
864
|
+
sessionId,
|
|
865
|
+
status: 409,
|
|
866
|
+
reason: "session_busy",
|
|
867
|
+
opId,
|
|
868
|
+
busyKind,
|
|
869
|
+
});
|
|
812
870
|
json(res, 409, { error: "Session is busy", busyKind });
|
|
813
871
|
return;
|
|
814
872
|
}
|
|
@@ -817,10 +875,26 @@ export function createRequestHandler(deps) {
|
|
|
817
875
|
body = JSON.parse(await readBody(req));
|
|
818
876
|
}
|
|
819
877
|
catch {
|
|
878
|
+
logPromptRejectBeforeSave({
|
|
879
|
+
sessionId,
|
|
880
|
+
status: 400,
|
|
881
|
+
reason: "invalid_json",
|
|
882
|
+
opId,
|
|
883
|
+
});
|
|
820
884
|
json(res, 400, { error: "Invalid JSON" });
|
|
821
885
|
return;
|
|
822
886
|
}
|
|
823
887
|
if (!body.text) {
|
|
888
|
+
logPromptRejectBeforeSave({
|
|
889
|
+
sessionId,
|
|
890
|
+
status: 400,
|
|
891
|
+
reason: "missing_text",
|
|
892
|
+
opId,
|
|
893
|
+
textLength: 0,
|
|
894
|
+
attachmentCount: Array.isArray(body.attachments)
|
|
895
|
+
? body.attachments.length
|
|
896
|
+
: undefined,
|
|
897
|
+
});
|
|
824
898
|
json(res, 400, { error: "Missing required field: text" });
|
|
825
899
|
return;
|
|
826
900
|
}
|
|
@@ -830,6 +904,13 @@ export function createRequestHandler(deps) {
|
|
|
830
904
|
const attachments = body.attachments;
|
|
831
905
|
if (attachments) {
|
|
832
906
|
if (!Array.isArray(attachments)) {
|
|
907
|
+
logPromptRejectBeforeSave({
|
|
908
|
+
sessionId,
|
|
909
|
+
status: 400,
|
|
910
|
+
reason: "attachments_not_array",
|
|
911
|
+
opId,
|
|
912
|
+
textLength: body.text.length,
|
|
913
|
+
});
|
|
833
914
|
json(res, 400, { error: "attachments must be an array" });
|
|
834
915
|
return;
|
|
835
916
|
}
|
|
@@ -841,14 +922,32 @@ export function createRequestHandler(deps) {
|
|
|
841
922
|
typeof att.attachmentId !== "string" ||
|
|
842
923
|
typeof att.displayName !== "string" ||
|
|
843
924
|
typeof att.mimeType !== "string") {
|
|
925
|
+
logPromptRejectBeforeSave({
|
|
926
|
+
sessionId,
|
|
927
|
+
status: 400,
|
|
928
|
+
reason: "invalid_attachment_entry",
|
|
929
|
+
opId,
|
|
930
|
+
textLength: body.text.length,
|
|
931
|
+
attachmentCount: attachments.length,
|
|
932
|
+
});
|
|
844
933
|
json(res, 400, { error: "Invalid attachment entry" });
|
|
845
934
|
return;
|
|
846
935
|
}
|
|
847
936
|
if (typeof att.uri === "string" ||
|
|
848
937
|
typeof att.data === "string" ||
|
|
849
|
-
typeof att.path === "string"
|
|
938
|
+
typeof att.path === "string" ||
|
|
939
|
+
typeof att.width === "number" ||
|
|
940
|
+
typeof att.height === "number") {
|
|
941
|
+
logPromptRejectBeforeSave({
|
|
942
|
+
sessionId,
|
|
943
|
+
status: 400,
|
|
944
|
+
reason: "client_supplied_attachment_data",
|
|
945
|
+
opId,
|
|
946
|
+
textLength: body.text.length,
|
|
947
|
+
attachmentCount: attachments.length,
|
|
948
|
+
});
|
|
850
949
|
json(res, 400, {
|
|
851
|
-
error: "Client must not supply uri/data/path",
|
|
950
|
+
error: "Client must not supply uri/data/path/width/height",
|
|
852
951
|
});
|
|
853
952
|
return;
|
|
854
953
|
}
|
|
@@ -873,6 +972,9 @@ export function createRequestHandler(deps) {
|
|
|
873
972
|
displayName: a.displayName,
|
|
874
973
|
mimeType: a.mimeType,
|
|
875
974
|
path: `/api/v1/sessions/${sessionId}/attachments/${fileName}`,
|
|
975
|
+
...(row.width != null && row.height != null
|
|
976
|
+
? { width: row.width, height: row.height }
|
|
977
|
+
: {}),
|
|
876
978
|
},
|
|
877
979
|
];
|
|
878
980
|
});
|
|
@@ -1885,21 +1987,27 @@ export function createRequestHandler(deps) {
|
|
|
1885
1987
|
else {
|
|
1886
1988
|
sessionIdPatch = null;
|
|
1887
1989
|
}
|
|
1888
|
-
if (deps.
|
|
1889
|
-
|
|
1990
|
+
if (deps.clientRegistry) {
|
|
1991
|
+
// setVisibility no-ops on unknown clients; auto-register here so
|
|
1992
|
+
// SSE-only clients (which haven't sent /hello yet) still take
|
|
1993
|
+
// effect. The trust boundary above already gated on identity.
|
|
1994
|
+
if (!deps.clientRegistry.get(clientId)) {
|
|
1995
|
+
deps.clientRegistry.register(clientId, { capabilities: [] });
|
|
1996
|
+
}
|
|
1997
|
+
const { becameVisibleFor } = deps.clientRegistry.setVisibility(clientId, {
|
|
1890
1998
|
visible: body.visible,
|
|
1891
|
-
|
|
1999
|
+
active: sessionIdPatch,
|
|
1892
2000
|
});
|
|
1893
2001
|
// Edge-triggered only: heartbeat refreshes repeat the same
|
|
1894
2002
|
// (visible:true, sessionId:X) POST every 15s — firing sendClose
|
|
1895
2003
|
// on each would hammer banner recall. Only the first such
|
|
1896
2004
|
// transition after a change should recall stale banners.
|
|
1897
|
-
if (
|
|
1898
|
-
void deps.pushService.sendClose(`sess-${
|
|
2005
|
+
if (becameVisibleFor && deps.pushService) {
|
|
2006
|
+
void deps.pushService.sendClose(`sess-${becameVisibleFor}-done`);
|
|
1899
2007
|
if (sessions) {
|
|
1900
2008
|
for (const perm of sessions.pendingPermissions.values()) {
|
|
1901
|
-
if (perm.sessionId ===
|
|
1902
|
-
void deps.pushService.sendClose(`sess-${
|
|
2009
|
+
if (perm.sessionId === becameVisibleFor) {
|
|
2010
|
+
void deps.pushService.sendClose(`sess-${becameVisibleFor}-perm-${perm.requestId}`);
|
|
1903
2011
|
}
|
|
1904
2012
|
}
|
|
1905
2013
|
}
|
|
@@ -2014,7 +2122,11 @@ export function createRequestHandler(deps) {
|
|
|
2014
2122
|
const ext = extname(filePath);
|
|
2015
2123
|
const base = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
2016
2124
|
const isHashedAsset = /\.[A-Za-z0-9_-]{8,}\.(js|css)$/.test(base);
|
|
2017
|
-
|
|
2125
|
+
// `/lib/**` is vendored third-party content pinned by upstream SHA in
|
|
2126
|
+
// package metadata — treat as immutable just like content-hashed bundles.
|
|
2127
|
+
// Avoids re-downloading multi-hundred-KB wasm on every consumer init.
|
|
2128
|
+
const isVendoredLib = staticPath.startsWith("/lib/");
|
|
2129
|
+
const cacheControl = isHashedAsset || isVendoredLib
|
|
2018
2130
|
? "public, max-age=31536000, immutable"
|
|
2019
2131
|
: "no-cache";
|
|
2020
2132
|
const headers = {
|