@lelouchhe/webagent 0.7.0 → 0.9.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.
@@ -1 +1 @@
1
- import{b as r,c as i}from"./chunk.UMQMOGWO.js";import"./chunk.AJZBJBMO.js";localStorage.getItem(r)&&location.replace("/");var s=document.getElementById("login-form"),n=document.getElementById("token-input"),o=document.getElementById("error"),t=document.getElementById("submit-btn");function u(e){o&&(o.textContent=e,o.style.display=e?"block":"none")}s&&n&&t&&(s.addEventListener("submit",e=>{e.preventDefault(),u(""),t.disabled=!0,t.textContent="Verifying\u2026",(async()=>{try{let l=await i(n.value);l.ok?location.replace("/"):u(l.error)}finally{t.disabled=!1,t.textContent="Sign in"}})()}),n.focus());
1
+ import{b as r,c as i}from"./chunk.3CLGCUHW.js";import"./chunk.AJZBJBMO.js";localStorage.getItem(r)&&location.replace("/");var s=document.getElementById("login-form"),n=document.getElementById("token-input"),o=document.getElementById("error"),t=document.getElementById("submit-btn");function u(e){o&&(o.textContent=e,o.style.display=e?"block":"none")}s&&n&&t&&(s.addEventListener("submit",e=>{e.preventDefault(),u(""),t.disabled=!0,t.textContent="Verifying\u2026",(async()=>{try{let l=await i(n.value);l.ok?location.replace("/"):u(l.error)}finally{t.disabled=!1,t.textContent="Sign in"}})()}),n.focus());
@@ -0,0 +1 @@
1
+ import{c as g,e as h,n as y,p as T,q as C}from"./chunk.S5LRNRJI.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 s=new Date(t),i=s.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=s.getUTCFullYear()===n.getUTCFullYear(),r=$[s.getUTCMonth()],a=s.getUTCDate();return m?`${r} ${a}`:`${r} ${a}, ${s.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\/sessions\/[^/]+\/attachments\/([A-Za-z0-9._-]+)(?:\?.*)?$/;return s=>{let i=n.exec(s);return i?`/s/${encodeURIComponent(t)}/attachments/${i[1]}`:s}}function w(t){if(typeof t.data=="string")try{return JSON.parse(t.data)}catch{return{}}return t.data}function x(t,n,s,i){if(t.type!=="assistant_message"||n?.type!=="assistant_message"||t.seq!==n.seq+1||!s?.classList.contains("assistant"))return!1;let l=(s.getAttribute("data-raw")??"")+(typeof i.text=="string"?i.text:"");return s.setAttribute("data-raw",l),C(s,l,void 0,!0),g(s),!0}function v(t,n,s){let i=new Set;for(let r of t)if(r.type==="permission_response")try{let a=JSON.parse(r.data);typeof a.requestId=="string"&&i.add(a.requestId)}catch{}let l=new Map,c=new Map,o=null,e=null,m={rewriteAttachmentSrc:S(s),enhanceMarkdown:g,findToolCallEl:r=>l.get(r)??null,findPermissionEl:r=>c.get(r)??null,findBashEl:()=>o,isPermissionResolved:r=>i.has(r),finalAnswerToolText:null};for(let r=0;r<t.length;r++){let a=t[r];if(!y(a.type))continue;let u=w(a),A=r>0?t[r-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 U(){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],s=document.getElementById("session-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.session_title?`${o.share.session_title} \u2014 shared`:"shared session",s&&(s.textContent=o.share.session_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))}}U();
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.01ky3cex.css" />
20
+ <link rel="stylesheet" href="/styles.00nlhhf3.css" />
21
21
  </head>
22
22
  <body class="login-page">
23
23
  <div class="login-card">
@@ -44,6 +44,6 @@
44
44
  <code>webagent --create-token &lt;name&gt;</code> on the server.
45
45
  </p>
46
46
  </div>
47
- <script type="module" src="/js/login.2WA6DTGM.js"></script>
47
+ <script type="module" src="/js/login.WMURU4NI.js"></script>
48
48
  </body>
49
49
  </html>
@@ -16,10 +16,10 @@
16
16
  media="(prefers-color-scheme: light)"
17
17
  />
18
18
  <meta name="robots" content="noindex, nofollow" />
19
- <link rel="stylesheet" href="/s/_/styles.01ky3cex.css" />
19
+ <link rel="stylesheet" href="/s/_/styles.00nlhhf3.css" />
20
20
  <link rel="stylesheet" href="/s/_/share-viewer.00gubshk.css" />
21
- <link rel="modulepreload" href="/s/_/chunk.3CCHZCG7.js">
22
- <link rel="modulepreload" href="/s/_/chunk.UMQMOGWO.js">
21
+ <link rel="modulepreload" href="/s/_/chunk.S5LRNRJI.js">
22
+ <link rel="modulepreload" href="/s/_/chunk.3CLGCUHW.js">
23
23
  <link rel="modulepreload" href="/s/_/chunk.AJZBJBMO.js">
24
24
  </head>
25
25
  <body class="share-viewer">
@@ -49,6 +49,6 @@
49
49
  >
50
50
  </div>
51
51
  </footer>
52
- <script type="module" src="/s/_/viewer.T32M5AZZ.js"></script>
52
+ <script type="module" src="/s/_/viewer.GP5VXAUY.js"></script>
53
53
  </body>
54
54
  </html>
@@ -84,6 +84,32 @@ body {
84
84
  font-weight: bold;
85
85
  color: var(--green);
86
86
  }
87
+ #inbox-btn {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ gap: 4px;
91
+ min-height: 28px;
92
+ padding: 2px 6px;
93
+ margin-left: -6px;
94
+ border: 0;
95
+ background: transparent;
96
+ color: inherit;
97
+ font: inherit;
98
+ cursor: pointer;
99
+ }
100
+ #inbox-btn:hover .logo,
101
+ #inbox-btn:focus-visible .logo {
102
+ color: var(--text);
103
+ }
104
+ #inbox-btn:focus-visible {
105
+ outline: 1px solid var(--text-dim);
106
+ outline-offset: 1px;
107
+ }
108
+ #inbox-count {
109
+ color: var(--red);
110
+ font-size: 13px;
111
+ font-weight: 600;
112
+ }
87
113
  .header-side {
88
114
  min-width: 0;
89
115
  display: flex;
@@ -139,7 +165,8 @@ body {
139
165
  width: 100%;
140
166
  text-align: center;
141
167
  }
142
- #theme-btn {
168
+ #theme-btn,
169
+ #file-viewer-close {
143
170
  background: transparent;
144
171
  border: none;
145
172
  color: var(--text-dim);
@@ -148,7 +175,8 @@ body {
148
175
  font-family: inherit;
149
176
  padding: 2px 6px;
150
177
  }
151
- #theme-btn:hover {
178
+ #theme-btn:hover,
179
+ #file-viewer-close:hover {
152
180
  color: var(--text);
153
181
  }
154
182
  @media (max-width: 640px) {
@@ -312,6 +340,22 @@ body {
312
340
  font-weight: normal;
313
341
  }
314
342
 
343
+ .msg.assistant .subagent-result {
344
+ color: var(--text-dim);
345
+ font-size: 14px;
346
+ margin: 2px 0;
347
+ }
348
+ .msg.assistant .subagent-result summary {
349
+ cursor: pointer;
350
+ }
351
+ .msg.assistant .subagent-result-content {
352
+ color: var(--text);
353
+ margin: 4px 0 4px 16px;
354
+ }
355
+ .msg.assistant .assistant-continuation {
356
+ margin-top: 4px;
357
+ }
358
+
315
359
  /* Thinking */
316
360
  .thinking {
317
361
  color: var(--text-dim);
@@ -494,6 +538,12 @@ body {
494
538
  margin-bottom: 2px;
495
539
  cursor: pointer;
496
540
  }
541
+ .plan-summary::marker {
542
+ content: "";
543
+ }
544
+ .plan-summary::-webkit-details-marker {
545
+ display: none;
546
+ }
497
547
  .plan-counts {
498
548
  color: var(--text-dim);
499
549
  font-weight: normal;
@@ -505,6 +555,28 @@ body {
505
555
  .plan-entry {
506
556
  padding: 1px 0;
507
557
  }
558
+ .plan-status-pending {
559
+ color: var(--text-dim);
560
+ }
561
+ .plan-status-in_progress .plan-symbol,
562
+ .plan-counts .plan-status-in_progress {
563
+ color: var(--blue);
564
+ }
565
+ .plan-status-in_progress .plan-symbol {
566
+ display: inline-block;
567
+ background: var(--accent-soft);
568
+ border-radius: 2px;
569
+ }
570
+ .plan-status-completed .plan-symbol,
571
+ .plan-counts .plan-status-completed {
572
+ color: var(--green);
573
+ }
574
+ .plan-status-completed .plan-content {
575
+ color: var(--text-dim);
576
+ }
577
+ .plan-status-unknown {
578
+ color: var(--text-dim);
579
+ }
508
580
 
509
581
  /* Permission dialog */
510
582
  .permission {
@@ -678,7 +750,25 @@ body {
678
750
  #status-bar::before {
679
751
  content: "\200b";
680
752
  }
753
+ #status-bar .status-model {
754
+ flex: 0 0 auto;
755
+ }
756
+ #status-bar .status-separator,
757
+ #status-bar .status-usage {
758
+ flex: 0 0 auto;
759
+ }
681
760
  #status-bar .status-cwd {
761
+ display: flex;
762
+ flex: 1 1 auto;
763
+ min-width: 0;
764
+ overflow: hidden;
765
+ }
766
+ #status-bar .status-cwd-prefix {
767
+ flex: 0 0 auto;
768
+ }
769
+ #status-bar .status-cwd-tail {
770
+ flex: 1 1 auto;
771
+ min-width: 0;
682
772
  overflow: hidden;
683
773
  text-overflow: ellipsis;
684
774
  direction: rtl;
@@ -795,6 +885,30 @@ body {
795
885
  }
796
886
  }
797
887
 
888
+ /* Panels attached to the input */
889
+ .input-panel {
890
+ background: var(--surface);
891
+ border: 1px solid var(--border);
892
+ border-bottom: none;
893
+ max-height: 220px;
894
+ }
895
+ #plan-panel {
896
+ flex: 0 0 auto;
897
+ }
898
+ #plan-panel[hidden] {
899
+ display: none;
900
+ }
901
+ #plan-panel > .plan-summary {
902
+ margin: 0;
903
+ padding: 6px 16px;
904
+ }
905
+ #plan-panel > .plan-entries {
906
+ max-height: 180px;
907
+ margin: 0;
908
+ padding: 0 16px 6px;
909
+ overflow-y: auto;
910
+ }
911
+
798
912
  /* Slash command autocomplete */
799
913
  #slash-menu {
800
914
  display: none;
@@ -802,10 +916,6 @@ body {
802
916
  bottom: 100%;
803
917
  left: 0;
804
918
  right: 0;
805
- background: var(--surface);
806
- border: 1px solid var(--border);
807
- border-bottom: none;
808
- max-height: 220px;
809
919
  overflow-y: auto;
810
920
  z-index: 10;
811
921
  }
@@ -900,6 +1010,151 @@ body {
900
1010
  cursor: default;
901
1011
  }
902
1012
 
1013
+ /* Read-only file viewer */
1014
+ #file-viewer[hidden] {
1015
+ display: none;
1016
+ }
1017
+ #file-viewer {
1018
+ position: fixed;
1019
+ inset: 0;
1020
+ z-index: 100;
1021
+ min-width: 0;
1022
+ background: var(--bg);
1023
+ color: var(--text);
1024
+ display: flex;
1025
+ flex-direction: column;
1026
+ }
1027
+ body.file-viewer-open {
1028
+ overflow: hidden;
1029
+ }
1030
+ .file-viewer-header {
1031
+ min-height: 41px;
1032
+ padding: 6px 16px;
1033
+ background: var(--surface);
1034
+ border-bottom: 1px solid var(--border);
1035
+ display: flex;
1036
+ align-items: center;
1037
+ gap: 12px;
1038
+ font-size: 15px;
1039
+ }
1040
+ #file-viewer-path {
1041
+ min-width: 0;
1042
+ flex: 1;
1043
+ overflow: hidden;
1044
+ text-overflow: ellipsis;
1045
+ white-space: nowrap;
1046
+ direction: ltr;
1047
+ text-align: left;
1048
+ color: var(--text-dim);
1049
+ font-size: inherit;
1050
+ }
1051
+ #file-viewer-close {
1052
+ flex: 0 0 auto;
1053
+ }
1054
+ #file-viewer-notice {
1055
+ flex: 0 0 auto;
1056
+ padding: 6px 14px;
1057
+ border-bottom: 1px solid var(--border);
1058
+ color: var(--yellow);
1059
+ font-size: 13px;
1060
+ }
1061
+ #file-viewer-notice[hidden] {
1062
+ display: none;
1063
+ }
1064
+ #file-viewer-content {
1065
+ flex: 1 1 auto;
1066
+ min-height: 0;
1067
+ overflow: auto;
1068
+ padding-bottom: env(safe-area-inset-bottom);
1069
+ }
1070
+ .file-viewer-markdown {
1071
+ width: 100%;
1072
+ min-height: 100%;
1073
+ }
1074
+ .file-viewer-image {
1075
+ display: block;
1076
+ max-width: 100%;
1077
+ max-height: 100%;
1078
+ margin: auto;
1079
+ padding: 12px;
1080
+ object-fit: contain;
1081
+ }
1082
+ .file-viewer-code-scroll {
1083
+ min-height: 100%;
1084
+ min-width: max-content;
1085
+ }
1086
+ .file-viewer-code-grid {
1087
+ display: grid;
1088
+ grid-template-columns: max-content minmax(max-content, 1fr);
1089
+ width: max-content;
1090
+ min-width: 100%;
1091
+ min-height: 100%;
1092
+ }
1093
+ .file-viewer-line-numbers,
1094
+ .file-viewer-code {
1095
+ margin: 0;
1096
+ padding: 10px 12px;
1097
+ border: 0;
1098
+ border-radius: 0;
1099
+ background: var(--bg);
1100
+ color: var(--text);
1101
+ font:
1102
+ 14px/1.55 "SF Mono",
1103
+ "Fira Code",
1104
+ monospace;
1105
+ white-space: pre;
1106
+ }
1107
+ .file-viewer-line-numbers {
1108
+ position: sticky;
1109
+ left: 0;
1110
+ z-index: 1;
1111
+ padding-right: 8px;
1112
+ background: var(--surface);
1113
+ border-right: 1px solid var(--border);
1114
+ color: var(--text-dim);
1115
+ text-align: right;
1116
+ user-select: none;
1117
+ }
1118
+ .file-viewer-code {
1119
+ padding-left: 12px;
1120
+ }
1121
+ .file-viewer-code code,
1122
+ .file-viewer-code code.hljs {
1123
+ padding: 0;
1124
+ background: transparent;
1125
+ font: inherit;
1126
+ }
1127
+ .file-viewer-binary-meta {
1128
+ padding: 18px 14px 10px;
1129
+ color: var(--text-dim);
1130
+ overflow-wrap: anywhere;
1131
+ }
1132
+ .file-viewer-download {
1133
+ display: inline-flex;
1134
+ min-height: 44px;
1135
+ margin: 0 14px;
1136
+ padding: 8px 12px;
1137
+ border: 1px solid var(--border);
1138
+ border-radius: var(--radius);
1139
+ align-items: center;
1140
+ color: var(--accent);
1141
+ text-decoration: none;
1142
+ }
1143
+ .file-viewer-download:hover,
1144
+ .file-viewer-download:focus-visible {
1145
+ border-color: var(--accent);
1146
+ }
1147
+ @media (min-width: 1024px) {
1148
+ body.file-viewer-open {
1149
+ padding-right: min(55vw, 800px);
1150
+ }
1151
+ #file-viewer {
1152
+ left: auto;
1153
+ width: min(55vw, 800px);
1154
+ border-left: 1px solid var(--border);
1155
+ }
1156
+ }
1157
+
903
1158
  /* Image attach */
904
1159
  #attach-preview {
905
1160
  display: none;
package/dist/sw.js CHANGED
@@ -79,25 +79,35 @@ self.addEventListener("notificationclick", (e) => {
79
79
  e.notification.close();
80
80
 
81
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
82
  const sessionId = data.sessionId;
85
- const urlHash = sessionId ? `/#${sessionId}` : "/";
83
+ const messageId = data.messageId;
84
+ const target = sessionId
85
+ ? { type: "navigate", sessionId }
86
+ : messageId
87
+ ? { type: "navigate", messageId }
88
+ : { type: "navigate" };
89
+ const targetUrl = sessionId
90
+ ? `/#${encodeURIComponent(sessionId)}`
91
+ : messageId
92
+ ? `/?message=${encodeURIComponent(messageId)}`
93
+ : "/";
86
94
 
87
95
  e.waitUntil(
88
96
  self.clients
89
97
  .matchAll({ type: "window", includeUncontrolled: true })
90
- .then((clients) => {
91
- // Focus existing window if open
98
+ .then(async (clients) => {
92
99
  for (const client of clients) {
93
100
  if (client.url.includes(self.location.origin)) {
94
- client.focus();
95
- client.postMessage({ type: "navigate", sessionId });
101
+ try {
102
+ await client.focus();
103
+ } catch {
104
+ // Navigation still works if the browser refuses focus.
105
+ }
106
+ client.postMessage(target);
96
107
  return;
97
108
  }
98
109
  }
99
- // Otherwise open a new window
100
- return self.clients.openWindow(urlHash);
110
+ return self.clients.openWindow(targetUrl);
101
111
  }),
102
112
  );
103
113
  });
@@ -0,0 +1,6 @@
1
+ export function agentKeyFromCommand(agentCmd) {
2
+ const key = agentCmd.trim().split(/\s+/, 1)[0];
3
+ if (!key)
4
+ throw new Error("ACP agent command is empty");
5
+ return key;
6
+ }
@@ -70,14 +70,51 @@ function looksLikeUtf8Text(buf) {
70
70
  if (byte === 0)
71
71
  return false;
72
72
  }
73
- try {
74
- const decoder = new TextDecoder("utf-8", { fatal: true });
75
- decoder.decode(buf);
76
- return true;
77
- }
78
- catch {
79
- return false;
73
+ return decodesAsUtf8ToleratingTailTruncation(buf);
74
+ }
75
+ /**
76
+ * Maximum bytes one UTF-8 code point can occupy. A TRAILING code point that
77
+ * was cut off by the sampling window contributes at most one byte less than
78
+ * a full character (a complete 4-byte character would decode), so backing up
79
+ * `MAX_UTF8_CHAR_BYTES - 1` bytes always reaches the preceding boundary.
80
+ */
81
+ const MAX_UTF8_CHAR_BYTES = 4;
82
+ /**
83
+ * Whether `buf` decodes cleanly as UTF-8, allowing its FINAL multi-byte
84
+ * character to be cut off at the buffer end.
85
+ *
86
+ * Why the tolerance: sniffMime only receives the first 4096 bytes of a
87
+ * larger file (the viewer's `SNIFF_BYTES` window in src/files/routes.ts and
88
+ * the upload-pipeline head). Content dense in multi-byte characters —
89
+ * Chinese, Japanese, emoji, ... — regularly leaves that window slicing
90
+ * clean through a character. A strict whole-buffer fatal decode then
91
+ * misclassifies perfectly valid UTF-8 text as binary, so the viewer
92
+ * force-downloads it instead of previewing it (real-world regression:
93
+ * ~100 of the markdown notes under ~/mine/space downloaded instead of
94
+ * rendering, even though every one of them was valid UTF-8).
95
+ *
96
+ * This relaxes ONLY the tail: a truncated trailing code point occupies at
97
+ * most 3 bytes of the buffer, so backing up at most 3 bytes reaches a
98
+ * character boundary — and the loop never trims the whole buffer, because an
99
+ * empty prefix would trivially "decode" and would turn short garbage buffers
100
+ * into text. NUL bytes and genuinely invalid sequences elsewhere fail every
101
+ * prefix attempt and still fall through to application/octet-stream — the
102
+ * "binary stays binary" security posture of the sniff is unchanged.
103
+ */
104
+ function decodesAsUtf8ToleratingTailTruncation(buf) {
105
+ for (let end = buf.length; end >= Math.max(1, buf.length - (MAX_UTF8_CHAR_BYTES - 1)); end--) {
106
+ try {
107
+ // Fresh decoder per attempt: reuse-after-throw is not part of the
108
+ // TextDecoder contract across implementations.
109
+ new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, end));
110
+ return true;
111
+ }
112
+ catch {
113
+ // Cut deeper into the tail. Only a trailing truncated character can
114
+ // make a later attempt succeed — real corruption fails all of them.
115
+ }
80
116
  }
117
+ return false;
81
118
  }
82
119
  /**
83
120
  * Mimes we are willing to render inline in <img>. Everything else is forced
@@ -1,5 +1,6 @@
1
- // All whitelisted paths must be GETs that read non-sensitive data, or static
2
- // assets needed before the user can present a token.
1
+ // All whitelisted paths must be GETs that either read non-sensitive data,
2
+ // serve static assets needed before the user can present a token, or enforce
3
+ // independent ticket/HMAC authentication inside their route handler.
3
4
  const WHITELIST = [
4
5
  // Public probes
5
6
  { method: "GET", test: (p) => p === "/api/v1/version" },
@@ -35,6 +36,12 @@ const WHITELIST = [
35
36
  method: "GET",
36
37
  test: (p) => /^\/api\/v1\/sessions\/[A-Za-z0-9_-]+\/attachments\/[A-Za-z0-9._-]+$/.test(p),
37
38
  },
39
+ // File viewer content — read-only bytes for one confirmed path,
40
+ // authenticated via HMAC sig+exp query string for the same reason as
41
+ // image GETs above (media tags / downloads cannot send Authorization
42
+ // headers). The content handler verifies the signature before serving:
43
+ // without a valid sig+exp it returns 401, never bytes.
44
+ { method: "GET", test: (p) => p === "/api/v1/files/content" },
38
45
  // --- Share viewer (public read-only snapshots) ---
39
46
  // Viewer HTML shell + image proxy + viewer-namespaced static assets
40
47
  // (CSS/JS) all live under /s/* — see src/share/routes.ts. The auth gate