@1agh/maude 0.55.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/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- 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/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- 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 +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -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/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -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/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The sentence a person reads after pressing Connect.
|
|
2
|
+
//
|
|
3
|
+
// It used to be computed once, from the ATTACH RESPONSE, at the instant the
|
|
4
|
+
// confirm dialog closed — and never again. The attach response reports an
|
|
5
|
+
// intention (`{ syncing: true }` means `runtime.start()` did not throw and one
|
|
6
|
+
// local canvas qualified), so the sentence claimed success before a socket
|
|
7
|
+
// existed and then sat there through every outcome, including total failure.
|
|
8
|
+
//
|
|
9
|
+
// These cases are one per row of the plan's states/copy table, plus the two
|
|
10
|
+
// degradations that must never take the note away entirely: no live payload,
|
|
11
|
+
// and a payload from a server that predates the counts.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import { connectOutcomeNote } from '../client/panels/CloudBar.jsx';
|
|
16
|
+
import type { SyncStatusLike } from '../sync/presentation.ts';
|
|
17
|
+
|
|
18
|
+
const live = (over: SyncStatusLike): SyncStatusLike => ({
|
|
19
|
+
state: 'online',
|
|
20
|
+
queuedOps: 0,
|
|
21
|
+
lastSyncAt: null,
|
|
22
|
+
offlineSince: null,
|
|
23
|
+
flash: null,
|
|
24
|
+
updatedAt: 0,
|
|
25
|
+
...over,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const started = { syncing: true, canvases: 75 };
|
|
29
|
+
|
|
30
|
+
describe('with a live payload — the sentence moves', () => {
|
|
31
|
+
test('connecting: nothing has settled yet, and it does not pretend otherwise', () => {
|
|
32
|
+
const n = connectOutcomeNote(
|
|
33
|
+
'alligators',
|
|
34
|
+
started,
|
|
35
|
+
live({ state: 'connecting', docs: { synced: 0, pending: 75, rejected: 0 } })
|
|
36
|
+
);
|
|
37
|
+
expect(n.text).toContain('Connecting to alligators');
|
|
38
|
+
expect(n.text).not.toContain('Synced');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('syncing: real progress, drawn from settled handshakes', () => {
|
|
42
|
+
const n = connectOutcomeNote(
|
|
43
|
+
'alligators',
|
|
44
|
+
started,
|
|
45
|
+
live({ docs: { synced: 40, pending: 35, rejected: 0 } })
|
|
46
|
+
);
|
|
47
|
+
expect(n.text).toContain('40 of 75');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('synced: names what arrived and tells the person to go open it', () => {
|
|
51
|
+
const n = connectOutcomeNote(
|
|
52
|
+
'alligators',
|
|
53
|
+
started,
|
|
54
|
+
live({
|
|
55
|
+
docs: { synced: 75, pending: 0, rejected: 0 },
|
|
56
|
+
pulled: { names: ['ui-welcome'], count: 3 },
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
expect(n.text).toContain('Synced with alligators');
|
|
60
|
+
expect(n.text).toContain('3 came down');
|
|
61
|
+
// Deliberately not an imperative to open it: a pulled canvas is hub-authored
|
|
62
|
+
// TSX, and "open this" inside our own green success sentence is the
|
|
63
|
+
// strongest endorsement we could give content we do not vouch for.
|
|
64
|
+
expect(n.title).toContain('new to this machine');
|
|
65
|
+
expect(n.title).not.toContain('Open one');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('refused: the state that cannot fix itself says so, and says what to do', () => {
|
|
69
|
+
// The reported shape, at its worst: the attach response says syncing, the
|
|
70
|
+
// socket is up, and every single document is going nowhere.
|
|
71
|
+
const n = connectOutcomeNote(
|
|
72
|
+
'alligators',
|
|
73
|
+
started,
|
|
74
|
+
live({ state: 'online', docs: { synced: 0, pending: 0, rejected: 75 } })
|
|
75
|
+
);
|
|
76
|
+
expect(n.text).toContain('refused');
|
|
77
|
+
expect(n.title).toContain('Reconnect');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('unreachable: queued, not lost — and explicitly nothing to do', () => {
|
|
81
|
+
const n = connectOutcomeNote(
|
|
82
|
+
'alligators',
|
|
83
|
+
started,
|
|
84
|
+
live({ state: 'offline', queuedOps: 2, docs: { synced: 75, pending: 0, rejected: 0 } })
|
|
85
|
+
);
|
|
86
|
+
expect(n.text).toContain('Not reachable');
|
|
87
|
+
expect(n.text).toContain('queued');
|
|
88
|
+
expect(n.title).toContain('resumes by itself');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the live payload beats the attach response, not the other way round', () => {
|
|
92
|
+
// `sync.syncing` is true here and the link is in fact dead. Whichever wins
|
|
93
|
+
// decides whether this whole change did anything.
|
|
94
|
+
const n = connectOutcomeNote('alligators', started, live({ state: 'offline' }));
|
|
95
|
+
expect(n.text).toContain('Not reachable');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('without a live payload — degrade, never vanish', () => {
|
|
100
|
+
test('first render falls back to the attach response, phrased as in-flight', () => {
|
|
101
|
+
const n = connectOutcomeNote('alligators', started, undefined);
|
|
102
|
+
expect(n.text).toContain('Connecting to alligators');
|
|
103
|
+
expect(n.text).toContain('75 canvases');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('an unlinked live payload does not swallow the note', () => {
|
|
107
|
+
// `syncPresentation` returns null for `{linked:false}`; the note must fall
|
|
108
|
+
// through to the attach response rather than render nothing at all.
|
|
109
|
+
const n = connectOutcomeNote('alligators', started, { linked: false });
|
|
110
|
+
expect(n.text).toContain('alligators');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('nothing syncable keeps its own copy', () => {
|
|
114
|
+
const n = connectOutcomeNote('alligators', {
|
|
115
|
+
syncing: false,
|
|
116
|
+
reason: 'nothing-syncable',
|
|
117
|
+
detail: 'No canvases in this project are syncable yet.',
|
|
118
|
+
});
|
|
119
|
+
expect(n.text).toContain('nothing to sync yet');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a failed start reports the reason it was given', () => {
|
|
123
|
+
const n = connectOutcomeNote('alligators', {
|
|
124
|
+
syncing: false,
|
|
125
|
+
reason: 'error',
|
|
126
|
+
detail: 'Syncing could not start: hub refused the handshake',
|
|
127
|
+
});
|
|
128
|
+
expect(n.text).toContain('hub refused the handshake');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a missing project name never renders as undefined', () => {
|
|
132
|
+
const n = connectOutcomeNote(null, started, undefined);
|
|
133
|
+
expect(n.text).toContain('the workspace');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
+
connectOutcomeNote,
|
|
13
14
|
isDisplayableUrl,
|
|
14
15
|
localIdentityHint,
|
|
15
16
|
parseDeepLink,
|
|
@@ -322,6 +323,48 @@ describe('localIdentityHint — only an exact, corroborated agreement is silent'
|
|
|
322
323
|
});
|
|
323
324
|
});
|
|
324
325
|
|
|
326
|
+
describe('connectOutcomeNote — what the rail says after Connect', () => {
|
|
327
|
+
test('no live payload yet: an in-flight state, not a claimed result', () => {
|
|
328
|
+
// This used to assert "Syncing with alligators — 12 canvases." and a hover
|
|
329
|
+
// pointing at the status bar. Both were wrong: the attach response reports
|
|
330
|
+
// that `runtime.start()` did not throw, which is an INTENTION, and the slot
|
|
331
|
+
// it sent people to was itself keying off `state` alone. See DDR-214 — the
|
|
332
|
+
// sentence now derives from the live payload, and this is only the fallback
|
|
333
|
+
// for the first render (and for a server too old to send one.)
|
|
334
|
+
const n = connectOutcomeNote('alligators', { syncing: true, canvases: 12 });
|
|
335
|
+
expect(n.text).toBe('Connecting to alligators — 12 canvases…');
|
|
336
|
+
expect(n.title).toContain('updates as they settle');
|
|
337
|
+
expect(n.text).not.toContain('hub sync');
|
|
338
|
+
expect(connectOutcomeNote('alligators', { syncing: true, canvases: 1 }).text).toContain(
|
|
339
|
+
'1 canvas…'
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('linked but nothing syncable says so — and never claims a restart would help', () => {
|
|
344
|
+
const n = connectOutcomeNote('alligators', {
|
|
345
|
+
syncing: false,
|
|
346
|
+
canvases: 0,
|
|
347
|
+
reason: 'nothing-syncable',
|
|
348
|
+
detail: 'no canvases found under .design.',
|
|
349
|
+
});
|
|
350
|
+
expect(n.text).toBe('Connected to alligators — nothing to sync yet.');
|
|
351
|
+
expect(n.title).toContain('no canvases found');
|
|
352
|
+
expect(n.text).not.toMatch(/restart/i);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('any other refusal repeats the server’s own reason', () => {
|
|
356
|
+
const n = connectOutcomeNote('alligators', {
|
|
357
|
+
syncing: false,
|
|
358
|
+
canvases: 0,
|
|
359
|
+
reason: 'no-credential',
|
|
360
|
+
detail: 'No sign-in for this workspace is stored on this machine yet.',
|
|
361
|
+
});
|
|
362
|
+
expect(n.text).toContain('No sign-in for this workspace');
|
|
363
|
+
// Never the old sentence: there is no studio server a person can see.
|
|
364
|
+
expect(n.text).not.toContain('studio server');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
325
368
|
describe('attachCode — the maude:// lane end to end (stubbed)', () => {
|
|
326
369
|
test('a valid code links: hub credential stored, linkedHub written token-free', async () => {
|
|
327
370
|
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
@@ -375,6 +418,65 @@ describe('attachCode — the maude:// lane end to end (stubbed)', () => {
|
|
|
375
418
|
expect(r.status).toBe(200);
|
|
376
419
|
});
|
|
377
420
|
|
|
421
|
+
test('linking STARTS syncing — the outcome is reported, not homework', async () => {
|
|
422
|
+
// The bug this closes: the panel used to answer a successful Connect with
|
|
423
|
+
// "restart the studio server to start syncing" — a task, naming a thing
|
|
424
|
+
// the desktop user cannot see. The attach lane now cycles the runtime and
|
|
425
|
+
// reports what happened.
|
|
426
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
427
|
+
const designRoot = join(scratch, '.design-sync');
|
|
428
|
+
const { mkdirSync } = await import('node:fs');
|
|
429
|
+
mkdirSync(designRoot, { recursive: true });
|
|
430
|
+
writeFileSync(join(designRoot, 'config.json'), '{}\n');
|
|
431
|
+
|
|
432
|
+
const handed: unknown[] = [];
|
|
433
|
+
const apiEndpoints = createCloudEndpoints({
|
|
434
|
+
paths: { repoRoot: scratch, designRoot },
|
|
435
|
+
syncControl: {
|
|
436
|
+
restart: async (linkedHub) => {
|
|
437
|
+
handed.push(linkedHub);
|
|
438
|
+
return { syncing: true, canvases: 4 };
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
|
|
443
|
+
expect(r.status).toBe(200);
|
|
444
|
+
expect((r.json as any).sync).toEqual({ syncing: true, canvases: 4 });
|
|
445
|
+
expect((r.json as any).note).toBe('Linked — syncing 4 canvases.');
|
|
446
|
+
|
|
447
|
+
// The supervisor is handed the value BY VALUE — the same object written to
|
|
448
|
+
// config.json — so nothing that touches that committed file in between can
|
|
449
|
+
// steer the socket.
|
|
450
|
+
const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
|
|
451
|
+
expect(handed).toEqual([cfg.linkedHub]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('a TSX opt-out survives a re-link — only the restrictive direction carries', async () => {
|
|
455
|
+
// `syncTsx: false` means "do not upload canvas bodies to this hub"
|
|
456
|
+
// (DDR-072/DDR-079). Re-linking used to overwrite linkedHub wholesale,
|
|
457
|
+
// which silently re-enabled it — harmless while nothing synced until a
|
|
458
|
+
// restart, a real disclosure now that Connect starts syncing immediately.
|
|
459
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
460
|
+
const designRoot = join(scratch, '.design-opt-out');
|
|
461
|
+
const { mkdirSync } = await import('node:fs');
|
|
462
|
+
mkdirSync(designRoot, { recursive: true });
|
|
463
|
+
writeFileSync(
|
|
464
|
+
join(designRoot, 'config.json'),
|
|
465
|
+
JSON.stringify({ linkedHub: { url: 'https://old.example', linkedAt: 1, syncTsx: false } })
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const apiEndpoints = createCloudEndpoints({ paths: { repoRoot: scratch, designRoot } });
|
|
469
|
+
const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
|
|
470
|
+
expect(r.status).toBe(200);
|
|
471
|
+
const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
|
|
472
|
+
expect(cfg.linkedHub.syncTsx).toBe(false);
|
|
473
|
+
expect(cfg.linkedHub.url).toContain('127.0.0.1');
|
|
474
|
+
// No supervisor wired (a plain embedder): still linked, and the note says
|
|
475
|
+
// what is left to do instead of claiming success.
|
|
476
|
+
expect((r.json as any).sync.syncing).toBe(false);
|
|
477
|
+
expect((r.json as any).note).toMatch(/Restart Maude/);
|
|
478
|
+
});
|
|
479
|
+
|
|
378
480
|
test('a malformed or dead code never reaches the network', async () => {
|
|
379
481
|
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
380
482
|
const apiEndpoints = createCloudEndpoints({
|
|
@@ -64,8 +64,11 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
64
64
|
// `=== null`, not falsiness. `cloud` is undefined until `/_config` answers,
|
|
65
65
|
// and treating unknown as "not cloud" mounted this bar for one frame in
|
|
66
66
|
// every cloud tab — long enough to fire the one request the cell refuses.
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Matched on the GUARD rather than the whole element, so adding a prop to
|
|
68
|
+
// CloudBar (DDR-214 drilled the live sync payload in) does not read as a
|
|
69
|
+
// regression in a rule that is entirely about `=== null`.
|
|
70
|
+
expect(APP).toMatch(/\{cloud === null \? <CloudBar[^>]*\/> : null\}/);
|
|
71
|
+
expect(APP).not.toMatch(/\{cloud \? null : <CloudBar/);
|
|
69
72
|
});
|
|
70
73
|
|
|
71
74
|
test('the local export queue does not hydrate before the shell is known', () => {
|
|
@@ -36,14 +36,31 @@ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
|
|
|
36
36
|
expect(sameOriginWrite(post('http://localhost:4399.evil.example'))).toBe(false);
|
|
37
37
|
// Right host, wrong port is still cross-origin.
|
|
38
38
|
expect(sameOriginWrite(post('http://localhost:4400'))).toBe(false);
|
|
39
|
-
// Right host:port, wrong scheme is still cross-origin.
|
|
40
|
-
expect(sameOriginWrite(post('https://localhost:4399'))).toBe(false);
|
|
41
39
|
});
|
|
42
40
|
|
|
43
41
|
test('allows the legit same-origin shell Origin', () => {
|
|
44
42
|
expect(sameOriginWrite(post(SELF))).toBe(true);
|
|
45
43
|
});
|
|
46
44
|
|
|
45
|
+
test('a matching host on a different SCHEME is same-origin — TLS terminates at the cell proxy', () => {
|
|
46
|
+
// The second half of the inspector-edits RCA. In a cell the browser sends
|
|
47
|
+
// `https://<host>` but the studio serves plaintext and computes `req.url` as
|
|
48
|
+
// `http://<host>`, so a scheme-strict compare 403'd every legitimate shell
|
|
49
|
+
// write. Host+port still pins it; a cross-site page has a different host.
|
|
50
|
+
const cell = 'https://alligators.cloud.maude.sh';
|
|
51
|
+
const cellReq = (origin?: string) =>
|
|
52
|
+
new Request('http://alligators.cloud.maude.sh/_api/edit-css', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/json', ...(origin ? { origin } : {}) },
|
|
55
|
+
body: '{}',
|
|
56
|
+
});
|
|
57
|
+
expect(sameOriginWrite(cellReq(cell))).toBe(true); // https Origin ↔ http req.url, same host
|
|
58
|
+
// But a genuinely different host is still rejected, scheme notwithstanding.
|
|
59
|
+
expect(sameOriginWrite(cellReq('https://evil.cloud.maude.sh'))).toBe(false);
|
|
60
|
+
// And the port still discriminates when present.
|
|
61
|
+
expect(sameOriginWrite(post('https://localhost:4400'))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
47
64
|
test('allows a request with no Origin header (curl / programmatic / bun:test)', () => {
|
|
48
65
|
expect(sameOriginWrite(post())).toBe(true);
|
|
49
66
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// A mock ACP agent that streams SEVERAL updates with a gap between them, so a
|
|
3
|
+
// test can close the socket MID-turn and assert the turn still completes
|
|
4
|
+
// (feature-acp-write-path-scope Addendum, Task 8 — bridges outlive their socket).
|
|
5
|
+
//
|
|
6
|
+
// The pacing is the whole point: `mock-acp-agent-permission.mjs` and friends
|
|
7
|
+
// settle in one tick, which leaves no window in which "mid-turn" is a real
|
|
8
|
+
// state. Six chunks at ~120 ms give a test room to observe the first one, yank
|
|
9
|
+
// the socket, and still have the rest arrive with nobody attached.
|
|
10
|
+
|
|
11
|
+
import { Readable, Writable } from 'node:stream';
|
|
12
|
+
|
|
13
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
14
|
+
|
|
15
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
16
|
+
|
|
17
|
+
const CHUNKS = Number(process.env.MAUDE_TEST_SLOW_CHUNKS || 6);
|
|
18
|
+
const GAP_MS = Number(process.env.MAUDE_TEST_SLOW_GAP_MS || 120);
|
|
19
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
|
|
21
|
+
let n = 0;
|
|
22
|
+
|
|
23
|
+
acp
|
|
24
|
+
.agent({ name: 'mock-acp-agent-slow' })
|
|
25
|
+
.onRequest('initialize', () => ({
|
|
26
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
27
|
+
agentCapabilities: { loadSession: false },
|
|
28
|
+
}))
|
|
29
|
+
.onRequest('session/new', () => ({ sessionId: `mock-slow-session-${++n}` }))
|
|
30
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
31
|
+
for (let i = 0; i < CHUNKS; i++) {
|
|
32
|
+
await ctx.client.notify('session/update', {
|
|
33
|
+
sessionId: ctx.params.sessionId,
|
|
34
|
+
update: {
|
|
35
|
+
sessionUpdate: 'agent_message_chunk',
|
|
36
|
+
// Numbered so a test can tell WHICH chunks it received, not just how
|
|
37
|
+
// many — the difference between "no duplicates" and "no gaps".
|
|
38
|
+
content: { type: 'text', text: `chunk-${i}\n` },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
await sleep(GAP_MS);
|
|
42
|
+
}
|
|
43
|
+
return { stopReason: 'end_turn' };
|
|
44
|
+
})
|
|
45
|
+
.connect(stream);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// feature-acp-write-path-scope Task 1 — the RECORDED SHAPE of a write tool's
|
|
3
|
+
// permission request, replayed as a mock ACP agent.
|
|
4
|
+
//
|
|
5
|
+
// This is not an invented shape. It reproduces, field for field, what
|
|
6
|
+
// `@agentclientprotocol/claude-agent-acp@0.57.0` actually puts on the wire for
|
|
7
|
+
// `Write`/`Edit`/`NotebookEdit`, read out of its own `dist/` on 2026-08-07:
|
|
8
|
+
//
|
|
9
|
+
// • `dist/acp-agent.js:2270-2286` builds the permission request's `toolCall`
|
|
10
|
+
// as `{ toolCallId, rawInput, ...toolInfoFromToolUse(name, input, id) }` —
|
|
11
|
+
// note there is NO tool NAME anywhere in that object.
|
|
12
|
+
// • `dist/tools.js` `case "Write"` / `case "Edit"` set
|
|
13
|
+
// `locations: input?.file_path ? [{ path: input.file_path }] : []` — the
|
|
14
|
+
// model's string VERBATIM. No normalization, no absolutization, despite the
|
|
15
|
+
// ACP schema (`types.gen.d.ts:568-572`) describing the field as "The
|
|
16
|
+
// absolute file path being accessed or modified".
|
|
17
|
+
// • `NotebookEdit` has NO case at all — it falls through to `case "Other"`,
|
|
18
|
+
// which emits no `locations` whatsoever. Its target lives only on
|
|
19
|
+
// `rawInput.notebook_path`.
|
|
20
|
+
// • `dist/acp-agent.js:3808-3829` (`toolCallNotification`) is the ONLY carrier
|
|
21
|
+
// of the tool name: `_meta.claudeCode.toolName` on the streamed `tool_call`.
|
|
22
|
+
// • `requestPermissionFromClient` (`:2134-2141`) AWAITS `ensureToolCallEmitted`
|
|
23
|
+
// before issuing the request, so that notification is guaranteed to be on
|
|
24
|
+
// the wire FIRST. The bridge's gate depends on exactly that ordering, so the
|
|
25
|
+
// fixture reproduces it rather than assuming it.
|
|
26
|
+
//
|
|
27
|
+
// Driven by env so one fixture covers every case:
|
|
28
|
+
// MAUDE_TEST_WRITE_PATH — the `file_path` the "model" emitted (required)
|
|
29
|
+
// MAUDE_TEST_WRITE_TOOL — Write (default) | Edit | NotebookEdit | Read
|
|
30
|
+
// MAUDE_TEST_OMIT_TOOL_CALL=1 — skip the `tool_call` notification, i.e.
|
|
31
|
+
// simulate an adapter that reorders or drops it. The gate must fail
|
|
32
|
+
// CLOSED here (prompt), never auto-approve.
|
|
33
|
+
|
|
34
|
+
import { Readable, Writable } from 'node:stream';
|
|
35
|
+
|
|
36
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
37
|
+
|
|
38
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
39
|
+
|
|
40
|
+
const TOOL = process.env.MAUDE_TEST_WRITE_TOOL || 'Write';
|
|
41
|
+
const FILE_PATH = process.env.MAUDE_TEST_WRITE_PATH || '/tmp/unset';
|
|
42
|
+
const OMIT_TOOL_CALL = process.env.MAUDE_TEST_OMIT_TOOL_CALL === '1';
|
|
43
|
+
const TOOL_CALL_ID = 'tc-write-1';
|
|
44
|
+
|
|
45
|
+
// Mirrors `toolInfoFromToolUse`'s per-tool branches. NotebookEdit deliberately
|
|
46
|
+
// yields NO `locations` key — that is the adapter's real behaviour, and the case
|
|
47
|
+
// that makes the rawInput fallback mandatory rather than decorative.
|
|
48
|
+
function toolInfo() {
|
|
49
|
+
if (TOOL === 'NotebookEdit') {
|
|
50
|
+
return { title: 'NotebookEdit', kind: 'other', content: [] };
|
|
51
|
+
}
|
|
52
|
+
if (TOOL === 'Read') {
|
|
53
|
+
return {
|
|
54
|
+
title: `Read ${FILE_PATH}`,
|
|
55
|
+
kind: 'read',
|
|
56
|
+
content: [],
|
|
57
|
+
locations: [{ path: FILE_PATH, line: 1 }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
title: `${TOOL} ${FILE_PATH}`,
|
|
62
|
+
kind: 'edit',
|
|
63
|
+
content: [],
|
|
64
|
+
locations: [{ path: FILE_PATH }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rawInput() {
|
|
69
|
+
if (TOOL === 'NotebookEdit') return { notebook_path: FILE_PATH, new_source: 'x' };
|
|
70
|
+
if (TOOL === 'Write') return { file_path: FILE_PATH, content: 'x' };
|
|
71
|
+
if (TOOL === 'Read') return { file_path: FILE_PATH };
|
|
72
|
+
return { file_path: FILE_PATH, old_string: 'a', new_string: 'b' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
acp
|
|
76
|
+
.agent({ name: 'mock-acp-agent-write' })
|
|
77
|
+
.onRequest('initialize', () => ({
|
|
78
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
79
|
+
agentCapabilities: { loadSession: false },
|
|
80
|
+
}))
|
|
81
|
+
.onRequest('session/new', () => ({ sessionId: 'mock-write-session-1' }))
|
|
82
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
83
|
+
// (1) The `tool_call` notification — the tool NAME's only carrier.
|
|
84
|
+
if (!OMIT_TOOL_CALL) {
|
|
85
|
+
await ctx.client.notify('session/update', {
|
|
86
|
+
sessionId: ctx.params.sessionId,
|
|
87
|
+
update: {
|
|
88
|
+
_meta: { claudeCode: { toolName: TOOL } },
|
|
89
|
+
toolCallId: TOOL_CALL_ID,
|
|
90
|
+
sessionUpdate: 'tool_call',
|
|
91
|
+
rawInput: rawInput(),
|
|
92
|
+
status: 'pending',
|
|
93
|
+
...toolInfo(),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// (2) THEN the permission request, with the adapter's real option set for a
|
|
98
|
+
// routine tool call (`acp-agent.js:2271-2278`) — note `allow_always` is
|
|
99
|
+
// listed FIRST, which is what Decision D's filter has to contend with.
|
|
100
|
+
const outcome = await ctx.client.request(acp.methods.client.session.requestPermission, {
|
|
101
|
+
sessionId: ctx.params.sessionId,
|
|
102
|
+
toolCall: { toolCallId: TOOL_CALL_ID, rawInput: rawInput(), ...toolInfo() },
|
|
103
|
+
options: [
|
|
104
|
+
{ optionId: 'allow_always', name: 'Always Allow', kind: 'allow_always' },
|
|
105
|
+
{ optionId: 'allow', name: 'Allow', kind: 'allow_once' },
|
|
106
|
+
{ optionId: 'reject', name: 'Reject', kind: 'reject_once' },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
await ctx.client.notify('session/update', {
|
|
110
|
+
sessionId: ctx.params.sessionId,
|
|
111
|
+
update: {
|
|
112
|
+
sessionUpdate: 'agent_message_chunk',
|
|
113
|
+
content: { type: 'text', text: `outcome=${JSON.stringify(outcome.outcome)}` },
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
return { stopReason: 'end_turn' };
|
|
117
|
+
})
|
|
118
|
+
.connect(stream);
|