@lelouchhe/webagent 0.9.0 → 0.10.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 +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +64 -41
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- package/lib/title-service.js +0 -95
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{c as g,e as h,n as y,p as T,q as C}from"./chunk.7WADDFJZ.js";import{a as p}from"./chunk.3CLGCUHW.js";import"./chunk.AJZBJBMO.js";var $=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function f(t){return String(t).padStart(2,"0")}function E(t,n){if(!t)return"";let r=new Date(t),i=r.getTime();if(isNaN(i))return"";let l=Math.floor((n.getTime()-i)/1e3);if(l<60)return"just now";let c=Math.floor(l/60);if(c<60)return`${c}m ago`;let o=Math.floor(c/60);if(o<24)return`${o}h ago`;let e=Math.floor(o/24);if(e<7)return`${e}d ago`;let m=r.getUTCFullYear()===n.getUTCFullYear(),s=$[r.getUTCMonth()],a=r.getUTCDate();return m?`${s} ${a}`:`${s} ${a}, ${r.getUTCFullYear()}`}function _(t){if(!t)return"";let n=new Date(t);return isNaN(n.getTime())?"":`${n.getUTCFullYear()}-${f(n.getUTCMonth()+1)}-${f(n.getUTCDate())} ${f(n.getUTCHours())}:${f(n.getUTCMinutes())} UTC`}function S(t){let n=/^\/api\/v1\/tasks\/[^/]+\/attachments\/([A-Za-z0-9._-]+)(?:\?.*)?$/;return r=>{let i=n.exec(r);return i?`/s/${encodeURIComponent(t)}/attachments/${i[1]}`:r}}function w(t){if(typeof t.data=="string")try{return JSON.parse(t.data)}catch{return{}}return t.data}function x(t,n,r,i){if(t.type!=="assistant_message"||n?.type!=="assistant_message"||t.seq!==n.seq+1||!r?.classList.contains("assistant"))return!1;let l=(r.getAttribute("data-raw")??"")+(typeof i.text=="string"?i.text:"");return r.setAttribute("data-raw",l),C(r,l,void 0,!0),g(r),!0}function v(t,n,r){let i=new Set;for(let s of t)if(s.type==="permission_response")try{let a=JSON.parse(s.data);typeof a.requestId=="string"&&i.add(a.requestId)}catch{}let l=new Map,c=new Map,o=null,e=null,m={rewriteAttachmentSrc:S(r),enhanceMarkdown:g,findToolCallEl:s=>l.get(s)??null,findPermissionEl:s=>c.get(s)??null,findBashEl:()=>o,isPermissionResolved:s=>i.has(s),finalAnswerToolText:null};for(let s=0;s<t.length;s++){let a=t[s];if(!y(a.type))continue;let u=w(a),A=s>0?t[s-1]:null,M=n.lastElementChild;if(x(a,A,M,u)){e=null;continue}m.finalAnswerToolText=a.type==="assistant_message"?e:null;let d=T(a.type,u,m);d?(n.appendChild(d),a.type==="tool_call"&&typeof u.id=="string"?l.set(u.id,d):a.type==="permission_request"&&typeof u.requestId=="string"?c.set(u.requestId,d):a.type==="bash_command"&&(o=d)):a.type==="bash_result"&&(o=null),a.type==="tool_call_update"?e=h(typeof u.status=="string"?u.status:"",Array.isArray(u.content)?u.content:void 0):e=null}}async function k(){try{let e=localStorage.getItem("theme")??"auto";document.documentElement.setAttribute("data-theme",e)}catch{}let t=/^\/s\/([A-Za-z0-9_-]{24})(?:[/?#]|$)/.exec(location.pathname);if(!t){document.body.textContent="invalid share URL";return}let n=t[1],r=document.getElementById("task-info"),i=document.getElementById("messages"),l=document.querySelector(".share-footer-author"),c=document.querySelector(".share-footer-meta");if(!i)return;let o;try{let e=await fetch(`/api/v1/shared/${encodeURIComponent(n)}/events`,{headers:{Accept:"application/json"},credentials:"omit"});if(e.status===p.GONE){document.body.innerHTML="<div class='share-gone'>\u6B64\u94FE\u63A5\u5DF2\u64A4\u9500\u6216\u8FC7\u671F\u3002</div>";return}if(!e.ok){document.body.textContent=`error ${e.status}`;return}o=await e.json()}catch(e){document.body.textContent=`failed to load: ${String(e)}`;return}if(document.title=o.share.task_title?`${o.share.task_title} \u2014 shared`:"shared task",r&&(r.textContent=o.share.task_title??"(untitled)"),l){let e=o.share.display_name??"";l.textContent=e?`by ${e}`:""}if(v(o.events,i,n),c){let e=o.share.shared_at??o.share.created_at;c.textContent=E(e,new Date),c instanceof HTMLElement&&(c.title=_(e))}}k();
|
package/dist/login.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
media="(prefers-color-scheme: light)"
|
|
18
18
|
/>
|
|
19
19
|
<script src="/theme-init.js"></script>
|
|
20
|
-
<link rel="stylesheet" href="/styles.
|
|
20
|
+
<link rel="stylesheet" href="/styles.01aj0l37.css" />
|
|
21
21
|
</head>
|
|
22
22
|
<body class="login-page">
|
|
23
23
|
<div class="login-card">
|
package/dist/share-viewer.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>shared
|
|
6
|
+
<title>shared task — >_</title>
|
|
7
7
|
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
|
|
8
8
|
<meta
|
|
9
9
|
name="theme-color"
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
media="(prefers-color-scheme: light)"
|
|
17
17
|
/>
|
|
18
18
|
<meta name="robots" content="noindex, nofollow" />
|
|
19
|
-
<link rel="stylesheet" href="/s/_/styles.
|
|
19
|
+
<link rel="stylesheet" href="/s/_/styles.01aj0l37.css" />
|
|
20
20
|
<link rel="stylesheet" href="/s/_/share-viewer.00gubshk.css" />
|
|
21
|
-
<link rel="modulepreload" href="/s/_/chunk.
|
|
21
|
+
<link rel="modulepreload" href="/s/_/chunk.7WADDFJZ.js">
|
|
22
22
|
<link rel="modulepreload" href="/s/_/chunk.3CLGCUHW.js">
|
|
23
23
|
<link rel="modulepreload" href="/s/_/chunk.AJZBJBMO.js">
|
|
24
24
|
</head>
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
<span class="logo">>_</span>
|
|
29
29
|
<span class="share-badge">shared</span>
|
|
30
30
|
</div>
|
|
31
|
-
<span id="
|
|
31
|
+
<span id="task-info" class="status"></span>
|
|
32
32
|
<div class="header-side header-right">
|
|
33
33
|
<button id="theme-btn" title="Toggle theme">◑</button>
|
|
34
34
|
</div>
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
>
|
|
50
50
|
</div>
|
|
51
51
|
</footer>
|
|
52
|
-
<script type="module" src="/s/_/viewer.
|
|
52
|
+
<script type="module" src="/s/_/viewer.RHZMFYWJ.js"></script>
|
|
53
53
|
</body>
|
|
54
54
|
</html>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
--text: #c9d1d9;
|
|
6
6
|
--text-dim: #6e7681;
|
|
7
7
|
--accent: #58a6ff;
|
|
8
|
+
--system-accent: #bc8cff;
|
|
8
9
|
--green: #3fb950;
|
|
9
10
|
--yellow: #d29922;
|
|
10
11
|
--red: #f85149;
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
--text: #1f2328;
|
|
26
27
|
--text-dim: #656d76;
|
|
27
28
|
--accent: #0969da;
|
|
29
|
+
--system-accent: #8250df;
|
|
28
30
|
--green: #1a7f37;
|
|
29
31
|
--yellow: #9a6700;
|
|
30
32
|
--red: #cf222e;
|
|
@@ -43,6 +45,7 @@
|
|
|
43
45
|
--text: #1f2328;
|
|
44
46
|
--text-dim: #656d76;
|
|
45
47
|
--accent: #0969da;
|
|
48
|
+
--system-accent: #8250df;
|
|
46
49
|
--green: #1a7f37;
|
|
47
50
|
--yellow: #9a6700;
|
|
48
51
|
--red: #cf222e;
|
|
@@ -157,7 +160,7 @@ body {
|
|
|
157
160
|
opacity: 1;
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
|
-
#
|
|
163
|
+
#task-info {
|
|
161
164
|
overflow: hidden;
|
|
162
165
|
text-overflow: ellipsis;
|
|
163
166
|
white-space: nowrap;
|
|
@@ -191,7 +194,7 @@ body {
|
|
|
191
194
|
.header-right {
|
|
192
195
|
margin-left: auto;
|
|
193
196
|
}
|
|
194
|
-
#
|
|
197
|
+
#task-info {
|
|
195
198
|
flex: 1 1 auto;
|
|
196
199
|
text-align: left;
|
|
197
200
|
}
|
|
@@ -428,6 +431,20 @@ body {
|
|
|
428
431
|
color: var(--text-dim);
|
|
429
432
|
white-space: pre-wrap;
|
|
430
433
|
}
|
|
434
|
+
.system-msg.expandable {
|
|
435
|
+
padding-top: 4px;
|
|
436
|
+
padding-bottom: 4px;
|
|
437
|
+
border-left: 2px solid var(--system-accent);
|
|
438
|
+
}
|
|
439
|
+
.system-msg.expandable summary {
|
|
440
|
+
cursor: pointer;
|
|
441
|
+
color: var(--system-accent);
|
|
442
|
+
}
|
|
443
|
+
.system-msg-body {
|
|
444
|
+
margin: 4px 0 2px 16px;
|
|
445
|
+
color: var(--text);
|
|
446
|
+
white-space: pre-wrap;
|
|
447
|
+
}
|
|
431
448
|
.system-msg a {
|
|
432
449
|
color: var(--accent);
|
|
433
450
|
text-decoration: underline;
|
package/dist/sw.js
CHANGED
|
@@ -31,7 +31,7 @@ self.addEventListener("push", (e) => {
|
|
|
31
31
|
|
|
32
32
|
// Backward-compat: older payloads had no `kind`. Treat as notify.
|
|
33
33
|
const { title, body, tag, data } = payload;
|
|
34
|
-
const finalTag = tag || data?.
|
|
34
|
+
const finalTag = tag || data?.taskId || "default";
|
|
35
35
|
e.waitUntil(showNotify(title, body, finalTag, data));
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -79,15 +79,15 @@ self.addEventListener("notificationclick", (e) => {
|
|
|
79
79
|
e.notification.close();
|
|
80
80
|
|
|
81
81
|
const data = e.notification.data || {};
|
|
82
|
-
const
|
|
82
|
+
const taskId = data.taskId;
|
|
83
83
|
const messageId = data.messageId;
|
|
84
|
-
const target =
|
|
85
|
-
? { type: "navigate",
|
|
84
|
+
const target = taskId
|
|
85
|
+
? { type: "navigate", taskId }
|
|
86
86
|
: messageId
|
|
87
87
|
? { type: "navigate", messageId }
|
|
88
88
|
: { type: "navigate" };
|
|
89
|
-
const targetUrl =
|
|
90
|
-
? `/#${encodeURIComponent(
|
|
89
|
+
const targetUrl = taskId
|
|
90
|
+
? `/#${encodeURIComponent(taskId)}`
|
|
91
91
|
: messageId
|
|
92
92
|
? `/?message=${encodeURIComponent(messageId)}`
|
|
93
93
|
: "/";
|
|
@@ -1,33 +1,38 @@
|
|
|
1
1
|
import { readFile, realpath } from "node:fs/promises";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { isUpstreamImageMime } from "./attachments.js";
|
|
4
|
+
import { isInsideTaskAttachments } from "./tasks-anchor.js";
|
|
4
5
|
const NOOP_LOGGER = { warn: () => { } };
|
|
5
6
|
/**
|
|
6
|
-
* Builds the ACP prompt
|
|
7
|
+
* Builds the ACP prompt blocks for one client-supplied attachment. Returns
|
|
7
8
|
* a fallback text block on any failure (DB miss, disk miss, anchor breach,
|
|
8
|
-
* cross-
|
|
9
|
+
* cross-task reference) so the prompt turn never gets stuck in a retry
|
|
9
10
|
* loop just because one image vanished.
|
|
10
11
|
*
|
|
11
12
|
* Trust boundary (decision 10 in uploads-plan v2.6): client only supplies
|
|
12
13
|
* `attachmentId`. Everything else (realpath, anchor, MIME for read) comes
|
|
13
|
-
* from the server-side row
|
|
14
|
+
* from the server-side row — including whether the payload is an image and
|
|
15
|
+
* whether its mime is one the upstream model accepts. A client cannot
|
|
16
|
+
* change the wire format by mislabelling the attachment.
|
|
14
17
|
*/
|
|
15
18
|
export class AttachmentDispatcher {
|
|
16
19
|
store;
|
|
17
|
-
|
|
20
|
+
tasksAnchor;
|
|
18
21
|
logger;
|
|
19
|
-
constructor(store,
|
|
22
|
+
constructor(store, tasksAnchor, logger = NOOP_LOGGER) {
|
|
20
23
|
this.store = store;
|
|
21
|
-
this.
|
|
24
|
+
this.tasksAnchor = tasksAnchor;
|
|
22
25
|
this.logger = logger;
|
|
23
26
|
}
|
|
24
|
-
async dispatch(
|
|
27
|
+
async dispatch(taskId, ref) {
|
|
25
28
|
const fallback = (reason) => {
|
|
26
|
-
this.logger.warn(`[attachments] dispatch fallback (${reason}) for ${
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
this.logger.warn(`[attachments] dispatch fallback (${reason}) for ${taskId}/${ref.attachmentId}`);
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: `[attachment removed: ${ref.displayName}]`,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
31
36
|
};
|
|
32
37
|
// Reject any client trying to smuggle a uri / data / path. The shape
|
|
33
38
|
// of AttachmentRef already forbids these statically; this guard is a
|
|
@@ -38,18 +43,18 @@ export class AttachmentDispatcher {
|
|
|
38
43
|
typeof wider.path === "string") {
|
|
39
44
|
return fallback("client_supplied_external_field");
|
|
40
45
|
}
|
|
41
|
-
const row = this.store.getAttachment(
|
|
46
|
+
const row = this.store.getAttachment(taskId, ref.attachmentId);
|
|
42
47
|
if (!row)
|
|
43
48
|
return fallback("row_not_found");
|
|
44
|
-
// Cross-
|
|
45
|
-
// store.getAttachment scopes by
|
|
49
|
+
// Cross-task reference — the row exists but for a DIFFERENT task.
|
|
50
|
+
// store.getAttachment scopes by task_id so this should already be
|
|
46
51
|
// caught by row_not_found, but assert defensively.
|
|
47
|
-
if (row.
|
|
48
|
-
return fallback("
|
|
52
|
+
if (row.task_id !== taskId) {
|
|
53
|
+
return fallback("cross_task");
|
|
49
54
|
}
|
|
50
55
|
// Anchor check on the stored realpath. If the file was moved out from
|
|
51
56
|
// under us, or a future bug let an attacker inject a row with a path
|
|
52
|
-
// outside
|
|
57
|
+
// outside TASKS_ANCHOR/<sid>/attachments/, we MUST refuse to dispatch
|
|
53
58
|
// it as a `file://` URI — the agent would happily read it.
|
|
54
59
|
let resolvedPath;
|
|
55
60
|
try {
|
|
@@ -58,24 +63,48 @@ export class AttachmentDispatcher {
|
|
|
58
63
|
catch {
|
|
59
64
|
return fallback("realpath_failed");
|
|
60
65
|
}
|
|
61
|
-
if (!
|
|
66
|
+
if (!isInsideTaskAttachments(this.tasksAnchor, taskId, resolvedPath)) {
|
|
62
67
|
return fallback("path_outside_anchor");
|
|
63
68
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
// Storage truth, not the client's `ref.kind`: the browser guesses the
|
|
70
|
+
// mime from the file extension / OS registry (renaming IMG_1040.HEIC to
|
|
71
|
+
// .jpg makes it claim image/jpeg), while `row.kind` is what the sniff on
|
|
72
|
+
// upload produced.
|
|
73
|
+
if (row.kind === "image") {
|
|
74
|
+
if (isUpstreamImageMime(row.mime)) {
|
|
75
|
+
try {
|
|
76
|
+
const buf = await readFile(resolvedPath);
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
type: "image",
|
|
80
|
+
data: buf.toString("base64"),
|
|
81
|
+
mimeType: row.mime,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return fallback("read_failed");
|
|
87
|
+
}
|
|
75
88
|
}
|
|
89
|
+
// Image container the upstream model rejects (heic/heif/avif/bmp/
|
|
90
|
+
// tiff and other `image/*` containers — note an SVG sniffs as text,
|
|
91
|
+
// so it never reaches this branch). Sending it as an image block
|
|
92
|
+
// poisons the session history: the provider 400s on every later turn
|
|
93
|
+
// too. Degrade to a file link plus a hint instead.
|
|
94
|
+
this.logger.warn(`[attachments] image downgraded to resource_link (mime=${row.mime}) for ${taskId}/${ref.attachmentId}`);
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
type: "text",
|
|
98
|
+
text: `[attachment ${row.name} (${row.mime}) cannot be read as an inline image by the model; convert it to png or jpeg to make it readable]`,
|
|
99
|
+
},
|
|
100
|
+
this.resourceLink(row, resolvedPath),
|
|
101
|
+
];
|
|
76
102
|
}
|
|
77
103
|
// kind === "file" → ACP resource_link with file:// URI built from the
|
|
78
104
|
// realpath (NOT from any client-supplied string).
|
|
105
|
+
return [this.resourceLink(row, resolvedPath)];
|
|
106
|
+
}
|
|
107
|
+
resourceLink(row, resolvedPath) {
|
|
79
108
|
return {
|
|
80
109
|
type: "resource_link",
|
|
81
110
|
uri: pathToFileURL(resolvedPath).toString(),
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Plan §1.4 (uploads-plan v2.6, lines 105-183). When an agent requests
|
|
4
4
|
// permission to *read* a path that we know is one of the user-uploaded
|
|
5
|
-
//
|
|
5
|
+
// task attachments, auto-approve with `allow_once`. Any deviation
|
|
6
6
|
// from the strict allowlist falls through to the user prompt.
|
|
7
7
|
//
|
|
8
8
|
// Defenses (mirrored from the plan):
|
|
9
9
|
// F1 kind === "read" only
|
|
10
|
-
// F2 every locations[].path realpath ∈
|
|
10
|
+
// F2 every locations[].path realpath ∈ task attachment realpaths
|
|
11
11
|
// F4 schema gate — locations must exist; if rawInput has known path
|
|
12
12
|
// keys they must also realpath into the attachment set; if it has
|
|
13
13
|
// none, allow but bump schemaDrift counter so we notice when the
|
|
@@ -67,7 +67,7 @@ async function checkRawInputPaths(raw, attachmentRealpaths, miss) {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Returns true iff the request matches the strict
|
|
70
|
-
* "agent reading a known
|
|
70
|
+
* "agent reading a known task attachment" pattern.
|
|
71
71
|
*
|
|
72
72
|
* Returning false does NOT deny the user prompt — it just declines to
|
|
73
73
|
* auto-approve, so the normal permission UI continues to render.
|
|
@@ -79,7 +79,7 @@ export async function shouldAutoApproveAttachmentRead(ev, deps) {
|
|
|
79
79
|
counters.fellThrough++;
|
|
80
80
|
log.debug?.("attachment auto-allow miss", {
|
|
81
81
|
reason,
|
|
82
|
-
|
|
82
|
+
taskId: ev.taskId,
|
|
83
83
|
toolKind: ev.toolKind,
|
|
84
84
|
toolName: ev.toolName,
|
|
85
85
|
});
|
|
@@ -96,11 +96,11 @@ export async function shouldAutoApproveAttachmentRead(ev, deps) {
|
|
|
96
96
|
}
|
|
97
97
|
let attachmentRealpaths;
|
|
98
98
|
try {
|
|
99
|
-
attachmentRealpaths = new Set(deps.listAttachmentRealpaths(ev.
|
|
99
|
+
attachmentRealpaths = new Set(deps.listAttachmentRealpaths(ev.taskId));
|
|
100
100
|
}
|
|
101
101
|
catch (e) {
|
|
102
102
|
log.warn?.("attachment interceptor db error", {
|
|
103
|
-
|
|
103
|
+
taskId: ev.taskId,
|
|
104
104
|
error: e.message,
|
|
105
105
|
});
|
|
106
106
|
return miss("db_error");
|
|
@@ -121,7 +121,7 @@ export async function shouldAutoApproveAttachmentRead(ev, deps) {
|
|
|
121
121
|
}
|
|
122
122
|
counters.autoAllowed++;
|
|
123
123
|
log.info?.("attachment auto-allowed", {
|
|
124
|
-
|
|
124
|
+
taskId: ev.taskId,
|
|
125
125
|
toolKind: ev.toolKind,
|
|
126
126
|
toolName: ev.toolName,
|
|
127
127
|
locations: ev.locations.map((l) => l.path),
|
package/lib/attachment-labels.js
CHANGED
|
@@ -109,7 +109,7 @@ export function enrichStoredEventDataForDisplay(type, data, map) {
|
|
|
109
109
|
return data;
|
|
110
110
|
}
|
|
111
111
|
// Synthesize a minimal event shape so we can reuse the object-level
|
|
112
|
-
// enricher. `
|
|
112
|
+
// enricher. `taskId` etc. don't matter to enrich logic.
|
|
113
113
|
const ev = { type, ...parsed };
|
|
114
114
|
const out = enrichEventForDisplay(ev, map);
|
|
115
115
|
if (out === ev)
|
package/lib/attachments.js
CHANGED
|
@@ -16,6 +16,11 @@ const MIME_TO_EXT = {
|
|
|
16
16
|
"image/jpeg": "jpg",
|
|
17
17
|
"image/gif": "gif",
|
|
18
18
|
"image/webp": "webp",
|
|
19
|
+
"image/heic": "heic",
|
|
20
|
+
"image/heif": "heif",
|
|
21
|
+
"image/avif": "avif",
|
|
22
|
+
"image/bmp": "bmp",
|
|
23
|
+
"image/tiff": "tif",
|
|
19
24
|
"image/svg+xml": "svg",
|
|
20
25
|
"application/pdf": "pdf",
|
|
21
26
|
"text/plain": "txt",
|
|
@@ -130,6 +135,26 @@ const INLINE_MIMES = new Set([
|
|
|
130
135
|
export function isInlineMime(mime) {
|
|
131
136
|
return INLINE_MIMES.has(mime.toLowerCase());
|
|
132
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Image mimes the upstream model accepts as inline ACP image blocks.
|
|
140
|
+
*
|
|
141
|
+
* This is deliberately a SEPARATE set from INLINE_MIMES even though the two
|
|
142
|
+
* currently hold the same values: INLINE_MIMES is a display/XSS policy (which
|
|
143
|
+
* mimes may be served `Content-Disposition: inline` in the browser), while
|
|
144
|
+
* UPSTREAM_IMAGE_MIMES is an upstream compatibility policy (which image
|
|
145
|
+
* containers the model provider accepts on the wire). They answer different
|
|
146
|
+
* questions and will diverge — e.g. upstream support for HEIC would change
|
|
147
|
+
* only this set. Never reference one from the other's check.
|
|
148
|
+
*/
|
|
149
|
+
const UPSTREAM_IMAGE_MIMES = new Set([
|
|
150
|
+
"image/png",
|
|
151
|
+
"image/jpeg",
|
|
152
|
+
"image/gif",
|
|
153
|
+
"image/webp",
|
|
154
|
+
]);
|
|
155
|
+
export function isUpstreamImageMime(mime) {
|
|
156
|
+
return UPSTREAM_IMAGE_MIMES.has(mime.toLowerCase());
|
|
157
|
+
}
|
|
133
158
|
/**
|
|
134
159
|
* Classify an upload as "image" (sent as ACP image block, inlined as base64)
|
|
135
160
|
* or "file" (sent as ACP resource_link). Decision is mime-prefix-based —
|
package/lib/auth-middleware.js
CHANGED
|
@@ -27,14 +27,14 @@ const WHITELIST = [
|
|
|
27
27
|
{ method: "GET", test: (p) => p === "/api/v1/events/stream" },
|
|
28
28
|
{
|
|
29
29
|
method: "GET",
|
|
30
|
-
test: (p) => /^\/api\/v1\/
|
|
30
|
+
test: (p) => /^\/api\/v1\/tasks\/[A-Za-z0-9_-]+\/events\/stream$/.test(p),
|
|
31
31
|
},
|
|
32
32
|
// Image GETs — authenticated via HMAC sig+exp query string (an <img>
|
|
33
33
|
// tag cannot send Authorization headers). The image route handler does
|
|
34
34
|
// its own verification before serving bytes.
|
|
35
35
|
{
|
|
36
36
|
method: "GET",
|
|
37
|
-
test: (p) => /^\/api\/v1\/
|
|
37
|
+
test: (p) => /^\/api\/v1\/tasks\/[A-Za-z0-9_-]+\/attachments\/[A-Za-z0-9._-]+$/.test(p),
|
|
38
38
|
},
|
|
39
39
|
// File viewer content — read-only bytes for one confirmed path,
|
|
40
40
|
// authenticated via HMAC sig+exp query string for the same reason as
|
package/lib/auth.js
CHANGED
|
@@ -75,12 +75,12 @@ export function verifyAttachmentSig(path, expRaw, sigHex, secret) {
|
|
|
75
75
|
return timingSafeEqual(actual, expected);
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
* Rewrite every `/api/v1/
|
|
78
|
+
* Rewrite every `/api/v1/tasks/:id/attachments/:file` URL inside a JSON-serialized
|
|
79
79
|
* payload to carry a fresh `?exp=&sig=`. Applied at egress (history GET, SSE
|
|
80
80
|
* push) so a 1h-old stored URL is re-signed on the way out — the user can
|
|
81
81
|
* reload history days later and images still resolve.
|
|
82
82
|
*/
|
|
83
|
-
const ATTACHMENT_URL_RE = /\/api\/v1\/
|
|
83
|
+
const ATTACHMENT_URL_RE = /\/api\/v1\/tasks\/[A-Za-z0-9_-]+\/attachments\/[A-Za-z0-9._-]+(?:\?(?:exp=\d+&sig=[a-f0-9]+|sig=[a-f0-9]+&exp=\d+))?/g;
|
|
84
84
|
export function reSignAttachmentUrlsInJson(json, secret, ttlSeconds = 3600) {
|
|
85
85
|
return json.replace(ATTACHMENT_URL_RE, (match) => {
|
|
86
86
|
const basePath = match.split("?")[0];
|