@1agh/maude 0.56.0 → 0.57.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/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +47 -30
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/dist/client.bundle.js +1278 -1278
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +51 -2
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
|
@@ -21,6 +21,11 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
|
|
|
21
21
|
// canvas-cursors.ts above.
|
|
22
22
|
import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
|
|
23
23
|
import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
24
|
+
// The single "what is the hub link doing" rule, shared with the cloud rail's
|
|
25
|
+
// connect note so the two surfaces can never disagree. Pure data + strings —
|
|
26
|
+
// same "pull only pure logic into the client bundle" shape as the imports
|
|
27
|
+
// above (a type-only SyncStatusSnapshot import that Bun erases).
|
|
28
|
+
import { syncPresentation } from '../sync/presentation.ts';
|
|
24
29
|
import { canvasUrl } from './canvas-url.js';
|
|
25
30
|
import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
|
|
26
31
|
import { useTreeDrag } from './use-tree-drag.js';
|
|
@@ -77,6 +82,7 @@ const Lu = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{
|
|
|
77
82
|
import { PhotoKnobs } from './photo-knobs.jsx';
|
|
78
83
|
import {
|
|
79
84
|
appIsFirstRun,
|
|
85
|
+
invoke,
|
|
80
86
|
isNativeApp,
|
|
81
87
|
onMenuReportBug,
|
|
82
88
|
onUpdateReady,
|
|
@@ -203,14 +209,30 @@ const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__
|
|
|
203
209
|
// handleAssistantAttention below (identical support/permission check +
|
|
204
210
|
// try/catch, only the title/body differ). The in-app badge stays the
|
|
205
211
|
// reliable signal if this silently fails for any reason.
|
|
212
|
+
//
|
|
213
|
+
// feature-acp-turn-notifications Task 6 — under Tauri, route through the
|
|
214
|
+
// native `send_notification` command (notify.rs) instead of the Web `Notification` API.
|
|
215
|
+
// This is the SAME native notifier the shell's cross-project poller uses for
|
|
216
|
+
// every OTHER project, so the visible project now goes through the identical
|
|
217
|
+
// OS-level path rather than a webview API whose behavior inside WKWebView was
|
|
218
|
+
// never empirically confirmed (see Task 1's finding in the plan). Falls back
|
|
219
|
+
// to the Web API on any failure — an older desktop build without the command,
|
|
220
|
+
// or a plain browser tab — so the signal degrades rather than disappearing.
|
|
206
221
|
function notifyDesktop(title, body) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
222
|
+
const webFallback = () => {
|
|
223
|
+
try {
|
|
224
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
225
|
+
new Notification(title, { body });
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
/* best-effort — the in-app badge is the reliable signal */
|
|
210
229
|
}
|
|
211
|
-
}
|
|
212
|
-
|
|
230
|
+
};
|
|
231
|
+
if (isNativeApp()) {
|
|
232
|
+
invoke('send_notification', { title, body }).catch(webFallback);
|
|
233
|
+
return;
|
|
213
234
|
}
|
|
235
|
+
webFallback();
|
|
214
236
|
}
|
|
215
237
|
|
|
216
238
|
function readInitialTheme() {
|
|
@@ -2473,6 +2495,9 @@ function Sidebar({
|
|
|
2473
2495
|
onMoveCanvas,
|
|
2474
2496
|
onNewFolder,
|
|
2475
2497
|
onDeleteFolder,
|
|
2498
|
+
// Passed through to CloudBar only — the live `sync:status` payload that makes
|
|
2499
|
+
// the connect note follow the link instead of freezing at attach time.
|
|
2500
|
+
syncStatus,
|
|
2476
2501
|
}) {
|
|
2477
2502
|
const filteredGroups = useMemo(() => {
|
|
2478
2503
|
if (!search) return groups;
|
|
@@ -2846,7 +2871,7 @@ function Sidebar({
|
|
|
2846
2871
|
Absent rather than disabled, because unlike the agent chat there is
|
|
2847
2872
|
nothing here to explain — the capability is not missing, it is
|
|
2848
2873
|
already satisfied. */}
|
|
2849
|
-
{cloud === null ? <CloudBar /> : null}
|
|
2874
|
+
{cloud === null ? <CloudBar syncStatus={syncStatus} /> : null}
|
|
2850
2875
|
{/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
|
|
2851
2876
|
sign in, connected account + New/Pull/Share, sign out. Self-contained
|
|
2852
2877
|
(owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
|
|
@@ -4537,6 +4562,7 @@ function StatusBar({
|
|
|
4537
4562
|
onToggleTheme,
|
|
4538
4563
|
onClearSelected,
|
|
4539
4564
|
syncStatus,
|
|
4565
|
+
syncProject,
|
|
4540
4566
|
changesCount = 0,
|
|
4541
4567
|
unpushed = 0,
|
|
4542
4568
|
changesOpen = false,
|
|
@@ -4559,32 +4585,21 @@ function StatusBar({
|
|
|
4559
4585
|
// shapes: solo `{linked:false}` (hide), DDR-060 `{notSyncable,tsxCount,reason}`
|
|
4560
4586
|
// (linked but 0 syncable), or the connection-state machine `{state,queuedOps,
|
|
4561
4587
|
// flash,…}` (the common linked case the old notSyncable-only guard never
|
|
4562
|
-
// showed — the "hub sync se neukazuje" bug).
|
|
4588
|
+
// showed — the "hub sync se neukazuje" bug).
|
|
4589
|
+
//
|
|
4590
|
+
// The mapping used to live here, and it referenced `docs` ZERO times: it read
|
|
4591
|
+
// `state` alone, so a link whose every document the hub had refused still
|
|
4592
|
+
// showed a green dot and the word "synced". `syncPresentation` is now the one
|
|
4593
|
+
// rule, shared with the cloud rail's connect note, so the two surfaces cannot
|
|
4594
|
+
// give a person different answers about the same payload.
|
|
4563
4595
|
const syncSlot = (() => {
|
|
4564
|
-
|
|
4565
|
-
if (
|
|
4566
|
-
|
|
4567
|
-
online: false,
|
|
4568
|
-
label: `0 syncable${syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}`,
|
|
4569
|
-
title: syncStatus.reason || 'Linked to a hub, but no canvases are syncable.',
|
|
4570
|
-
};
|
|
4571
|
-
}
|
|
4572
|
-
const q = syncStatus.queuedOps ?? 0;
|
|
4573
|
-
const synced = syncStatus.state === 'online' || syncStatus.flash === 'synced';
|
|
4574
|
-
if (synced) {
|
|
4575
|
-
return {
|
|
4576
|
-
online: true,
|
|
4577
|
-
label: q > 0 ? `${q} ↑` : 'synced',
|
|
4578
|
-
title: q > 0 ? `${q} edit(s) queued to push` : 'All changes synced to the hub',
|
|
4579
|
-
};
|
|
4580
|
-
}
|
|
4596
|
+
const p = syncPresentation(syncStatus, { project: syncProject });
|
|
4597
|
+
if (!p) return null;
|
|
4598
|
+
const detail = p.names.length ? ` (${p.names.join(', ')})` : '';
|
|
4581
4599
|
return {
|
|
4582
|
-
online:
|
|
4583
|
-
label:
|
|
4584
|
-
title:
|
|
4585
|
-
syncStatus.state === 'connecting'
|
|
4586
|
-
? 'Connecting to the hub…'
|
|
4587
|
-
: 'Offline — edits queued, will sync when the hub reconnects',
|
|
4600
|
+
online: p.online,
|
|
4601
|
+
label: p.label,
|
|
4602
|
+
title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
|
|
4588
4603
|
};
|
|
4589
4604
|
})();
|
|
4590
4605
|
|
|
@@ -14087,6 +14102,7 @@ function App() {
|
|
|
14087
14102
|
remoteSync={remoteSync}
|
|
14088
14103
|
onGetLatest={gitGetLatest}
|
|
14089
14104
|
canvasKinds={cfg?.canvasKinds}
|
|
14105
|
+
syncStatus={syncStatus}
|
|
14090
14106
|
/>
|
|
14091
14107
|
);
|
|
14092
14108
|
if (id === 'changes')
|
|
@@ -15147,6 +15163,7 @@ function App() {
|
|
|
15147
15163
|
onToggleTheme={toggleTheme}
|
|
15148
15164
|
onClearSelected={clearSelected}
|
|
15149
15165
|
syncStatus={syncStatus}
|
|
15166
|
+
syncProject={cfg?.cloud?.projectName || project}
|
|
15150
15167
|
changesCount={unsavedCount}
|
|
15151
15168
|
unpushed={gitStatus?.unpushed || 0}
|
|
15152
15169
|
changesOpen={changesOpen}
|
|
@@ -1855,6 +1855,11 @@ export default function ChatPanel({
|
|
|
1855
1855
|
const connsRef = useRef(new Map()); // chatId → connection
|
|
1856
1856
|
const busyRef = useRef(new Map()); // chatId → busy (aggregated for the menubar)
|
|
1857
1857
|
const hydratedRef = useRef(new Map()); // chatId → initial messages
|
|
1858
|
+
// chatId → the transcript LINE the hydration above covered (the
|
|
1859
|
+
// `X-Maude-Chat-Seq` response header). Handed to the connection's `attach`
|
|
1860
|
+
// so the server replays only what the client missed — Addendum Task 8's
|
|
1861
|
+
// re-attach seam. Absent/0 for a chat that was never hydrated (a new chat).
|
|
1862
|
+
const hydratedSeqRef = useRef(new Map());
|
|
1858
1863
|
const [openChatIds, setOpenChatIds] = useState([]);
|
|
1859
1864
|
const [activeChatId, setActiveChatId] = useState(null);
|
|
1860
1865
|
const [busyChats, setBusyChats] = useState({}); // reactive: chatId → busy (for the dot)
|
|
@@ -1872,6 +1877,13 @@ export default function ChatPanel({
|
|
|
1872
1877
|
const existing = connsRef.current.get(chatId);
|
|
1873
1878
|
if (existing) return existing;
|
|
1874
1879
|
const conn = createAcpConnection();
|
|
1880
|
+
// Addendum Task 8 — bind BEFORE anything can open the socket. The `attach`
|
|
1881
|
+
// frame goes out from `onopen` and is what subscribes this socket to the
|
|
1882
|
+
// chat's server-side bridge (which now outlives its socket, so it may
|
|
1883
|
+
// already be mid-turn from before a reload). `hydratedSeqRef` holds the
|
|
1884
|
+
// transcript line the history fetch covered, so the server replays exactly
|
|
1885
|
+
// the gap — no duplicated tail, no hole.
|
|
1886
|
+
conn.bindChat(chatId, hydratedSeqRef.current.get(chatId) || 0);
|
|
1875
1887
|
connsRef.current.set(chatId, conn);
|
|
1876
1888
|
// Agent-generated title (session_info_update — fires at turn-end, so a
|
|
1877
1889
|
// fresh chat shows the client's "New chat" heuristic until the first
|
|
@@ -1949,7 +1961,12 @@ export default function ChatPanel({
|
|
|
1949
1961
|
return;
|
|
1950
1962
|
}
|
|
1951
1963
|
fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`)
|
|
1952
|
-
.then((r) =>
|
|
1964
|
+
.then(async (r) => {
|
|
1965
|
+
// Capture the seam marker alongside the body — see hydratedSeqRef.
|
|
1966
|
+
const seq = Number(r.headers.get('X-Maude-Chat-Seq') || 0);
|
|
1967
|
+
hydratedSeqRef.current.set(id, Number.isFinite(seq) ? seq : 0);
|
|
1968
|
+
return r.json();
|
|
1969
|
+
})
|
|
1953
1970
|
.then((msgs) => openChat(id, toThreadMessages(msgs)))
|
|
1954
1971
|
.catch(() => {});
|
|
1955
1972
|
},
|
|
@@ -1965,6 +1982,7 @@ export default function ChatPanel({
|
|
|
1965
1982
|
connsRef.current.delete(id);
|
|
1966
1983
|
busyRef.current.delete(id);
|
|
1967
1984
|
hydratedRef.current.delete(id);
|
|
1985
|
+
hydratedSeqRef.current.delete(id);
|
|
1968
1986
|
setBusyChats((prev) => {
|
|
1969
1987
|
const next = { ...prev };
|
|
1970
1988
|
delete next[id];
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { useEffect, useRef, useState } from 'react';
|
|
16
16
|
|
|
17
|
+
import { safeName, syncPresentation } from '../../sync/presentation.ts';
|
|
17
18
|
import { invoke, isNativeApp, listen, openCloudUrl } from '../github.js';
|
|
18
19
|
|
|
19
20
|
const api = async (path, init) => {
|
|
@@ -261,24 +262,56 @@ export function localIdentityHint({
|
|
|
261
262
|
* Returns `{ text, title }` — `text` is the rail's one line, `title` the full
|
|
262
263
|
* sentence on hover (the rail truncates).
|
|
263
264
|
*/
|
|
264
|
-
export function connectOutcomeNote(project, sync) {
|
|
265
|
-
|
|
265
|
+
export function connectOutcomeNote(project, sync, live) {
|
|
266
|
+
// The project name and `sync.detail` both originate at the hub, and the
|
|
267
|
+
// fallback path below puts them in trusted app chrome uncapped — including a
|
|
268
|
+
// `title=` tooltip, where a newline renders. `safeName` strips control and
|
|
269
|
+
// bidi-override characters, collapses whitespace and bounds the length; the
|
|
270
|
+
// live path already went through it inside `syncPresentation`.
|
|
271
|
+
const name = safeName(project, 'the workspace');
|
|
272
|
+
|
|
273
|
+
// The LIVE payload wins whenever there is one.
|
|
274
|
+
//
|
|
275
|
+
// `sync` is the attach response, and it reports an intention: the supervisor
|
|
276
|
+
// answers `{ syncing: true }` as soon as `runtime.start()` did not throw and
|
|
277
|
+
// one local canvas qualified — before a socket, before a token was accepted,
|
|
278
|
+
// before a byte moved. Rendered once and never updated, it was the whole of
|
|
279
|
+
// the reported complaint ("vyskočí modal, pak vidím jen syncing canvases, ale
|
|
280
|
+
// reálně se nic nestane"). `live` comes off the `sync:status` bus that is
|
|
281
|
+
// already wired to this app, so the same sentence now MOVES: connecting →
|
|
282
|
+
// syncing 40/75 → synced. That motion is the feedback that was missing.
|
|
283
|
+
//
|
|
284
|
+
// Same rule as the status bar (`syncPresentation`), deliberately — the old
|
|
285
|
+
// hover text told the person to go look at that slot for the real answer,
|
|
286
|
+
// which only worked as long as the two agreed, and they did not.
|
|
287
|
+
if (live) {
|
|
288
|
+
const p = syncPresentation(live, { project: name });
|
|
289
|
+
if (p) {
|
|
290
|
+
const detail = p.names.length ? ` (${p.names.join(', ')})` : '';
|
|
291
|
+
return {
|
|
292
|
+
text: p.title,
|
|
293
|
+
title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
266
298
|
if (sync?.syncing) {
|
|
267
299
|
const n = sync.canvases ?? 0;
|
|
268
300
|
return {
|
|
269
|
-
text: `
|
|
270
|
-
title: `
|
|
301
|
+
text: `Connecting to ${name} — ${n} canvas${n === 1 ? '' : 'es'}…`,
|
|
302
|
+
title: `Opening ${n} canvas${n === 1 ? '' : 'es'} against the ${name} workspace. This line updates as they settle.`,
|
|
271
303
|
};
|
|
272
304
|
}
|
|
273
305
|
if (sync?.reason === 'nothing-syncable') {
|
|
274
306
|
return {
|
|
275
307
|
text: `Connected to ${name} — nothing to sync yet.`,
|
|
276
|
-
title: sync.detail
|
|
308
|
+
title: safeName(sync.detail, 'No canvases in this project are syncable yet.'),
|
|
277
309
|
};
|
|
278
310
|
}
|
|
311
|
+
const detail = safeName(sync?.detail, 'restart Maude to start syncing.');
|
|
279
312
|
return {
|
|
280
|
-
text: `Connected to ${name} — ${
|
|
281
|
-
title:
|
|
313
|
+
text: `Connected to ${name} — ${detail}`,
|
|
314
|
+
title: detail,
|
|
282
315
|
};
|
|
283
316
|
}
|
|
284
317
|
|
|
@@ -336,14 +369,21 @@ function Icon({ name, size = 15 }) {
|
|
|
336
369
|
);
|
|
337
370
|
}
|
|
338
371
|
|
|
339
|
-
export default function CloudBar() {
|
|
372
|
+
export default function CloudBar({ syncStatus }) {
|
|
340
373
|
const [state, setState] = useState('loading'); // loading | out | in
|
|
341
374
|
const [email, setEmail] = useState(null);
|
|
342
375
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
343
376
|
const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
|
|
344
377
|
const [projects, setProjects] = useState(null);
|
|
345
378
|
const [busy, setBusy] = useState('');
|
|
346
|
-
|
|
379
|
+
// What the last Connect attached, NOT the sentence it produced.
|
|
380
|
+
//
|
|
381
|
+
// This used to hold the rendered `{ text, title }`, which is why the note was
|
|
382
|
+
// frozen: a string computed once at the moment the dialog closed could only
|
|
383
|
+
// ever describe that moment. Keeping the INPUTS and deriving the sentence on
|
|
384
|
+
// every render is the whole liveness mechanism — `syncStatus` arrives on the
|
|
385
|
+
// already-wired `sync:status` bus, so no polling and no second fetch.
|
|
386
|
+
const [connected, setConnected] = useState(null); // { project, sync }
|
|
347
387
|
const [error, setError] = useState('');
|
|
348
388
|
const [copied, setCopied] = useState(false);
|
|
349
389
|
const [copiedLink, setCopiedLink] = useState(false);
|
|
@@ -356,6 +396,15 @@ export default function CloudBar() {
|
|
|
356
396
|
const railRef = useRef(null);
|
|
357
397
|
const pollRef = useRef(null);
|
|
358
398
|
|
|
399
|
+
// Derived, not stored — so the sentence follows the link instead of freezing
|
|
400
|
+
// at the instant the confirm dialog closed. `syncStatus` is the live payload
|
|
401
|
+
// App already receives; when it is absent (older server, first render) the
|
|
402
|
+
// attach response is still the fallback and the note degrades to what it
|
|
403
|
+
// said before, never to nothing.
|
|
404
|
+
const note = connected
|
|
405
|
+
? connectOutcomeNote(connected.project, connected.sync, syncStatus)
|
|
406
|
+
: null;
|
|
407
|
+
|
|
359
408
|
useEffect(() => {
|
|
360
409
|
api('/_api/cloud/status')
|
|
361
410
|
.then((r) => {
|
|
@@ -426,7 +475,7 @@ export default function CloudBar() {
|
|
|
426
475
|
setBusy('');
|
|
427
476
|
setPending(null);
|
|
428
477
|
if (r.ok && r.json?.ok) {
|
|
429
|
-
|
|
478
|
+
setConnected({ project: r.json.project ?? pending.project, sync: r.json.sync });
|
|
430
479
|
} else {
|
|
431
480
|
setError(r.json?.error || 'The workspace could not be connected.');
|
|
432
481
|
}
|
|
@@ -503,7 +552,7 @@ export default function CloudBar() {
|
|
|
503
552
|
|
|
504
553
|
async function connect(projectId) {
|
|
505
554
|
setBusy(projectId);
|
|
506
|
-
|
|
555
|
+
setConnected(null);
|
|
507
556
|
setError('');
|
|
508
557
|
const r = await api('/_api/cloud/attach', {
|
|
509
558
|
method: 'POST',
|
|
@@ -512,7 +561,7 @@ export default function CloudBar() {
|
|
|
512
561
|
});
|
|
513
562
|
setBusy('');
|
|
514
563
|
if (r.ok && r.json?.ok) {
|
|
515
|
-
|
|
564
|
+
setConnected({ project: r.json.project ?? projectId, sync: r.json.sync });
|
|
516
565
|
} else {
|
|
517
566
|
setError(r.json?.error || 'The workspace could not be connected.');
|
|
518
567
|
}
|
|
@@ -705,8 +754,18 @@ export default function CloudBar() {
|
|
|
705
754
|
<span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
|
|
706
755
|
<span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
|
|
707
756
|
</button>
|
|
757
|
+
{/* A live region: the sentence now CHANGES as the link settles, and a
|
|
758
|
+
screen-reader user has to hear that transition rather than only
|
|
759
|
+
whatever text happened to be there when they last tabbed past.
|
|
760
|
+
`polite` — it is progress, never an interruption. */}
|
|
708
761
|
{note && (
|
|
709
|
-
<span
|
|
762
|
+
<span
|
|
763
|
+
className="gi-rail-hint"
|
|
764
|
+
title={note.title}
|
|
765
|
+
role="status"
|
|
766
|
+
aria-live="polite"
|
|
767
|
+
data-testid="cloud-connect-note"
|
|
768
|
+
>
|
|
710
769
|
{note.text}
|
|
711
770
|
</span>
|
|
712
771
|
)}
|
|
@@ -54,10 +54,102 @@ export function pickDefaultReject(options) {
|
|
|
54
54
|
return list.find((o) => o?.kind === 'reject_once' || o?.kind === 'reject_always') ?? null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// feature-acp-write-path-scope Task 4 — the out-of-project write case.
|
|
58
|
+
//
|
|
59
|
+
// `scope` is attached by the SERVER (bridge.ts `classifyWrite` → index.ts's
|
|
60
|
+
// `permission-request` frame) and only ever for a write tool the path gate
|
|
61
|
+
// refused to auto-approve. Everything this renders comes from that payload: the
|
|
62
|
+
// paths are the bridge's REALPATH-RESOLVED absolutes, never `toolCall.rawInput`,
|
|
63
|
+
// because the model's own string is what makes the write look harmless
|
|
64
|
+
// (`docs/../../../.zshenv` reads as a docs edit). Same lesson as the deep-link
|
|
65
|
+
// modal's truncated project name.
|
|
66
|
+
//
|
|
67
|
+
// Exported pure so the copy is unit-testable without rendering (mirrors
|
|
68
|
+
// pickDefaultAllow's rationale — the affordance IS the security control here).
|
|
69
|
+
// SECURITY (security-auditor F3) — the resolved path is REAL filesystem data,
|
|
70
|
+
// but its non-existent tail is whatever the model asked to create, so it is
|
|
71
|
+
// attacker-influenced text rendered in the one card whose entire job is to be
|
|
72
|
+
// read accurately. React escapes markup; it does NOT neutralize bidi overrides
|
|
73
|
+
// or C0 controls, so `~/safe/gnp.esriv/…` can be made to READ as a
|
|
74
|
+
// harmless path while resolving somewhere else entirely.
|
|
75
|
+
//
|
|
76
|
+
// Stripped at RENDER time rather than in the frame: the frame should carry the
|
|
77
|
+
// true path (it is also an audit record), and only the display needs to be
|
|
78
|
+
// unspoofable. Replaced with U+FFFD rather than deleted so a tampered path is
|
|
79
|
+
// visibly odd instead of silently shorter.
|
|
80
|
+
// Written as \u escapes, never literal characters (security-auditor F3 nit) —
|
|
81
|
+
// the same reasoning applied to the tests: a literal RLO in this source would
|
|
82
|
+
// reorder THIS FILE in every editor and code-review diff that renders it.
|
|
83
|
+
// \p{Cc} C0/C1 controls (newline, CR, NUL — hide a whole segment)
|
|
84
|
+
// 202A-202E,2066-2069 bidi embeddings/overrides/isolates (visual reversal)
|
|
85
|
+
// 200E,200F,061C LRM/RLM/ALM — directional marks, same class of trick
|
|
86
|
+
// 200B-200D,FEFF zero-width space/non-joiner/joiner/BOM (invisible splits)
|
|
87
|
+
// 00AD soft hyphen (invisible, but may render as a break)
|
|
88
|
+
const UNSAFE_DISPLAY =
|
|
89
|
+
/[\p{Cc}\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C\u200B-\u200D\uFEFF\u00AD]/gu;
|
|
90
|
+
export function sanitizePathForDisplay(p) {
|
|
91
|
+
return String(p).replace(UNSAFE_DISPLAY, '�');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function describeOutOfProject(scope) {
|
|
95
|
+
if (!scope || scope.outOfProjectWrite !== true) return null;
|
|
96
|
+
const paths = Array.isArray(scope.resolvedPaths)
|
|
97
|
+
? scope.resolvedPaths.filter(Boolean).map(sanitizePathForDisplay)
|
|
98
|
+
: [];
|
|
99
|
+
const lead =
|
|
100
|
+
scope.reason === 'no-target'
|
|
101
|
+
? 'Claude asked to write a file but did not say which one.'
|
|
102
|
+
: scope.reason === 'disagreement'
|
|
103
|
+
? 'Claude named two different files for one write.'
|
|
104
|
+
: // `in-project-denied` is INSIDE the project — saying "outside" would be
|
|
105
|
+
// a plain lie, and the reason it's being asked about is different in
|
|
106
|
+
// kind: not "where is this going" but "this file runs".
|
|
107
|
+
scope.reason === 'in-project-denied'
|
|
108
|
+
? 'This file is part of how your project RUNS, not part of its design.'
|
|
109
|
+
: paths.length > 1
|
|
110
|
+
? 'Claude wants to write files outside this project.'
|
|
111
|
+
: 'Claude wants to write a file outside this project.';
|
|
112
|
+
return {
|
|
113
|
+
lead,
|
|
114
|
+
paths,
|
|
115
|
+
scopeRoot: typeof scope.scopeRoot === 'string' ? sanitizePathForDisplay(scope.scopeRoot) : '',
|
|
116
|
+
// Decision D — the server already stripped every `allow_always` option, so
|
|
117
|
+
// there is no "always allow" to explain away. Say so, rather than leaving
|
|
118
|
+
// the user wondering where the usual option went.
|
|
119
|
+
note: 'This is allowed once, for this request only.',
|
|
120
|
+
/** Inside the project, but denied for an execution reason — the copy above
|
|
121
|
+
* must not claim "outside". */
|
|
122
|
+
inProjectDenied: scope.reason === 'in-project-denied',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Decision D is enforced SERVER-side (bridge.ts filters `allow_always` out of
|
|
127
|
+
// the offered set, and validates a response against that same filtered set).
|
|
128
|
+
// This is a second, client-side pass purely so a stale/racing frame can never
|
|
129
|
+
// paint a button that the server would reject anyway — belt-and-braces, not the
|
|
130
|
+
// control itself.
|
|
131
|
+
function visibleOptions(options, outOfProject) {
|
|
132
|
+
const list = Array.isArray(options) ? options : [];
|
|
133
|
+
return outOfProject ? list.filter((o) => o?.kind !== 'allow_always') : list;
|
|
134
|
+
}
|
|
135
|
+
|
|
57
136
|
export default function PermissionPrompt({ request, onRespond, queueLength = 1 }) {
|
|
58
137
|
const cardRef = useRef(null);
|
|
59
|
-
const { toolCall
|
|
138
|
+
const { toolCall } = request;
|
|
60
139
|
const target = toolTarget(toolCall);
|
|
140
|
+
const outOfProject = describeOutOfProject(request.scope);
|
|
141
|
+
const options = visibleOptions(request.options, !!outOfProject);
|
|
142
|
+
// See the header comment in the JSX below for why an out-of-project write
|
|
143
|
+
// gets a substituted headline + target instead of the adapter's own.
|
|
144
|
+
const headline =
|
|
145
|
+
request.scope?.reason === 'in-project-denied'
|
|
146
|
+
? 'Write to a file that runs'
|
|
147
|
+
: 'Write outside this project';
|
|
148
|
+
const headerTarget = outOfProject
|
|
149
|
+
? outOfProject.paths.length === 1
|
|
150
|
+
? String(outOfProject.paths[0]).split('/').pop()
|
|
151
|
+
: null
|
|
152
|
+
: target;
|
|
61
153
|
|
|
62
154
|
const firstAllow = pickDefaultAllow(options);
|
|
63
155
|
const firstReject = pickDefaultReject(options);
|
|
@@ -81,16 +173,33 @@ export default function PermissionPrompt({ request, onRespond, queueLength = 1 }
|
|
|
81
173
|
|
|
82
174
|
return (
|
|
83
175
|
<div
|
|
84
|
-
className=
|
|
176
|
+
className={'chat-perm' + (outOfProject ? ' chat-perm--outside' : '')}
|
|
85
177
|
role="alertdialog"
|
|
86
|
-
aria-label=
|
|
178
|
+
aria-label={
|
|
179
|
+
outOfProject
|
|
180
|
+
? outOfProject.inProjectDenied
|
|
181
|
+
? 'Permission request — file that runs code'
|
|
182
|
+
: 'Permission request — file outside this project'
|
|
183
|
+
: 'Permission request'
|
|
184
|
+
}
|
|
87
185
|
tabIndex={-1}
|
|
88
186
|
ref={cardRef}
|
|
89
187
|
data-testid="chat-permission-prompt"
|
|
90
188
|
>
|
|
91
189
|
<div className="chat-perm-hd">
|
|
92
|
-
|
|
93
|
-
|
|
190
|
+
{/* SECURITY (feature-acp-write-path-scope Task 4) — for an out-of-project
|
|
191
|
+
write the header must NOT echo `toolCall.title`/`rawInput`. Both are
|
|
192
|
+
built from the model's own path string, and the adapter passes it
|
|
193
|
+
through verbatim (see acp/write-scope.ts's measured contract), so the
|
|
194
|
+
title of a write to `~/.zshenv` reads literally "Write
|
|
195
|
+
docs/../../../.zshenv" — a docs edit, to any human skimming the
|
|
196
|
+
header. The card's whole job is to be read; a truthful body under a
|
|
197
|
+
misleading headline is worse than no headline. Substitute a fixed
|
|
198
|
+
label + the RESOLVED basename, and let the body carry the full path. */}
|
|
199
|
+
<b title={outOfProject ? headline : toolCall?.title || 'Tool call'}>
|
|
200
|
+
{outOfProject ? headline : toolCall?.title || 'Tool call'}
|
|
201
|
+
</b>
|
|
202
|
+
{headerTarget ? <span className="chat-tool-path">{headerTarget}</span> : null}
|
|
94
203
|
{/* SECURITY (ethical-hacker finding) — a growing backlog was
|
|
95
204
|
invisible (always "1 of 1"), which is what manufactures the
|
|
96
205
|
reflexive-Enter-mashing precondition the wrong-default bug
|
|
@@ -101,7 +210,38 @@ export default function PermissionPrompt({ request, onRespond, queueLength = 1 }
|
|
|
101
210
|
</span>
|
|
102
211
|
) : null}
|
|
103
212
|
</div>
|
|
104
|
-
|
|
213
|
+
{outOfProject ? (
|
|
214
|
+
<div className="chat-perm-outside" data-testid="chat-perm-outside">
|
|
215
|
+
<p className="chat-perm-body">{outOfProject.lead}</p>
|
|
216
|
+
{outOfProject.paths.length > 0 ? (
|
|
217
|
+
<ul className="chat-perm-paths" data-testid="chat-perm-paths">
|
|
218
|
+
{outOfProject.paths.map((p) => (
|
|
219
|
+
<li key={p} className="chat-perm-path">
|
|
220
|
+
{p}
|
|
221
|
+
</li>
|
|
222
|
+
))}
|
|
223
|
+
</ul>
|
|
224
|
+
) : null}
|
|
225
|
+
{outOfProject.scopeRoot ? (
|
|
226
|
+
<p className="chat-perm-scope">
|
|
227
|
+
{outOfProject.inProjectDenied ? (
|
|
228
|
+
<>
|
|
229
|
+
Git hooks and Claude settings can execute code later, so Maude asks even
|
|
230
|
+
though they live inside this project.
|
|
231
|
+
</>
|
|
232
|
+
) : (
|
|
233
|
+
<>
|
|
234
|
+
This project is <span className="chat-perm-root">{outOfProject.scopeRoot}</span>.
|
|
235
|
+
Edits inside it never ask.
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
</p>
|
|
239
|
+
) : null}
|
|
240
|
+
<p className="chat-perm-note">{outOfProject.note}</p>
|
|
241
|
+
</div>
|
|
242
|
+
) : (
|
|
243
|
+
<p className="chat-perm-body">Claude wants permission to continue. Choose an option:</p>
|
|
244
|
+
)}
|
|
105
245
|
<div className="chat-perm-actions">
|
|
106
246
|
{options.map((o) => (
|
|
107
247
|
<button
|