@1agh/maude 0.30.0 → 0.32.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 +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +788 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +796 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +625 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +821 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +29097 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5816 -1614
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +399 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/readiness.ts +220 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +95 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/readiness.test.ts +127 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +82 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
|
@@ -50,17 +50,27 @@ import {
|
|
|
50
50
|
commentsFromDoc,
|
|
51
51
|
cssFromDoc,
|
|
52
52
|
htmlFromDoc,
|
|
53
|
+
markSeeded,
|
|
53
54
|
mergeSharedMetaIntoLocal,
|
|
54
55
|
metaFromDoc,
|
|
56
|
+
seededByFromDoc,
|
|
55
57
|
stampBodyEdit,
|
|
56
58
|
Y_SYNC_TYPES,
|
|
57
59
|
} from './codec.ts';
|
|
58
|
-
import { decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
60
|
+
import { decideColdStart, isExactRepeat, unionCommentsById } from './cold-start.ts';
|
|
59
61
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
60
62
|
import type { SyncJournal } from './journal.ts';
|
|
61
63
|
|
|
62
64
|
export const DOC_FLUSH_MS = 800;
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Window after a `seed-local-up` during which this agent will repair a
|
|
68
|
+
* concurrent-seed duplication (F1). Bounded so the repair only ever fires for
|
|
69
|
+
* the cold-start race — a genuine peer edit that arrives minutes later is NEVER
|
|
70
|
+
* collapsed back to our original seed. Generous relative to the hub sync RTT.
|
|
71
|
+
*/
|
|
72
|
+
export const SEED_REPAIR_WINDOW_MS = 10_000;
|
|
73
|
+
|
|
64
74
|
export interface CanvasSyncPaths {
|
|
65
75
|
/** Absolute path to <designRoot>/<canvas>.html. */
|
|
66
76
|
html: string;
|
|
@@ -164,10 +174,21 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
164
174
|
let lastMeta: string | null = null;
|
|
165
175
|
let lastCss: string | null = null;
|
|
166
176
|
|
|
177
|
+
// F1 — the body this agent pushed on `seed-local-up`, plus the deadline past
|
|
178
|
+
// which the de-dup repair stops firing. Set on seed; nulled only when the
|
|
179
|
+
// window lapses (after convergence the repair is a cheap no-op — the
|
|
180
|
+
// `body === seedInfo.body` guard short-circuits, so we don't bother clearing).
|
|
181
|
+
// Null = this agent never seeded (so it never repairs).
|
|
182
|
+
let seedInfo: { body: string; until: number } | null = null;
|
|
183
|
+
|
|
167
184
|
function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
|
|
168
185
|
if (stopped) return;
|
|
169
186
|
// Self-applied (we just synced from disk) — disk is already current.
|
|
170
187
|
if (updateOrigin === origin) return;
|
|
188
|
+
// F1 — a remote update during the seed window may have concatenated a
|
|
189
|
+
// concurrent peer's identical seed onto ours. Collapse it (single elected
|
|
190
|
+
// writer) BEFORE the flush below can mirror a doubled body to disk.
|
|
191
|
+
maybeRepairSeedDuplication();
|
|
171
192
|
scheduleFlush();
|
|
172
193
|
}
|
|
173
194
|
|
|
@@ -188,6 +209,45 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
188
209
|
}, flushMs);
|
|
189
210
|
}
|
|
190
211
|
|
|
212
|
+
/**
|
|
213
|
+
* F1 — collapse a concurrent cold-seed duplication. When two peers
|
|
214
|
+
* `seed-local-up` the SAME body into an empty hub at the same instant, the
|
|
215
|
+
* merged Y.Text holds both insertions (`BODY` × N — un-buildable). This runs
|
|
216
|
+
* on every remote update inside the seed window and, on the single peer the
|
|
217
|
+
* `seededBy` Y.Map LWW elected, re-applies the canonical body so
|
|
218
|
+
* `applyHtmlToDoc`'s diff deletes the trailing duplicate(s). Only the elected
|
|
219
|
+
* owner repairs → the collapse op is emitted once and converges across peers
|
|
220
|
+
* (a non-owner just receives the delete). Restricted to an EXACT repeat of our
|
|
221
|
+
* seed: a divergent edit carries genuine bytes, so we DON'T touch it here —
|
|
222
|
+
* silently collapsing it would discard content. NOTE: that leaves the rare
|
|
223
|
+
* "two DIFFERENT bodies seeded into one empty hub at the same instant" case
|
|
224
|
+
* un-buildable until the NEXT boot, when `decideColdStart` routes it through
|
|
225
|
+
* the conflict path (dual snapshot + newest-wins). This live repair only
|
|
226
|
+
* covers the identical-seed race (the F1 RCA's scenario); the divergent
|
|
227
|
+
* variant is an accepted follow-up, not handled in-flight.
|
|
228
|
+
*/
|
|
229
|
+
function maybeRepairSeedDuplication(): void {
|
|
230
|
+
if (!seedInfo) return;
|
|
231
|
+
if (Date.now() > seedInfo.until) {
|
|
232
|
+
seedInfo = null;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const owner = seededByFromDoc(doc);
|
|
236
|
+
if (owner === null || owner !== doc.clientID) return; // not the elected writer
|
|
237
|
+
const body = htmlFromDoc(doc);
|
|
238
|
+
if (body === seedInfo.body) return; // already a single copy
|
|
239
|
+
if (!isExactRepeat(body, seedInfo.body)) return; // divergent edit → conflict path owns it
|
|
240
|
+
const canonical = seedInfo.body;
|
|
241
|
+
doc.transact(() => {
|
|
242
|
+
if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
|
|
243
|
+
}, origin);
|
|
244
|
+
lastHtml = canonical;
|
|
245
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
|
|
246
|
+
console.warn(
|
|
247
|
+
`[sync/${slug}] concurrent cold-seed duplication detected — collapsed to one copy (F1).`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
191
251
|
async function flush(): Promise<void> {
|
|
192
252
|
if (!dirty || stopped) return;
|
|
193
253
|
dirty = false;
|
|
@@ -348,7 +408,12 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
348
408
|
if (localHtml !== null) {
|
|
349
409
|
doc.transact(() => {
|
|
350
410
|
if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
|
|
411
|
+
// F1 — adopt is also a "seed local up" (first link / fresh hub); claim
|
|
412
|
+
// it + arm the repair window so two peers adopting the same draft into
|
|
413
|
+
// one hub at once self-heal the same way the decision-table seed does.
|
|
414
|
+
markSeeded(doc, origin);
|
|
351
415
|
}, origin);
|
|
416
|
+
seedInfo = { body: localHtml, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
352
417
|
}
|
|
353
418
|
if (localComments !== null) {
|
|
354
419
|
const parsed = tryParseJsonArray(localComments);
|
|
@@ -396,8 +461,15 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
396
461
|
const seedBodyUp = (body: string): void => {
|
|
397
462
|
doc.transact(() => {
|
|
398
463
|
if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
|
|
464
|
+
// F1 — claim the seed (clientID marker) in the SAME update so a
|
|
465
|
+
// concurrent second seeder's merge resolves a single elected writer.
|
|
466
|
+
markSeeded(doc, origin);
|
|
399
467
|
}, origin);
|
|
400
468
|
lastHtml = body;
|
|
469
|
+
// Arm the de-dup repair window: if another peer seeded the same body at the
|
|
470
|
+
// same instant, the merged Y.Text will double — maybeRepairSeedDuplication
|
|
471
|
+
// (on the elected owner) collapses it back during this window.
|
|
472
|
+
seedInfo = { body, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
401
473
|
opts.journal?.record(slug, { bodyHash: hashBytes(body) });
|
|
402
474
|
};
|
|
403
475
|
|
|
@@ -421,6 +493,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
421
493
|
seedBodyUp(localHtml as string);
|
|
422
494
|
bodyWinner = 'local';
|
|
423
495
|
break;
|
|
496
|
+
case 'recover-seed-dup':
|
|
497
|
+
// F1 — booting against a hub whose body is our local body repeated (a
|
|
498
|
+
// concurrent cold-seed that already duplicated). Re-apply local so the
|
|
499
|
+
// diff deletes the extra copy/copies; the content equals local, so the
|
|
500
|
+
// visually-coupled lanes follow local too. Idempotent across peers.
|
|
501
|
+
seedBodyUp(localHtml as string);
|
|
502
|
+
bodyWinner = 'local';
|
|
503
|
+
break;
|
|
424
504
|
case 'conflict': {
|
|
425
505
|
// Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
|
|
426
506
|
// write, then apply the newest-wins winner. Even a wrong pick costs
|
|
@@ -338,6 +338,30 @@ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
|
|
|
338
338
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
|
|
343
|
+
* in the SAME transaction + origin as a `seed-local-up` body apply. The value is
|
|
344
|
+
* a Yjs clientID, so when two peers seed the same empty hub simultaneously and
|
|
345
|
+
* their maps merge, Y.Map's deterministic last-writer-wins resolution converges
|
|
346
|
+
* `seededBy` to ONE clientID on EVERY peer — that elected peer is the
|
|
347
|
+
* single-writer that performs the de-duplication repair, so the collapse op is
|
|
348
|
+
* never emitted twice. Informational/bookkeeping only; never materialized to
|
|
349
|
+
* disk (syncMeta is a sync-internal lane).
|
|
350
|
+
*/
|
|
351
|
+
export function markSeeded(doc: Y.Doc, origin?: unknown): void {
|
|
352
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
353
|
+
doc.transact(() => {
|
|
354
|
+
map.set('seededBy', doc.clientID);
|
|
355
|
+
}, origin);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The elected seeder's clientID (`syncMeta.seededBy`), or null when no peer
|
|
359
|
+
* ever seeded through the marker path (older peers / non-seed canvases). */
|
|
360
|
+
export function seededByFromDoc(doc: Y.Doc): number | null {
|
|
361
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('seededBy');
|
|
362
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
341
365
|
/* ---------------------------------------------------------------- css */
|
|
342
366
|
|
|
343
367
|
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|
|
@@ -26,6 +26,7 @@ export type ColdStartAction =
|
|
|
26
26
|
| 'materialize-hub'
|
|
27
27
|
| 'seed-local-up'
|
|
28
28
|
| 'fast-forward-hub'
|
|
29
|
+
| 'recover-seed-dup'
|
|
29
30
|
| 'conflict';
|
|
30
31
|
|
|
31
32
|
export interface ColdStartInput {
|
|
@@ -56,6 +57,24 @@ function isEmptyBody(body: string | null): boolean {
|
|
|
56
57
|
return body === null || body.trim() === '';
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* True when `docBody` is exactly `localBody` repeated N≥2 times — the signature
|
|
62
|
+
* of a concurrent cold-seed collision (F1): two peers each `seed-local-up`-ed
|
|
63
|
+
* the SAME body into an empty hub before either's write propagated, so the CRDT
|
|
64
|
+
* preserved both insertions and the Y.Text became `BODY` × N. We detect the
|
|
65
|
+
* exact-repeat shape (not a fuzzy "contains") so a legitimate later edit — which
|
|
66
|
+
* is never a clean integer-multiple repeat of the prior body — can't be
|
|
67
|
+
* mis-read as a duplication and clobbered back. Exact equality (N==1) is the
|
|
68
|
+
* caller's `noop`, handled before this is consulted.
|
|
69
|
+
*/
|
|
70
|
+
export function isExactRepeat(docBody: string, localBody: string): boolean {
|
|
71
|
+
if (localBody.length === 0) return false;
|
|
72
|
+
if (docBody.length <= localBody.length) return false;
|
|
73
|
+
if (docBody.length % localBody.length !== 0) return false;
|
|
74
|
+
const n = docBody.length / localBody.length;
|
|
75
|
+
return docBody === localBody.repeat(n);
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
function commentId(c: unknown): string | null {
|
|
60
79
|
if (c && typeof c === 'object' && typeof (c as { id?: unknown }).id === 'string') {
|
|
61
80
|
return (c as { id: string }).id;
|
|
@@ -117,6 +136,27 @@ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
|
|
|
117
136
|
return { action: 'noop', reason: 'local and hub identical' };
|
|
118
137
|
}
|
|
119
138
|
|
|
139
|
+
// Concurrent cold-seed collision (F1): the hub body is our local body repeated
|
|
140
|
+
// N≥2 times — two peers seeded the same canvas into an empty hub at the same
|
|
141
|
+
// moment and the CRDT concatenated both insertions (un-buildable: two `export
|
|
142
|
+
// default`). This is NOT divergence (it carries no new bytes — just a doubled
|
|
143
|
+
// copy of ours), so it must NOT take the conflict/fast-forward path. Collapse
|
|
144
|
+
// it back to one copy by re-applying local (the caller's `applyHtmlToDoc` diff
|
|
145
|
+
// deletes the trailing duplicate). Idempotent across peers — the delete targets
|
|
146
|
+
// the same CRDT items, so concurrent recoveries converge instead of fighting.
|
|
147
|
+
if (
|
|
148
|
+
input.localBody !== null &&
|
|
149
|
+
!localEmpty &&
|
|
150
|
+
!docEmpty &&
|
|
151
|
+
isExactRepeat(input.docBody, input.localBody)
|
|
152
|
+
) {
|
|
153
|
+
return {
|
|
154
|
+
action: 'recover-seed-dup',
|
|
155
|
+
reason:
|
|
156
|
+
'hub body is local repeated — concurrent cold-seed duplication; collapsing to one copy',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
120
160
|
// Hash via hashBytes ONLY — the journal recorded its hashes through the same
|
|
121
161
|
// fn (single source, echo-guard.ts), so this comparison is apples-to-apples.
|
|
122
162
|
if (
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// sync/hub-link.ts — Phase 29 (E4) Door C: connect to a team hub from the wizard.
|
|
2
|
+
//
|
|
3
|
+
// The CLI (`maude design link`) owns the FULL link flow (interactive trust gate +
|
|
4
|
+
// per-project `.design/config.json` linkedHub write + `--adopt` push). This is the
|
|
5
|
+
// lean in-app counterpart used by the onboarding wizard's advanced door: it saves the
|
|
6
|
+
// hub CREDENTIAL to the global `~/.config/maude/hubs.json` (mode 0600) and records the
|
|
7
|
+
// hub as trusted on THIS machine — the in-UI "Connect" IS the explicit trust grant the
|
|
8
|
+
// CLI's interactive confirmation provides (DDR-054 F2). Hub tokens are GLOBAL /
|
|
9
|
+
// per-machine (keyed by normalized URL), so a project later opened whose
|
|
10
|
+
// `.design/config.json` names this hub syncs using the saved token. We deliberately do
|
|
11
|
+
// NOT write a per-project `linkedHub` here (onboarding has no project yet) — that stays
|
|
12
|
+
// a CLI / post-onboarding operation.
|
|
13
|
+
//
|
|
14
|
+
// SECURITY: the http layer gates this main-origin + loopback only (mirrors
|
|
15
|
+
// /_api/github/*). The health probe is a best-effort, TOKENLESS GET to the user-entered
|
|
16
|
+
// hub URL; only `{ ok, version }` is reflected back — no response-body passthrough, so it
|
|
17
|
+
// can't become an SSRF data-exfil channel. The probe deliberately does NOT carry the hub
|
|
18
|
+
// token: a user who pastes a lookalike URL must not have their credential delivered to the
|
|
19
|
+
// attacker's host (phase-29 /flow:done attacker finding A2). The token is only persisted
|
|
20
|
+
// locally (mode 0600) and presented later on the authenticated sync WS upgrade. The hub
|
|
21
|
+
// address is whatever the user typed (a LAN / Tailscale / fly.dev URL is legitimate —
|
|
22
|
+
// phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
|
|
23
|
+
// is the loopback caller gate + no reflection + no token on the probe.
|
|
24
|
+
|
|
25
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { dirname } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
|
|
29
|
+
|
|
30
|
+
export interface HubLinkResult {
|
|
31
|
+
status: number;
|
|
32
|
+
json: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HUB_PROBE_TIMEOUT_MS = 4000;
|
|
36
|
+
|
|
37
|
+
interface HubsFile {
|
|
38
|
+
hubs: Record<string, { token: string; linkedAt: number }>;
|
|
39
|
+
trusted?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Validate + probe + persist a hub credential. Returns an http-shaped result. */
|
|
43
|
+
export async function linkHub(body: unknown): Promise<HubLinkResult> {
|
|
44
|
+
const b = (body ?? {}) as { url?: unknown; token?: unknown };
|
|
45
|
+
if (typeof b.url !== 'string' || !b.url.trim()) return bad('Enter the hub address.');
|
|
46
|
+
if (typeof b.token !== 'string' || !b.token.trim())
|
|
47
|
+
return bad('Paste the invite link or token your team gave you.');
|
|
48
|
+
|
|
49
|
+
let norm: string;
|
|
50
|
+
try {
|
|
51
|
+
norm = normalizeUrl(b.url.trim());
|
|
52
|
+
} catch {
|
|
53
|
+
return bad("That doesn't look like a valid hub address.");
|
|
54
|
+
}
|
|
55
|
+
let parsed: URL;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(norm);
|
|
58
|
+
} catch {
|
|
59
|
+
return bad('Invalid hub address.');
|
|
60
|
+
}
|
|
61
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
62
|
+
return bad('The hub address must start with http:// or https://.');
|
|
63
|
+
|
|
64
|
+
const token = b.token.trim();
|
|
65
|
+
|
|
66
|
+
// Best-effort reachability probe — TOKENLESS (never deliver the credential to a
|
|
67
|
+
// possibly-lookalike host) and does NOT gate the save (a firewalled hub the user
|
|
68
|
+
// trusts still links; the real auth happens on the sync WS upgrade).
|
|
69
|
+
const probe = await probeHealth(norm);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
saveHubCredential(norm, token);
|
|
73
|
+
} catch {
|
|
74
|
+
return { status: 500, json: { ok: false, error: "Couldn't save the hub connection." } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
status: 200,
|
|
79
|
+
json: { ok: true, url: norm, healthy: probe.ok, version: probe.version ?? null },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bad(error: string): HubLinkResult {
|
|
84
|
+
return { status: 400, json: { ok: false, error } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function probeHealth(url: string): Promise<{ ok: boolean; version?: string }> {
|
|
88
|
+
const ctrl = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => ctrl.abort(), HUB_PROBE_TIMEOUT_MS);
|
|
90
|
+
try {
|
|
91
|
+
// Tokenless on purpose (attacker finding A2): /health is a liveness check; the
|
|
92
|
+
// credential is never sent to the user-typed host, only to the trusted hub on the
|
|
93
|
+
// authenticated sync WS upgrade later.
|
|
94
|
+
const res = await fetch(`${url}/health`, { signal: ctrl.signal });
|
|
95
|
+
if (!res.ok) return { ok: false };
|
|
96
|
+
let version: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
const j = (await res.json()) as { version?: unknown };
|
|
99
|
+
if (j && typeof j.version === 'string') version = j.version;
|
|
100
|
+
} catch {
|
|
101
|
+
/* health may not be JSON — reachability alone is enough */
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, version };
|
|
104
|
+
} catch {
|
|
105
|
+
return { ok: false };
|
|
106
|
+
} finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Upsert the token under `normUrl` + record per-machine trust; mode 0600. */
|
|
112
|
+
export function saveHubCredential(normUrl: string, token: string): void {
|
|
113
|
+
const path = hubsConfigPath();
|
|
114
|
+
const dir = dirname(path);
|
|
115
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
116
|
+
let cfg: HubsFile = { hubs: {} };
|
|
117
|
+
if (existsSync(path)) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
120
|
+
if (parsed && typeof parsed.hubs === 'object' && parsed.hubs !== null)
|
|
121
|
+
cfg = parsed as HubsFile;
|
|
122
|
+
} catch {
|
|
123
|
+
/* malformed → start fresh rather than throw */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
cfg.hubs[normUrl] = { token, linkedAt: Date.now() };
|
|
127
|
+
if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
|
|
128
|
+
if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
|
|
129
|
+
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
130
|
+
try {
|
|
131
|
+
chmodSync(path, 0o600);
|
|
132
|
+
} catch {
|
|
133
|
+
/* windows / read-only fs — best effort */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const __testing = { probeHealth };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// End-to-end bridge test against a mock ACP agent (test/fixtures/mock-acp-agent.mjs)
|
|
2
|
+
// so no real `claude` install is needed. Proves: spawn + handshake + streamed
|
|
3
|
+
// update + turn completion, AND that ANTHROPIC_API_KEY never reaches the child
|
|
4
|
+
// (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { AcpBridge } from '../acp/bridge.ts';
|
|
10
|
+
import { probeAcpAvailability } from '../acp/probe.ts';
|
|
11
|
+
|
|
12
|
+
const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
|
|
13
|
+
const TEST_ENV_KEYS = [
|
|
14
|
+
'ANTHROPIC_API_KEY',
|
|
15
|
+
'MAUDE_ACP_ADAPTER_ENTRY',
|
|
16
|
+
'MAUDE_ACP_RUNTIME',
|
|
17
|
+
'MAUDE_CLAUDE_BIN',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const key of TEST_ENV_KEYS) delete process.env[key];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('AcpBridge — round-trip + subscription guardrail', () => {
|
|
25
|
+
test('connects, streams an update, completes a turn, and scrubs ANTHROPIC_API_KEY', async () => {
|
|
26
|
+
process.env.ANTHROPIC_API_KEY = 'sk-must-be-scrubbed';
|
|
27
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
28
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath; // run the mock under this bun
|
|
29
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath; // satisfy the claude-present check
|
|
30
|
+
|
|
31
|
+
const updates: unknown[] = [];
|
|
32
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
33
|
+
try {
|
|
34
|
+
const result = await bridge.prompt('hello', 'c1');
|
|
35
|
+
expect(bridge.connected).toBe(true);
|
|
36
|
+
expect(bridge.sessionId).toBe('mock-session-1');
|
|
37
|
+
expect(result.stopReason).toBe('end_turn');
|
|
38
|
+
|
|
39
|
+
// The mock streamed its own ANTHROPIC_API_KEY — it must read `<unset>`,
|
|
40
|
+
// proving scrubAgentEnv stripped it from the child env before spawn.
|
|
41
|
+
const streamed = JSON.stringify(updates);
|
|
42
|
+
expect(streamed).toContain('apiKey=<unset>');
|
|
43
|
+
expect(streamed).not.toContain('sk-must-be-scrubbed');
|
|
44
|
+
} finally {
|
|
45
|
+
await bridge.stop();
|
|
46
|
+
}
|
|
47
|
+
}, 15000);
|
|
48
|
+
|
|
49
|
+
test('setConfig passes model + effort to the child env (ANTHROPIC_MODEL / MAX_THINKING_TOKENS)', async () => {
|
|
50
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
51
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
52
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
53
|
+
|
|
54
|
+
const updates: unknown[] = [];
|
|
55
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
56
|
+
try {
|
|
57
|
+
bridge.setConfig('opus', 'thorough');
|
|
58
|
+
await bridge.prompt('hi', 'c1');
|
|
59
|
+
const first = JSON.stringify(updates);
|
|
60
|
+
expect(first).toContain('model=opus');
|
|
61
|
+
expect(first).toContain('thinking=31999');
|
|
62
|
+
|
|
63
|
+
// Changing the config re-spawns with the new env on the next prompt.
|
|
64
|
+
updates.length = 0;
|
|
65
|
+
bridge.setConfig(null, 'fast');
|
|
66
|
+
await bridge.prompt('again', 'c1');
|
|
67
|
+
const second = JSON.stringify(updates);
|
|
68
|
+
expect(second).toContain('model=<unset>'); // null model → ANTHROPIC_MODEL not set
|
|
69
|
+
expect(second).toContain('thinking=0'); // fast → thinking disabled
|
|
70
|
+
} finally {
|
|
71
|
+
await bridge.stop();
|
|
72
|
+
}
|
|
73
|
+
}, 15000);
|
|
74
|
+
|
|
75
|
+
test('a second prompt reuses the same live session', async () => {
|
|
76
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
77
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
78
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
79
|
+
|
|
80
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
81
|
+
try {
|
|
82
|
+
await bridge.prompt('first', 'c1');
|
|
83
|
+
const sid = bridge.sessionId;
|
|
84
|
+
await bridge.prompt('second', 'c1');
|
|
85
|
+
expect(bridge.sessionId).toBe(sid);
|
|
86
|
+
} finally {
|
|
87
|
+
await bridge.stop();
|
|
88
|
+
}
|
|
89
|
+
}, 15000);
|
|
90
|
+
|
|
91
|
+
test('different chat ids get separate claude sessions; same id reuses its own', async () => {
|
|
92
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
93
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
94
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
95
|
+
|
|
96
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
97
|
+
try {
|
|
98
|
+
await bridge.prompt('a', 'chatA');
|
|
99
|
+
const sa = bridge.sessionId;
|
|
100
|
+
await bridge.prompt('b', 'chatB');
|
|
101
|
+
const sb = bridge.sessionId;
|
|
102
|
+
expect(sa).not.toBe(sb); // separate per-chat contexts
|
|
103
|
+
await bridge.prompt('a again', 'chatA');
|
|
104
|
+
expect(bridge.sessionId).toBe(sa); // chatA reuses its own session
|
|
105
|
+
} finally {
|
|
106
|
+
await bridge.stop();
|
|
107
|
+
}
|
|
108
|
+
}, 15000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('probeAcpAvailability — not-connected detection', () => {
|
|
112
|
+
test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
|
|
113
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
114
|
+
process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'no-such-claude-bin');
|
|
115
|
+
const probe = probeAcpAvailability();
|
|
116
|
+
expect(probe.available).toBe(false);
|
|
117
|
+
expect(probe.reason ?? '').toMatch(/Claude Code/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('reports available when both the adapter and a claude binary resolve', () => {
|
|
121
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
122
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
123
|
+
const probe = probeAcpAvailability();
|
|
124
|
+
expect(probe.available).toBe(true);
|
|
125
|
+
expect(probe.adapterEntry).toBe(FIXTURE);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// DDR-123 guardrail #1 — the single load-bearing detail that keeps the chat
|
|
2
|
+
// panel on the user's Pro/Max subscription instead of metered API billing.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { SUBSCRIPTION_SCRUBBED_ENV_KEYS, scrubAgentEnv } from '../acp/env.ts';
|
|
7
|
+
|
|
8
|
+
describe('scrubAgentEnv — subscription guardrail', () => {
|
|
9
|
+
test('strips the billing-switching keys, keeps everything else', () => {
|
|
10
|
+
const out = scrubAgentEnv({
|
|
11
|
+
PATH: '/usr/bin',
|
|
12
|
+
HOME: '/Users/x',
|
|
13
|
+
ANTHROPIC_API_KEY: 'sk-live-xxx',
|
|
14
|
+
ANTHROPIC_AUTH_TOKEN: 'tok-xxx',
|
|
15
|
+
UNDEFINED_VAR: undefined,
|
|
16
|
+
});
|
|
17
|
+
expect(out.PATH).toBe('/usr/bin');
|
|
18
|
+
expect(out.HOME).toBe('/Users/x');
|
|
19
|
+
expect('ANTHROPIC_API_KEY' in out).toBe(false);
|
|
20
|
+
expect('ANTHROPIC_AUTH_TOKEN' in out).toBe(false);
|
|
21
|
+
// undefined values are dropped (Bun.spawn env wants Record<string,string>)
|
|
22
|
+
expect('UNDEFINED_VAR' in out).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('pins exactly the two precedence-relevant keys', () => {
|
|
26
|
+
expect([...SUBSCRIPTION_SCRUBBED_ENV_KEYS].sort()).toEqual([
|
|
27
|
+
'ANTHROPIC_API_KEY',
|
|
28
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('scrubs the whole provider/billing namespace, not just the two keys (F1)', () => {
|
|
33
|
+
const out = scrubAgentEnv({
|
|
34
|
+
PATH: '/usr/bin',
|
|
35
|
+
ANTHROPIC_BASE_URL: 'https://evil.example/v1',
|
|
36
|
+
ANTHROPIC_MODEL: 'attacker-pinned',
|
|
37
|
+
ANTHROPIC_BEDROCK_BASE_URL: 'https://evil',
|
|
38
|
+
CLAUDE_CODE_USE_BEDROCK: '1',
|
|
39
|
+
CLAUDE_CODE_USE_VERTEX: '1',
|
|
40
|
+
AWS_BEARER_TOKEN_BEDROCK: 'tok',
|
|
41
|
+
claude_session: 'keep-me', // not a provider redirect → kept
|
|
42
|
+
});
|
|
43
|
+
expect(out.PATH).toBe('/usr/bin');
|
|
44
|
+
expect('ANTHROPIC_BASE_URL' in out).toBe(false);
|
|
45
|
+
expect('ANTHROPIC_MODEL' in out).toBe(false); // re-added by the bridge from a validated value
|
|
46
|
+
expect('ANTHROPIC_BEDROCK_BASE_URL' in out).toBe(false);
|
|
47
|
+
expect('CLAUDE_CODE_USE_BEDROCK' in out).toBe(false);
|
|
48
|
+
expect('CLAUDE_CODE_USE_VERTEX' in out).toBe(false);
|
|
49
|
+
expect('AWS_BEARER_TOKEN_BEDROCK' in out).toBe(false);
|
|
50
|
+
expect(out.claude_session).toBe('keep-me');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('never mutates the source (process.env stays intact for the parent)', () => {
|
|
54
|
+
const src: Record<string, string | undefined> = { ANTHROPIC_API_KEY: 'sk', PATH: '/bin' };
|
|
55
|
+
const out = scrubAgentEnv(src);
|
|
56
|
+
expect(src.ANTHROPIC_API_KEY).toBe('sk');
|
|
57
|
+
expect(out).not.toBe(src);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('defaults to process.env when called with no argument', () => {
|
|
61
|
+
// Smoke: doesn't throw, returns a string map without the scrubbed keys.
|
|
62
|
+
const out = scrubAgentEnv();
|
|
63
|
+
expect('ANTHROPIC_API_KEY' in out).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ACP bridge origin gate (DDR-123). The agent bridge spawns the user's `claude`
|
|
2
|
+
// and can drive file edits, so it lives ONLY on the main (privileged) origin and
|
|
3
|
+
// only over loopback. This proves:
|
|
4
|
+
// (1) GET /_api/acp/status serves on the main origin;
|
|
5
|
+
// (2) it is 403'd on the untrusted canvas origin (off CANVAS_SAFE_API +
|
|
6
|
+
// startCanvasServer routes — the dual-allowlist rule);
|
|
7
|
+
// (3) /_ws/acp is wired on the main origin (upgrade-without-headers → 400);
|
|
8
|
+
// (4) the loopback guard that fronts /_ws/acp rejects non-loopback hosts.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { isLoopbackHost } from '../ws.ts';
|
|
15
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
16
|
+
|
|
17
|
+
async function readCanvasOrigin(designRoot: string): Promise<string> {
|
|
18
|
+
for (let i = 0; i < 40; i++) {
|
|
19
|
+
try {
|
|
20
|
+
const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
|
|
21
|
+
if (info.canvasOrigin) return info.canvasOrigin as string;
|
|
22
|
+
} catch {
|
|
23
|
+
/* not written yet */
|
|
24
|
+
}
|
|
25
|
+
await Bun.sleep(50);
|
|
26
|
+
}
|
|
27
|
+
throw new Error('canvasOrigin never appeared in _server.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('ACP bridge origin gate', () => {
|
|
31
|
+
test('status is main-origin only; /_ws/acp wired on main', async () => {
|
|
32
|
+
const { root, designRoot } = makeSandbox();
|
|
33
|
+
const port = nextPort();
|
|
34
|
+
const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
|
|
35
|
+
try {
|
|
36
|
+
const main = `http://localhost:${port}`;
|
|
37
|
+
const canvas = await readCanvasOrigin(designRoot);
|
|
38
|
+
const status = async (base: string, path: string) =>
|
|
39
|
+
(await fetch(base + path, { signal: AbortSignal.timeout(2000) })).status;
|
|
40
|
+
|
|
41
|
+
// (1) main origin exposes the readiness probe
|
|
42
|
+
const probe = await fetch(`${main}/_api/acp/status`, { signal: AbortSignal.timeout(2000) });
|
|
43
|
+
expect(probe.status).toBe(200);
|
|
44
|
+
expect(typeof (await probe.json()).available).toBe('boolean');
|
|
45
|
+
|
|
46
|
+
// (2) the untrusted canvas origin must NOT reach the probe
|
|
47
|
+
expect(await status(canvas, '/_api/acp/status')).toBe(403);
|
|
48
|
+
|
|
49
|
+
// (3) /_ws/acp is wired on the main origin — a plain GET (no Upgrade
|
|
50
|
+
// headers) passes the loopback guard then fails the WS upgrade → 400,
|
|
51
|
+
// which proves the route branch exists (a 404 would mean it's unwired).
|
|
52
|
+
expect(await status(main, '/_ws/acp')).toBe(400);
|
|
53
|
+
|
|
54
|
+
// (4) /_api/acp/focus (the /design:chat hook) — POST-only on the main
|
|
55
|
+
// origin, 403 on the canvas origin.
|
|
56
|
+
const focusPost = await fetch(`${main}/_api/acp/focus`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
signal: AbortSignal.timeout(2000),
|
|
59
|
+
});
|
|
60
|
+
expect(focusPost.status).toBe(200);
|
|
61
|
+
expect((await focusPost.json()).ok).toBe(true);
|
|
62
|
+
expect(await status(main, '/_api/acp/focus')).toBe(405); // GET not allowed
|
|
63
|
+
expect(await status(canvas, '/_api/acp/focus')).toBe(403); // off the canvas origin
|
|
64
|
+
|
|
65
|
+
// (5) DDR-128 — /_api/preflight readiness probe: main-origin 200 with the
|
|
66
|
+
// {ready, items[]} shape; 403 on the untrusted canvas origin (dual-allowlist).
|
|
67
|
+
const pre = await fetch(`${main}/_api/preflight`, { signal: AbortSignal.timeout(2000) });
|
|
68
|
+
expect(pre.status).toBe(200);
|
|
69
|
+
const preJson = await pre.json();
|
|
70
|
+
expect(typeof preJson.ready).toBe('boolean');
|
|
71
|
+
expect(Array.isArray(preJson.items)).toBe(true);
|
|
72
|
+
expect(await status(canvas, '/_api/preflight')).toBe(403);
|
|
73
|
+
// A cross-origin drive-by GET is Origin-rejected before the probe can shell
|
|
74
|
+
// out — DDR-128 DoS hardening (ethical-hacker F1). Same-origin/loopback (no
|
|
75
|
+
// Origin header, asserted above) stays 200.
|
|
76
|
+
const crossOrigin = await fetch(`${main}/_api/preflight`, {
|
|
77
|
+
headers: { Origin: 'https://evil.example' },
|
|
78
|
+
signal: AbortSignal.timeout(2000),
|
|
79
|
+
});
|
|
80
|
+
expect(crossOrigin.status).toBe(403);
|
|
81
|
+
} finally {
|
|
82
|
+
await killProc(proc);
|
|
83
|
+
}
|
|
84
|
+
}, 15000);
|
|
85
|
+
|
|
86
|
+
test('the loopback guard fronting /_ws/acp rejects non-loopback hosts', () => {
|
|
87
|
+
// Same guard server.ts applies before upgrading the acp socket.
|
|
88
|
+
expect(isLoopbackHost('127.0.0.1:4399')).toBe(true);
|
|
89
|
+
expect(isLoopbackHost('localhost:4399')).toBe(true);
|
|
90
|
+
expect(isLoopbackHost('[::1]:4399')).toBe(true);
|
|
91
|
+
expect(isLoopbackHost('evil.example.com')).toBe(false);
|
|
92
|
+
expect(isLoopbackHost('10.0.0.5:4399')).toBe(false);
|
|
93
|
+
expect(isLoopbackHost(null)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|