@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
package/dist/sw.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// Minimal service worker for PWA installability + push notifications.
|
|
2
2
|
// No offline caching — app requires SSE connection.
|
|
3
3
|
|
|
4
|
-
self.addEventListener(
|
|
5
|
-
self.addEventListener(
|
|
4
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
5
|
+
self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
|
|
6
6
|
|
|
7
7
|
// --- Push notifications ---
|
|
8
|
+
//
|
|
9
|
+
// Two payload shapes from the server:
|
|
10
|
+
// { kind: "notify", title, body, tag, data } → showNotification
|
|
11
|
+
// { kind: "close", tag } → close any existing
|
|
12
|
+
// notifications with that
|
|
13
|
+
// tag (cross-device recall
|
|
14
|
+
// for acked/consumed inbox
|
|
15
|
+
// messages).
|
|
8
16
|
|
|
9
|
-
self.addEventListener(
|
|
17
|
+
self.addEventListener("push", (e) => {
|
|
10
18
|
if (!e.data) return;
|
|
11
19
|
|
|
12
20
|
let payload;
|
|
@@ -16,36 +24,80 @@ self.addEventListener('push', (e) => {
|
|
|
16
24
|
return;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
if (payload.kind === "close" && payload.tag) {
|
|
28
|
+
e.waitUntil(closeByTag(payload.tag));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Backward-compat: older payloads had no `kind`. Treat as notify.
|
|
33
|
+
const { title, body, tag, data } = payload;
|
|
34
|
+
const finalTag = tag || data?.sessionId || "default";
|
|
35
|
+
e.waitUntil(showNotify(title, body, finalTag, data));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// iOS Safari's tag-based notification collapse in showNotification is
|
|
39
|
+
// unreliable: notifications stack in Notification Center even with
|
|
40
|
+
// identical tag. The community-standard workaround is to explicitly close
|
|
41
|
+
// any existing same-tag notifications in the SW before showing the new
|
|
42
|
+
// one. Harmless on conformant platforms (they already replace).
|
|
43
|
+
//
|
|
44
|
+
// NOTE: In iOS 17 PWA dogfood this workaround did NOT actually collapse
|
|
45
|
+
// banners either — two same-tag pushes still produced two banners. The
|
|
46
|
+
// underlying cause appears to be in WebKit / APNs and is outside our
|
|
47
|
+
// control. We keep this code because (a) it's correct per the Web
|
|
48
|
+
// Notifications spec, (b) it works on Chrome/Firefox/Android, and
|
|
49
|
+
// (c) future iOS versions may honor it.
|
|
50
|
+
async function showNotify(title, body, tag, data) {
|
|
51
|
+
const existing = await self.registration.getNotifications({ tag });
|
|
52
|
+
for (const n of existing) n.close();
|
|
53
|
+
await self.registration.showNotification(title || "WebAgent", {
|
|
54
|
+
body: body || "",
|
|
55
|
+
icon: "/icon-192.png",
|
|
56
|
+
badge: "/icon-192.png",
|
|
57
|
+
tag,
|
|
58
|
+
data: data || {},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function closeByTag(tag) {
|
|
63
|
+
const notifications = await self.registration.getNotifications({ tag });
|
|
64
|
+
for (const n of notifications) n.close();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Allow page-side code to request close of a message-tagged notification
|
|
68
|
+
// (frontend dispatches this on `message_acked` / `message_consumed` so the
|
|
69
|
+
// local device's banner disappears without waiting for the server's silent
|
|
70
|
+
// close push).
|
|
71
|
+
self.addEventListener("message", (e) => {
|
|
72
|
+
const msg = e.data;
|
|
73
|
+
if (msg && msg.type === "close-notification" && msg.tag) {
|
|
74
|
+
e.waitUntil(closeByTag(msg.tag));
|
|
75
|
+
}
|
|
29
76
|
});
|
|
30
77
|
|
|
31
|
-
self.addEventListener(
|
|
78
|
+
self.addEventListener("notificationclick", (e) => {
|
|
32
79
|
e.notification.close();
|
|
33
80
|
|
|
34
|
-
const
|
|
35
|
-
|
|
81
|
+
const data = e.notification.data || {};
|
|
82
|
+
// For inbox messages, route to the bound session (if set) or root.
|
|
83
|
+
// For session events, route to the session.
|
|
84
|
+
const sessionId = data.sessionId;
|
|
85
|
+
const urlHash = sessionId ? `/#${sessionId}` : "/";
|
|
36
86
|
|
|
37
87
|
e.waitUntil(
|
|
38
|
-
self.clients
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
client.
|
|
44
|
-
|
|
88
|
+
self.clients
|
|
89
|
+
.matchAll({ type: "window", includeUncontrolled: true })
|
|
90
|
+
.then((clients) => {
|
|
91
|
+
// Focus existing window if open
|
|
92
|
+
for (const client of clients) {
|
|
93
|
+
if (client.url.includes(self.location.origin)) {
|
|
94
|
+
client.focus();
|
|
95
|
+
client.postMessage({ type: "navigate", sessionId });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
45
98
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
})
|
|
99
|
+
// Otherwise open a new window
|
|
100
|
+
return self.clients.openWindow(urlHash);
|
|
101
|
+
}),
|
|
50
102
|
);
|
|
51
103
|
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Agent auto-detection.
|
|
2
|
+
//
|
|
3
|
+
// When `agent_cmd` is the sentinel "auto" (the new default), we scan PATH
|
|
4
|
+
// in two passes:
|
|
5
|
+
//
|
|
6
|
+
// L1 — ACP-ready binaries that speak the protocol directly. First hit
|
|
7
|
+
// wins; we return its exact run command. Order is by perceived
|
|
8
|
+
// popularity / native-vs-wrapper, not alphabetical — see comments
|
|
9
|
+
// on each row.
|
|
10
|
+
//
|
|
11
|
+
// L2 — Bare vendor CLIs whose ACP support requires a separate adapter
|
|
12
|
+
// package. We don't auto-`npx` them (silent network downloads /
|
|
13
|
+
// supply-chain trust / startup latency are all real costs); we
|
|
14
|
+
// just tell the user which adapter to install.
|
|
15
|
+
//
|
|
16
|
+
// If a user explicitly set `agent_cmd` in their TOML, this module is
|
|
17
|
+
// skipped — explicit config always wins. Detection is a first-run
|
|
18
|
+
// affordance, not a policy.
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
// L1: ACP-ready binaries. Each row is "if `bin` is in PATH, run `cmd`".
|
|
21
|
+
// Verified against https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json.
|
|
22
|
+
export const L1_CANDIDATES = [
|
|
23
|
+
{ bin: "copilot", cmd: "copilot --acp", label: "GitHub Copilot CLI" },
|
|
24
|
+
{ bin: "gemini", cmd: "gemini --acp", label: "Gemini CLI" },
|
|
25
|
+
{ bin: "opencode", cmd: "opencode acp", label: "OpenCode" },
|
|
26
|
+
{
|
|
27
|
+
bin: "claude-agent-acp",
|
|
28
|
+
cmd: "claude-agent-acp",
|
|
29
|
+
label: "Claude Code (via ACP adapter)",
|
|
30
|
+
},
|
|
31
|
+
{ bin: "codex-acp", cmd: "codex-acp", label: "Codex (via ACP adapter)" },
|
|
32
|
+
{
|
|
33
|
+
bin: "qwen",
|
|
34
|
+
cmd: "qwen --acp --experimental-skills",
|
|
35
|
+
label: "Qwen Code",
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
// L2: Bare vendor CLIs that need a separate adapter to speak ACP.
|
|
39
|
+
export const L2_CANDIDATES = [
|
|
40
|
+
{
|
|
41
|
+
bin: "claude",
|
|
42
|
+
label: "Claude Code",
|
|
43
|
+
adapter: "@agentclientprotocol/claude-agent-acp",
|
|
44
|
+
install: "npm i -g @agentclientprotocol/claude-agent-acp",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
bin: "codex",
|
|
48
|
+
label: "Codex",
|
|
49
|
+
adapter: "@zed-industries/codex-acp",
|
|
50
|
+
install: "npm i -g @zed-industries/codex-acp",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
function inPath(bin) {
|
|
54
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
55
|
+
try {
|
|
56
|
+
const r = spawnSync(which, [bin], { stdio: "ignore" });
|
|
57
|
+
return r.status === 0;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function detectAgent() {
|
|
64
|
+
for (const c of L1_CANDIDATES) {
|
|
65
|
+
if (inPath(c.bin)) {
|
|
66
|
+
return { ok: true, cmd: c.cmd, bin: c.bin, label: c.label };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const c of L2_CANDIDATES) {
|
|
70
|
+
if (inPath(c.bin)) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
kind: "l2-hint",
|
|
74
|
+
bin: c.bin,
|
|
75
|
+
label: c.label,
|
|
76
|
+
adapter: c.adapter,
|
|
77
|
+
install: c.install,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, kind: "none" };
|
|
82
|
+
}
|
|
83
|
+
// Format a multi-line, copy-pasteable hint for the operator. server.ts
|
|
84
|
+
// prints this verbatim and exits non-zero when detection fails.
|
|
85
|
+
export function formatDetectionFailure(result) {
|
|
86
|
+
if (result.kind === "l2-hint") {
|
|
87
|
+
return [
|
|
88
|
+
`[bridge] detected ${result.label} (${result.bin}) but no ACP adapter.`,
|
|
89
|
+
``,
|
|
90
|
+
` Install the adapter to use ${result.label} with webagent:`,
|
|
91
|
+
` ${result.install}`,
|
|
92
|
+
``,
|
|
93
|
+
` Or set agent_cmd in config.toml to a different ACP agent.`,
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
96
|
+
return [
|
|
97
|
+
`[bridge] no ACP-ready agent found in PATH.`,
|
|
98
|
+
``,
|
|
99
|
+
` Install one of:`,
|
|
100
|
+
` npm i -g @github/copilot # Copilot CLI`,
|
|
101
|
+
` npm i -g @google/gemini-cli # Gemini CLI`,
|
|
102
|
+
` npm i -g opencode-ai # OpenCode`,
|
|
103
|
+
``,
|
|
104
|
+
` Or for Claude Code / Codex, install the ACP adapter:`,
|
|
105
|
+
` npm i -g @agentclientprotocol/claude-agent-acp`,
|
|
106
|
+
` npm i -g @zed-industries/codex-acp`,
|
|
107
|
+
``,
|
|
108
|
+
` Then re-run webagent (or set agent_cmd in config.toml).`,
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Atomic file writes via tmp + rename(2).
|
|
2
|
+
//
|
|
3
|
+
// rename(2) is atomic within a filesystem on POSIX (and on Windows on the
|
|
4
|
+
// same volume), so concurrent readers only ever see the old file, the new
|
|
5
|
+
// file, or ENOENT — never a half-written or zero-length file. Plain
|
|
6
|
+
// writeFile is open(O_TRUNC) → write → close, which exposes a
|
|
7
|
+
// truncate-but-not-yet-written race window where polling readers see ""
|
|
8
|
+
// and crash on JSON.parse. The flake that motivated extracting this
|
|
9
|
+
// helper was test/daemon.test.ts reading the PID file mid-write.
|
|
10
|
+
//
|
|
11
|
+
// Used for the daemon PID file (`webagent.pid`) and the auth token store
|
|
12
|
+
// (`auth.json`). Both have a single writer process, so no inter-process
|
|
13
|
+
// lock is needed — just atomicity against a polling reader.
|
|
14
|
+
//
|
|
15
|
+
// proper-lockfile is also a dependency in this repo but it provides
|
|
16
|
+
// inter-process *locking* (sentinel-dir semaphore), not atomic writes.
|
|
17
|
+
// Locking plus naive writeFile would still expose the truncate race to
|
|
18
|
+
// readers that don't hold the lock.
|
|
19
|
+
import { writeFileSync, renameSync, chmodSync } from "node:fs";
|
|
20
|
+
import { open, chmod, rename } from "node:fs/promises";
|
|
21
|
+
/**
|
|
22
|
+
* Synchronous atomic write. Used in code paths where awaiting isn't
|
|
23
|
+
* convenient (e.g. supervisor bootstrap before signal handlers are
|
|
24
|
+
* installed).
|
|
25
|
+
*/
|
|
26
|
+
export function atomicWriteFileSync(path, data, mode) {
|
|
27
|
+
const tmp = `${path}.tmp`;
|
|
28
|
+
writeFileSync(tmp, data, mode != null ? { mode } : undefined);
|
|
29
|
+
if (mode != null)
|
|
30
|
+
chmodSync(tmp, mode);
|
|
31
|
+
renameSync(tmp, path);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Async atomic write. `mode` is applied via open()'s mode arg AND a
|
|
35
|
+
* follow-up chmod so the final file ends up with the desired perms
|
|
36
|
+
* regardless of umask or whether the temp file pre-existed.
|
|
37
|
+
*/
|
|
38
|
+
export async function atomicWriteFile(path, data, mode) {
|
|
39
|
+
const tmp = `${path}.tmp`;
|
|
40
|
+
const fh = await open(tmp, "w", mode);
|
|
41
|
+
try {
|
|
42
|
+
await fh.writeFile(data);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await fh.close();
|
|
46
|
+
}
|
|
47
|
+
if (mode != null)
|
|
48
|
+
await chmod(tmp, mode);
|
|
49
|
+
await rename(tmp, path);
|
|
50
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { isInsideSessionAttachments } from "./sessions-anchor.js";
|
|
4
|
+
const NOOP_LOGGER = { warn: () => { } };
|
|
5
|
+
/**
|
|
6
|
+
* Builds the ACP prompt block for one client-supplied attachment. Returns
|
|
7
|
+
* a fallback text block on any failure (DB miss, disk miss, anchor breach,
|
|
8
|
+
* cross-session reference) so the prompt turn never gets stuck in a retry
|
|
9
|
+
* loop just because one image vanished.
|
|
10
|
+
*
|
|
11
|
+
* Trust boundary (decision 10 in uploads-plan v2.6): client only supplies
|
|
12
|
+
* `attachmentId`. Everything else (realpath, anchor, MIME for read) comes
|
|
13
|
+
* from the server-side row.
|
|
14
|
+
*/
|
|
15
|
+
export class AttachmentDispatcher {
|
|
16
|
+
store;
|
|
17
|
+
sessionsAnchor;
|
|
18
|
+
logger;
|
|
19
|
+
constructor(store, sessionsAnchor, logger = NOOP_LOGGER) {
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.sessionsAnchor = sessionsAnchor;
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
}
|
|
24
|
+
async dispatch(sessionId, ref) {
|
|
25
|
+
const fallback = (reason) => {
|
|
26
|
+
this.logger.warn(`[attachments] dispatch fallback (${reason}) for ${sessionId}/${ref.attachmentId}`);
|
|
27
|
+
return {
|
|
28
|
+
type: "text",
|
|
29
|
+
text: `[attachment removed: ${ref.displayName}]`,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
// Reject any client trying to smuggle a uri / data / path. The shape
|
|
33
|
+
// of AttachmentRef already forbids these statically; this guard is a
|
|
34
|
+
// belt-and-suspenders for callers passing a wider object via `as any`.
|
|
35
|
+
const wider = ref;
|
|
36
|
+
if (typeof wider.uri === "string" ||
|
|
37
|
+
typeof wider.data === "string" ||
|
|
38
|
+
typeof wider.path === "string") {
|
|
39
|
+
return fallback("client_supplied_external_field");
|
|
40
|
+
}
|
|
41
|
+
const row = this.store.getAttachment(sessionId, ref.attachmentId);
|
|
42
|
+
if (!row)
|
|
43
|
+
return fallback("row_not_found");
|
|
44
|
+
// Cross-session reference — the row exists but for a DIFFERENT session.
|
|
45
|
+
// store.getAttachment scopes by session_id so this should already be
|
|
46
|
+
// caught by row_not_found, but assert defensively.
|
|
47
|
+
if (row.session_id !== sessionId) {
|
|
48
|
+
return fallback("cross_session");
|
|
49
|
+
}
|
|
50
|
+
// Anchor check on the stored realpath. If the file was moved out from
|
|
51
|
+
// under us, or a future bug let an attacker inject a row with a path
|
|
52
|
+
// outside SESSIONS_ANCHOR/<sid>/attachments/, we MUST refuse to dispatch
|
|
53
|
+
// it as a `file://` URI — the agent would happily read it.
|
|
54
|
+
let resolvedPath;
|
|
55
|
+
try {
|
|
56
|
+
resolvedPath = await realpath(row.realpath);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return fallback("realpath_failed");
|
|
60
|
+
}
|
|
61
|
+
if (!isInsideSessionAttachments(this.sessionsAnchor, sessionId, resolvedPath)) {
|
|
62
|
+
return fallback("path_outside_anchor");
|
|
63
|
+
}
|
|
64
|
+
if (ref.kind === "image") {
|
|
65
|
+
try {
|
|
66
|
+
const buf = await readFile(resolvedPath);
|
|
67
|
+
return {
|
|
68
|
+
type: "image",
|
|
69
|
+
data: buf.toString("base64"),
|
|
70
|
+
mimeType: row.mime,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return fallback("read_failed");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// kind === "file" → ACP resource_link with file:// URI built from the
|
|
78
|
+
// realpath (NOT from any client-supplied string).
|
|
79
|
+
return {
|
|
80
|
+
type: "resource_link",
|
|
81
|
+
uri: pathToFileURL(resolvedPath).toString(),
|
|
82
|
+
name: row.name,
|
|
83
|
+
mimeType: row.mime,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Permission auto-approve interceptor for attachment reads.
|
|
2
|
+
//
|
|
3
|
+
// Plan §1.4 (uploads-plan v2.6, lines 105-183). When an agent requests
|
|
4
|
+
// permission to *read* a path that we know is one of the user-uploaded
|
|
5
|
+
// session attachments, auto-approve with `allow_once`. Any deviation
|
|
6
|
+
// from the strict allowlist falls through to the user prompt.
|
|
7
|
+
//
|
|
8
|
+
// Defenses (mirrored from the plan):
|
|
9
|
+
// F1 kind === "read" only
|
|
10
|
+
// F2 every locations[].path realpath ∈ session attachment realpaths
|
|
11
|
+
// F4 schema gate — locations must exist; if rawInput has known path
|
|
12
|
+
// keys they must also realpath into the attachment set; if it has
|
|
13
|
+
// none, allow but bump schemaDrift counter so we notice when the
|
|
14
|
+
// Copilot CLI changes its raw-input field names.
|
|
15
|
+
// F6 any realpath / DB error → fall through (deny auto-approve, not
|
|
16
|
+
// deny the user — the user dialog still shows)
|
|
17
|
+
// F7 four counters, dumped hourly via attachInterceptorLogger.
|
|
18
|
+
import { realpath as fsRealpath } from "node:fs/promises";
|
|
19
|
+
const READ_TOOL_ALLOWLIST = new Set(["view", "read_file"]);
|
|
20
|
+
const RAWINPUT_PATH_KEYS = ["path", "filePath", "file"];
|
|
21
|
+
export function createCounters() {
|
|
22
|
+
return {
|
|
23
|
+
autoAllowed: 0,
|
|
24
|
+
fellThrough: 0,
|
|
25
|
+
realpathErrors: 0,
|
|
26
|
+
schemaDrift: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function checkLocationPaths(locations, attachmentRealpaths, counters, log, miss) {
|
|
30
|
+
for (const loc of locations) {
|
|
31
|
+
let rp;
|
|
32
|
+
try {
|
|
33
|
+
rp = await fsRealpath(loc.path);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
counters.realpathErrors++;
|
|
37
|
+
log.warn?.("attachment interceptor realpath error", {
|
|
38
|
+
path: loc.path,
|
|
39
|
+
error: e.code ?? e.message,
|
|
40
|
+
});
|
|
41
|
+
return miss("realpath_failed");
|
|
42
|
+
}
|
|
43
|
+
if (!attachmentRealpaths.has(rp))
|
|
44
|
+
return miss("path_not_in_attachments");
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
async function checkRawInputPaths(raw, attachmentRealpaths, miss) {
|
|
49
|
+
let foundAnyKey = false;
|
|
50
|
+
for (const key of RAWINPUT_PATH_KEYS) {
|
|
51
|
+
const v = raw[key];
|
|
52
|
+
if (typeof v !== "string")
|
|
53
|
+
continue;
|
|
54
|
+
foundAnyKey = true;
|
|
55
|
+
let rp;
|
|
56
|
+
try {
|
|
57
|
+
rp = await fsRealpath(v);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { ok: false, result: miss("rawinput_realpath_failed") };
|
|
61
|
+
}
|
|
62
|
+
if (!attachmentRealpaths.has(rp)) {
|
|
63
|
+
return { ok: false, result: miss("rawinput_path_mismatch") };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, foundAnyKey };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Returns true iff the request matches the strict
|
|
70
|
+
* "agent reading a known session attachment" pattern.
|
|
71
|
+
*
|
|
72
|
+
* Returning false does NOT deny the user prompt — it just declines to
|
|
73
|
+
* auto-approve, so the normal permission UI continues to render.
|
|
74
|
+
*/
|
|
75
|
+
export async function shouldAutoApproveAttachmentRead(ev, deps) {
|
|
76
|
+
const { counters, logger } = deps;
|
|
77
|
+
const log = logger ?? {};
|
|
78
|
+
const miss = (reason) => {
|
|
79
|
+
counters.fellThrough++;
|
|
80
|
+
log.debug?.("attachment auto-allow miss", {
|
|
81
|
+
reason,
|
|
82
|
+
sessionId: ev.sessionId,
|
|
83
|
+
toolKind: ev.toolKind,
|
|
84
|
+
toolName: ev.toolName,
|
|
85
|
+
});
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
if (ev.toolKind !== "read")
|
|
89
|
+
return miss("tool_kind_not_read");
|
|
90
|
+
if (typeof ev.toolName === "string" &&
|
|
91
|
+
!READ_TOOL_ALLOWLIST.has(ev.toolName)) {
|
|
92
|
+
return miss("tool_name_not_allowlisted");
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(ev.locations) || ev.locations.length === 0) {
|
|
95
|
+
return miss("no_locations");
|
|
96
|
+
}
|
|
97
|
+
let attachmentRealpaths;
|
|
98
|
+
try {
|
|
99
|
+
attachmentRealpaths = new Set(deps.listAttachmentRealpaths(ev.sessionId));
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
log.warn?.("attachment interceptor db error", {
|
|
103
|
+
sessionId: ev.sessionId,
|
|
104
|
+
error: e.message,
|
|
105
|
+
});
|
|
106
|
+
return miss("db_error");
|
|
107
|
+
}
|
|
108
|
+
const locOk = await checkLocationPaths(ev.locations, attachmentRealpaths, counters, log, miss);
|
|
109
|
+
if (locOk !== true)
|
|
110
|
+
return locOk;
|
|
111
|
+
const raw = ev.rawInput;
|
|
112
|
+
if (raw && typeof raw === "object") {
|
|
113
|
+
const r = await checkRawInputPaths(raw, attachmentRealpaths, miss);
|
|
114
|
+
if (!r.ok)
|
|
115
|
+
return r.result;
|
|
116
|
+
if (!r.foundAnyKey) {
|
|
117
|
+
counters.schemaDrift++;
|
|
118
|
+
log.warn?.("attachment interceptor schema drift: rawInput has no known path key", { rawInputKeys: Object.keys(raw) });
|
|
119
|
+
deps.onSchemaDrift?.({ rawInputKeys: Object.keys(raw) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
counters.autoAllowed++;
|
|
123
|
+
log.info?.("attachment auto-allowed", {
|
|
124
|
+
sessionId: ev.sessionId,
|
|
125
|
+
toolKind: ev.toolKind,
|
|
126
|
+
toolName: ev.toolName,
|
|
127
|
+
locations: ev.locations.map((l) => l.path),
|
|
128
|
+
});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Build a label map from raw attachment rows. Keys: realpath +
|
|
4
|
+
* basename(realpath). Value: `<name> [#<id4>]`.
|
|
5
|
+
*
|
|
6
|
+
* `id` is sliced to 4 chars unconditionally — the post-hoc
|
|
7
|
+
* disambiguation suffix doubles as a stable reference anchor in
|
|
8
|
+
* conversation ("the abc1 file"). No collision detection.
|
|
9
|
+
*/
|
|
10
|
+
export function buildLabelMap(rows) {
|
|
11
|
+
const m = new Map();
|
|
12
|
+
for (const r of rows) {
|
|
13
|
+
const label = `${r.name} [#${r.id.slice(0, 4)}]`;
|
|
14
|
+
m.set(r.realpath, label);
|
|
15
|
+
const bn = basename(r.realpath);
|
|
16
|
+
// Don't shadow an existing realpath entry. Realpaths are
|
|
17
|
+
// absolute so collision with a basename is extremely rare; this
|
|
18
|
+
// is defensive only.
|
|
19
|
+
if (!m.has(bn))
|
|
20
|
+
m.set(bn, label);
|
|
21
|
+
}
|
|
22
|
+
return m;
|
|
23
|
+
}
|
|
24
|
+
/** Substring-replace each map key with its label. Longer keys first
|
|
25
|
+
* so the basename entry doesn't clobber a full-path occurrence. */
|
|
26
|
+
function rewriteString(s, map) {
|
|
27
|
+
if (map.size === 0)
|
|
28
|
+
return s;
|
|
29
|
+
// Sort keys descending by length: ensures e.g. `/a/b/file.pdf`
|
|
30
|
+
// gets replaced before its `file.pdf` basename.
|
|
31
|
+
const keys = [...map.keys()].sort((a, b) => b.length - a.length);
|
|
32
|
+
let out = s;
|
|
33
|
+
for (const k of keys) {
|
|
34
|
+
if (!out.includes(k))
|
|
35
|
+
continue;
|
|
36
|
+
out = out.split(k).join(map.get(k) ?? "");
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Replace internal uuid attachment paths with user-visible labels
|
|
42
|
+
* in the fields users see. Pure: returns a new event object when
|
|
43
|
+
* any field changed, otherwise the original reference.
|
|
44
|
+
*
|
|
45
|
+
* Touched:
|
|
46
|
+
* - `tool_call.title` (substring rewrite)
|
|
47
|
+
* - `tool_call.rawInput.path` (exact-match replace)
|
|
48
|
+
* - `permission_request.title` (substring rewrite)
|
|
49
|
+
*
|
|
50
|
+
* NOT touched (deliberate):
|
|
51
|
+
* - `permission_request.rawInput` — F2 interceptor
|
|
52
|
+
* (`attachment-interceptor.ts`) authoritatively reads this for
|
|
53
|
+
* realpath-equality auto-approve. Mutating it here would silently
|
|
54
|
+
* break the security gate even though enrich runs after the
|
|
55
|
+
* decision today; future read paths must not be poisoned.
|
|
56
|
+
* - `permission_request.locations[].path` — ACP protocol field,
|
|
57
|
+
* not user-visible in our UI.
|
|
58
|
+
* - `user_message.attachments[].displayName` — rendered as
|
|
59
|
+
* `<a class="user-file" download>` link, modality is different.
|
|
60
|
+
* - All other event types — pass through.
|
|
61
|
+
*/
|
|
62
|
+
export function enrichEventForDisplay(event, map) {
|
|
63
|
+
if (map.size === 0)
|
|
64
|
+
return event;
|
|
65
|
+
if (event.type === "tool_call") {
|
|
66
|
+
const newTitle = rewriteString(event.title, map);
|
|
67
|
+
let newRawInput = event.rawInput;
|
|
68
|
+
if (event.rawInput &&
|
|
69
|
+
typeof event.rawInput === "object" &&
|
|
70
|
+
typeof event.rawInput.path === "string") {
|
|
71
|
+
const replaced = map.get(event.rawInput.path);
|
|
72
|
+
if (replaced) {
|
|
73
|
+
newRawInput = { ...event.rawInput, path: replaced };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (newTitle !== event.title || newRawInput !== event.rawInput) {
|
|
77
|
+
return { ...event, title: newTitle, rawInput: newRawInput };
|
|
78
|
+
}
|
|
79
|
+
return event;
|
|
80
|
+
}
|
|
81
|
+
if (event.type === "permission_request") {
|
|
82
|
+
const newTitle = rewriteString(event.title, map);
|
|
83
|
+
if (newTitle !== event.title) {
|
|
84
|
+
return { ...event, title: newTitle };
|
|
85
|
+
}
|
|
86
|
+
return event;
|
|
87
|
+
}
|
|
88
|
+
return event;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* JSON-string variant of `enrichEventForDisplay` for the replay
|
|
92
|
+
* path: `store.getEvents()` returns rows whose `data` is a JSON
|
|
93
|
+
* string and whose `type` lives in a sibling column. Same chokepoint
|
|
94
|
+
* spirit as `reSignAttachmentUrlsInJson` in auth.ts.
|
|
95
|
+
*
|
|
96
|
+
* Mutates `row.data` in place when enrichment changes anything
|
|
97
|
+
* (returning the row unchanged otherwise).
|
|
98
|
+
*/
|
|
99
|
+
export function enrichStoredEventDataForDisplay(type, data, map) {
|
|
100
|
+
if (map.size === 0)
|
|
101
|
+
return data;
|
|
102
|
+
if (type !== "tool_call" && type !== "permission_request")
|
|
103
|
+
return data;
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(data);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
// Synthesize a minimal event shape so we can reuse the object-level
|
|
112
|
+
// enricher. `sessionId` etc. don't matter to enrich logic.
|
|
113
|
+
const ev = { type, ...parsed };
|
|
114
|
+
const out = enrichEventForDisplay(ev, map);
|
|
115
|
+
if (out === ev)
|
|
116
|
+
return data;
|
|
117
|
+
// Strip the synthesized `type` to match the stored shape.
|
|
118
|
+
const { type: _t, ...rest } = out;
|
|
119
|
+
void _t;
|
|
120
|
+
return JSON.stringify(rest);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Replay-path egress chokepoint: rewrites each stored event row's
|
|
124
|
+
* `data` field with attachment labels in place. Mutates rows.
|
|
125
|
+
*
|
|
126
|
+
* Use anywhere `store.getEvents` results are sent to clients
|
|
127
|
+
* (history GET, share viewer). Live SSE goes through
|
|
128
|
+
* `SseManager.sendEvent` which has its own object-level enricher.
|
|
129
|
+
*/
|
|
130
|
+
export function enrichStoredEventsForDisplay(events, map) {
|
|
131
|
+
if (map.size === 0)
|
|
132
|
+
return events;
|
|
133
|
+
for (const ev of events) {
|
|
134
|
+
if (typeof ev.data === "string") {
|
|
135
|
+
ev.data = enrichStoredEventDataForDisplay(ev.type, ev.data, map);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return events;
|
|
139
|
+
}
|