@moxxy/plugin-channel-web 0.27.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel.d.ts +152 -0
  3. package/dist/channel.d.ts.map +1 -0
  4. package/dist/channel.js +595 -0
  5. package/dist/channel.js.map +1 -0
  6. package/dist/index.d.ts +53 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +171 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/projector.d.ts +17 -0
  11. package/dist/projector.d.ts.map +1 -0
  12. package/dist/projector.js +96 -0
  13. package/dist/projector.js.map +1 -0
  14. package/dist/protocol.d.ts +145 -0
  15. package/dist/protocol.d.ts.map +1 -0
  16. package/dist/protocol.js +33 -0
  17. package/dist/protocol.js.map +1 -0
  18. package/dist/public/app.js +54 -0
  19. package/dist/public/index.html +178 -0
  20. package/dist/tunnel-settings.d.ts +17 -0
  21. package/dist/tunnel-settings.d.ts.map +1 -0
  22. package/dist/tunnel-settings.js +42 -0
  23. package/dist/tunnel-settings.js.map +1 -0
  24. package/package.json +71 -0
  25. package/src/channel.test.ts +514 -0
  26. package/src/channel.ts +669 -0
  27. package/src/discovery.test.ts +40 -0
  28. package/src/frontend/chat.test.ts +47 -0
  29. package/src/frontend/chat.tsx +138 -0
  30. package/src/frontend/index.html +178 -0
  31. package/src/frontend/main.tsx +87 -0
  32. package/src/frontend/render-diff.test.ts +86 -0
  33. package/src/frontend/render-diff.tsx +66 -0
  34. package/src/frontend/render.test.ts +166 -0
  35. package/src/frontend/render.tsx +274 -0
  36. package/src/frontend/socket.ts +212 -0
  37. package/src/frontend/url-safety.test.ts +0 -0
  38. package/src/frontend/url-safety.ts +33 -0
  39. package/src/frontend/view-store.test.ts +104 -0
  40. package/src/frontend/view-store.ts +120 -0
  41. package/src/index.ts +212 -0
  42. package/src/projector.test.ts +172 -0
  43. package/src/projector.ts +95 -0
  44. package/src/protocol.test.ts +59 -0
  45. package/src/protocol.ts +105 -0
  46. package/src/tunnel-settings.test.ts +64 -0
  47. package/src/tunnel-settings.ts +57 -0
  48. package/src/tunnel-tools.test.ts +120 -0
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import type { ReactElement } from 'react';
4
+ import type { ViewNode } from '@moxxy/sdk';
5
+ import { renderNode } from './render.js';
6
+
7
+ const noop = (): void => undefined;
8
+ const handlers = { dispatch: noop, navigate: noop };
9
+ const render = (node: ViewNode): string => renderToStaticMarkup(renderNode(node, handlers) as ReactElement);
10
+
11
+ const el = (
12
+ tag: string,
13
+ props: Record<string, string | number | boolean> = {},
14
+ children: ViewNode[] = [],
15
+ action?: { name: string; fields: string[] },
16
+ nav?: string,
17
+ ): ViewNode => ({ kind: 'element', tag, props, children, ...(action ? { action } : {}), ...(nav ? { nav } : {}) });
18
+ const txt = (value: string): ViewNode => ({ kind: 'text', value });
19
+
20
+ describe('frontend renderNode — security (the second wall)', () => {
21
+ it('escapes text so injected HTML cannot execute', () => {
22
+ const html = render(el('view', {}, [el('text', {}, [txt('<img src=x onerror=alert(1)>')])]));
23
+ expect(html).not.toContain('<img');
24
+ expect(html).toContain('&lt;img');
25
+ });
26
+
27
+ it('renders an unknown tag as an inert placeholder, never the raw tag', () => {
28
+ const html = render(el('view', {}, [el('script', {}, [txt('alert(1)')])]));
29
+ expect(html.toLowerCase()).toContain('unsupported');
30
+ expect(html).not.toContain('<script');
31
+ });
32
+
33
+ it('never emits event-handler attributes (handlers are functions, dropped by static render)', () => {
34
+ const html = render(el('view', {}, [el('button', { label: 'Go' }, [], { name: 'go', fields: [] })]));
35
+ expect(html).toContain('Go');
36
+ expect(html).not.toContain('onclick=');
37
+ expect(html).not.toContain('onsubmit=');
38
+ });
39
+
40
+ it('neutralizes a javascript: href — rendered as plain text, never a clickable anchor', () => {
41
+ const html = render(el('view', {}, [el('link', { href: 'javascript:alert(1)' }, [txt('click me')])]));
42
+ expect(html).toContain('click me');
43
+ expect(html).not.toContain('href=');
44
+ expect(html).not.toContain('javascript:');
45
+ });
46
+
47
+ it('neutralizes a data:text href', () => {
48
+ const html = render(el('view', {}, [el('link', { href: 'data:text/html,<script>1</script>' }, [txt('x')])]));
49
+ expect(html).not.toContain('href=');
50
+ expect(html).not.toContain('data:text');
51
+ });
52
+
53
+ it('neutralizes a javascript: href split by ASCII whitespace/control chars (browser strips them on click)', () => {
54
+ // The HTML5 URL parser strips tab/newline/CR (and leading C0 controls)
55
+ // before scheme resolution, so each of these executes as `javascript:` on
56
+ // click. The render-time gate must block them all → plain text, no href.
57
+ for (const href of [
58
+ 'java\tscript:alert(1)',
59
+ 'java\nscript:alert(1)',
60
+ 'java\rscript:alert(1)',
61
+ 'jav ascript:alert(1)',
62
+ 'javascript:alert(1)',
63
+ ]) {
64
+ const html = render(el('view', {}, [el('link', { href }, [txt('click me')])]));
65
+ expect(html).toContain('click me');
66
+ expect(html).not.toContain('href=');
67
+ expect(html.toLowerCase()).not.toContain('script:');
68
+ }
69
+ });
70
+
71
+ it('blocks an <img> whose src scheme is split by whitespace/control chars', () => {
72
+ for (const src of ['java\tscript:alert(1)', 'javascript:alert(1)']) {
73
+ const html = render(el('view', {}, [el('image', { src })]));
74
+ expect(html).not.toContain('<img');
75
+ expect(html.toLowerCase()).toContain('blocked image');
76
+ }
77
+ });
78
+
79
+ it('still renders an https/relative link untouched (valid flows unchanged)', () => {
80
+ const a = render(el('view', {}, [el('link', { href: 'https://example.com/p?q=1' }, [txt('site')])]));
81
+ expect(a).toContain('href="https://example.com/p?q=1"');
82
+ // a relative href stays a clickable external anchor
83
+ const rel = render(el('view', {}, [el('link', { href: '/local/path' }, [txt('local')])]));
84
+ expect(rel).toContain('href="/local/path"');
85
+ });
86
+
87
+ it('blocks an <img> with a non-image data: or javascript: src', () => {
88
+ for (const src of ['javascript:alert(1)', 'data:text/html,<script>1</script>']) {
89
+ const html = render(el('view', {}, [el('image', { src })]));
90
+ expect(html).not.toContain('<img');
91
+ expect(html.toLowerCase()).toContain('blocked image');
92
+ }
93
+ });
94
+
95
+ it('still renders safe https links and data:image sources', () => {
96
+ const a = render(el('view', {}, [el('link', { href: 'https://example.com' }, [txt('site')])]));
97
+ expect(a).toContain('href="https://example.com"');
98
+ const img = render(el('view', {}, [el('image', { src: 'data:image/png;base64,AAAA' })]));
99
+ expect(img).toContain('<img');
100
+ });
101
+ });
102
+
103
+ describe('frontend renderNode — correctness', () => {
104
+ it('renders the view title and nested content', () => {
105
+ const html = render(el('view', { title: 'Trip' }, [el('card', { title: 'Flight' }, [el('text', {}, [txt('hi')])])]));
106
+ expect(html).toContain('Trip');
107
+ expect(html).toContain('Flight');
108
+ expect(html).toContain('hi');
109
+ });
110
+
111
+ it('renders a form with a named input and the submit label', () => {
112
+ const html = render(
113
+ el('view', {}, [el('form', { submit: 'Search' }, [el('input', { name: 'from', label: 'From' })], { name: 'search', fields: ['from'] })]),
114
+ );
115
+ expect(html).toContain('<form');
116
+ expect(html).toContain('name="from"');
117
+ expect(html).toContain('Search');
118
+ expect(html).toContain('From');
119
+ });
120
+
121
+ it('renders a table with header and cell', () => {
122
+ const html = render(
123
+ el('view', {}, [el('table', {}, [el('tr', {}, [el('th', {}, [txt('H')]), el('td', {}, [txt('1')])])])]),
124
+ );
125
+ expect(html).toContain('<table');
126
+ expect(html).toContain('<th');
127
+ expect(html).toContain('<td');
128
+ expect(html).toContain('H');
129
+ });
130
+
131
+ it('renders the heading at the requested level (clamped 1–3)', () => {
132
+ expect(render(el('view', {}, [el('heading', { level: 2 }, [txt('Hi')])]))).toContain('<h2');
133
+ expect(render(el('view', {}, [el('heading', { level: 9 }, [txt('Hi')])]))).toContain('<h3');
134
+ });
135
+
136
+ it('renders an ordered vs unordered list', () => {
137
+ expect(render(el('view', {}, [el('list', { ordered: true }, [el('item', {}, [txt('a')])])]))).toContain('<ol');
138
+ expect(render(el('view', {}, [el('list', {}, [el('item', {}, [txt('a')])])]))).toContain('<ul');
139
+ });
140
+
141
+ it('renders a spinner and a skeleton with the requested rows', () => {
142
+ expect(render(el('view', {}, [el('spinner', { label: 'Loading…' })]))).toContain('v-spin');
143
+ const sk = render(el('view', {}, [el('skeleton', { rows: 4 })]));
144
+ expect((sk.match(/v-skel-row/g) ?? []).length).toBe(4);
145
+ });
146
+
147
+ it('renders an external link with safe rel/target', () => {
148
+ const html = render(el('view', {}, [el('link', { href: 'https://example.com' }, [txt('site')])]));
149
+ expect(html).toContain('href="https://example.com"');
150
+ expect(html).toContain('rel="noreferrer"');
151
+ });
152
+
153
+ it('renders a nav link (to) as a clickable anchor, not an external target', () => {
154
+ const html = render(el('view', {}, [el('link', { to: 'search' }, [txt('Back')], undefined, 'search')]));
155
+ expect(html).toContain('Back');
156
+ expect(html).toContain('<a');
157
+ expect(html).not.toContain('rel="noreferrer"'); // not an external link
158
+ expect(html).not.toContain('onclick='); // handler is a function, never an attribute
159
+ });
160
+
161
+ it('renders a nav button (to) with no handler attribute', () => {
162
+ const html = render(el('view', {}, [el('button', { to: 'results', label: 'See results' }, [], undefined, 'results')]));
163
+ expect(html).toContain('See results');
164
+ expect(html).not.toContain('onclick=');
165
+ });
166
+ });
@@ -0,0 +1,274 @@
1
+ import { createElement, type ReactNode } from 'react';
2
+ import type { ViewNode } from '@moxxy/sdk';
3
+ import { isSafeViewUrl } from './url-safety.js';
4
+
5
+ export type Dispatch = (action: { name: string }, formValues: Record<string, string>) => void;
6
+
7
+ export interface RenderHandlers {
8
+ /** Submit/click an `action` element → agent turn. */
9
+ dispatch: Dispatch;
10
+ /** A `to`/`nav` element → client-side navigation (or a build turn if uncached). */
11
+ navigate: (name: string) => void;
12
+ }
13
+
14
+ /**
15
+ * Maps the validated AST to React elements via a FIXED tag→element switch.
16
+ * This switch IS the allow-list (defense in depth alongside the parser): an
17
+ * unknown tag renders an inert placeholder, never raw HTML or evaluated code.
18
+ * Agent strings only ever land as escaped React text or typed attributes.
19
+ *
20
+ * Two interaction paths: `node.nav` (a `to` target) navigates client-side; an
21
+ * `action` drives an agent turn. Neither is ever an injected handler string.
22
+ */
23
+ export function renderNode(node: ViewNode, h: RenderHandlers, key?: number): ReactNode {
24
+ if (node.kind === 'text') return node.value;
25
+ const p = node.props;
26
+ const kids = node.children.map((c, i) => renderNode(c, h, i));
27
+ const s = (v: unknown): string | undefined => (v == null ? undefined : String(v));
28
+
29
+ switch (node.tag) {
30
+ case 'view':
31
+ return (
32
+ <div className="v-view" key={key}>
33
+ {p.title ? <h1 className="v-title">{String(p.title)}</h1> : null}
34
+ {kids}
35
+ </div>
36
+ );
37
+ case 'stack':
38
+ return (
39
+ <div className="v-stack" data-gap={s(p.gap)} data-align={s(p.align)} key={key}>
40
+ {kids}
41
+ </div>
42
+ );
43
+ case 'row':
44
+ return (
45
+ <div className="v-row" data-gap={s(p.gap)} data-align={s(p.align)} data-justify={s(p.justify)} key={key}>
46
+ {kids}
47
+ </div>
48
+ );
49
+ case 'grid':
50
+ return (
51
+ <div className="v-grid" style={{ gridTemplateColumns: `repeat(${Number(p.cols) || 1}, 1fr)` }} data-gap={s(p.gap)} key={key}>
52
+ {kids}
53
+ </div>
54
+ );
55
+ case 'card':
56
+ return (
57
+ <div className="v-card" data-accent={s(p.accent)} key={key}>
58
+ {p.title ? <div className="v-card-title">{String(p.title)}</div> : null}
59
+ {kids}
60
+ </div>
61
+ );
62
+ case 'divider':
63
+ return <hr className="v-divider" key={key} />;
64
+ case 'spinner':
65
+ return (
66
+ <div className="v-spinner" role="status" aria-live="polite" aria-busy="true" key={key}>
67
+ <span className="v-spin" aria-hidden="true" />
68
+ <span className="v-text" data-tone="muted">{p.label ? String(p.label) : 'Loading…'}</span>
69
+ </div>
70
+ );
71
+ case 'skeleton': {
72
+ const rows = Math.min(12, Math.max(1, Number(p.rows) || 3));
73
+ return (
74
+ <div className="v-skeleton" key={key}>
75
+ {Array.from({ length: rows }, (_, i) => (
76
+ <div className="v-skel-row" key={i} />
77
+ ))}
78
+ </div>
79
+ );
80
+ }
81
+ case 'heading': {
82
+ const lvl = Math.min(3, Math.max(1, Number(p.level) || 2));
83
+ return createElement(`h${lvl}`, { className: 'v-heading', key }, kids);
84
+ }
85
+ case 'text':
86
+ return (
87
+ <span className="v-text" data-tone={s(p.tone)} data-weight={s(p.weight)} key={key}>
88
+ {kids}
89
+ </span>
90
+ );
91
+ case 'badge':
92
+ return (
93
+ <span className="v-badge" data-tone={s(p.tone)} key={key}>
94
+ {kids}
95
+ </span>
96
+ );
97
+ case 'image': {
98
+ // Render-time URL re-validation (second wall, mirrors validateDoc):
99
+ // an unsafe scheme renders an inert placeholder, never an <img src>.
100
+ const src = String(p.src);
101
+ if (!isSafeViewUrl(src, 'src')) {
102
+ return (
103
+ <div className="v-unknown" key={key}>
104
+ [blocked image: disallowed URL scheme]
105
+ </div>
106
+ );
107
+ }
108
+ return <img className="v-image" src={src} alt={p.alt ? String(p.alt) : ''} key={key} />;
109
+ }
110
+ case 'link': {
111
+ // A `to` link navigates client-side; otherwise it is an external anchor.
112
+ if (node.nav) {
113
+ return (
114
+ <a
115
+ className="v-link"
116
+ href="#"
117
+ key={key}
118
+ onClick={(e) => {
119
+ e.preventDefault();
120
+ h.navigate(node.nav!);
121
+ }}
122
+ >
123
+ {kids}
124
+ </a>
125
+ );
126
+ }
127
+ // Render-time URL re-validation (second wall, mirrors validateDoc):
128
+ // an unsafe href (javascript:, data:text/*, …) renders as plain text
129
+ // — the click-XSS payload never becomes a clickable anchor.
130
+ const href = String(p.href);
131
+ if (!isSafeViewUrl(href, 'href')) {
132
+ return (
133
+ <span className="v-link" key={key}>
134
+ {kids}
135
+ </span>
136
+ );
137
+ }
138
+ return (
139
+ <a className="v-link" href={href} target="_blank" rel="noreferrer" key={key}>
140
+ {kids}
141
+ </a>
142
+ );
143
+ }
144
+ case 'list':
145
+ return p.ordered ? (
146
+ <ol className="v-list" key={key}>{kids}</ol>
147
+ ) : (
148
+ <ul className="v-list" key={key}>{kids}</ul>
149
+ );
150
+ case 'item':
151
+ return <li className="v-item" key={key}>{kids}</li>;
152
+ case 'table':
153
+ return (
154
+ <table className="v-table" key={key}>
155
+ <tbody>{kids}</tbody>
156
+ </table>
157
+ );
158
+ case 'tr':
159
+ return <tr key={key}>{kids}</tr>;
160
+ case 'th':
161
+ // scope="col" lets screen readers associate header cells with their
162
+ // column for the data tables agents commonly build.
163
+ return <th scope="col" style={{ textAlign: align(p.align) }} key={key}>{kids}</th>;
164
+ case 'td':
165
+ return <td style={{ textAlign: align(p.align) }} key={key}>{kids}</td>;
166
+ case 'form':
167
+ return <FormEl node={node} dispatch={h.dispatch} key={key}>{kids}</FormEl>;
168
+ case 'input':
169
+ return (
170
+ <label className="v-field" key={key}>
171
+ {p.label ? <span className="v-label">{String(p.label)}</span> : null}
172
+ <input
173
+ name={String(p.name)}
174
+ type={p.type ? String(p.type) : 'text'}
175
+ placeholder={p.placeholder ? String(p.placeholder) : ''}
176
+ defaultValue={p.value != null ? String(p.value) : undefined}
177
+ required={!!p.required}
178
+ />
179
+ </label>
180
+ );
181
+ case 'select':
182
+ return (
183
+ <label className="v-field" key={key}>
184
+ {p.label ? <span className="v-label">{String(p.label)}</span> : null}
185
+ <select name={String(p.name)} defaultValue={p.value != null ? String(p.value) : undefined}>
186
+ {kids}
187
+ </select>
188
+ </label>
189
+ );
190
+ case 'option':
191
+ return <option value={String(p.value)} key={key}>{kids}</option>;
192
+ case 'checkbox':
193
+ return (
194
+ <label className="v-check" key={key}>
195
+ <input type="checkbox" name={String(p.name)} defaultChecked={!!p.checked} />
196
+ {p.label ? <span>{String(p.label)}</span> : null}
197
+ </label>
198
+ );
199
+ case 'button':
200
+ return <ActionButton node={node} handlers={h} key={key} />;
201
+ default:
202
+ return (
203
+ <div className="v-unknown" key={key}>
204
+ [unsupported: {node.tag}]
205
+ </div>
206
+ );
207
+ }
208
+ }
209
+
210
+ function align(v: unknown): 'left' | 'center' | 'right' {
211
+ return v === 'center' || v === 'right' ? v : 'left';
212
+ }
213
+
214
+ function gatherFormValues(form: HTMLFormElement | null): Record<string, string> {
215
+ const out: Record<string, string> = {};
216
+ if (!form) return out;
217
+ new FormData(form).forEach((value, key) => {
218
+ out[key] = String(value);
219
+ });
220
+ return out;
221
+ }
222
+
223
+ function pick(values: Record<string, string>, fields: ReadonlyArray<string>): Record<string, string> {
224
+ if (fields.length === 0) return values;
225
+ const out: Record<string, string> = {};
226
+ for (const f of fields) if (f in values) out[f] = values[f]!;
227
+ return out;
228
+ }
229
+
230
+ function FormEl(props: { node: Extract<ViewNode, { kind: 'element' }>; dispatch: Dispatch; children: ReactNode }): ReactNode {
231
+ const { node, dispatch, children } = props;
232
+ const name = node.action?.name ?? '';
233
+ const submitLabel = node.props.submit ? String(node.props.submit) : 'Submit';
234
+ return (
235
+ <form
236
+ className="v-form"
237
+ onSubmit={(e) => {
238
+ e.preventDefault();
239
+ dispatch({ name }, gatherFormValues(e.currentTarget));
240
+ }}
241
+ >
242
+ {children}
243
+ <div className="v-form-actions">
244
+ <button type="submit" className="v-btn" data-variant="primary">
245
+ {submitLabel}
246
+ </button>
247
+ </div>
248
+ </form>
249
+ );
250
+ }
251
+
252
+ function ActionButton(props: { node: Extract<ViewNode, { kind: 'element' }>; handlers: RenderHandlers }): ReactNode {
253
+ const { node, handlers } = props;
254
+ return (
255
+ <button
256
+ type="button"
257
+ className="v-btn"
258
+ data-variant={node.props.variant != null ? String(node.props.variant) : undefined}
259
+ onClick={(e) => {
260
+ // `to`/nav takes precedence (client-side); else fire the agent action.
261
+ if (node.nav) {
262
+ handlers.navigate(node.nav);
263
+ return;
264
+ }
265
+ if (node.action) {
266
+ const values = gatherFormValues((e.currentTarget as HTMLButtonElement).closest('form'));
267
+ handlers.dispatch({ name: node.action.name }, pick(values, node.action.fields));
268
+ }
269
+ }}
270
+ >
271
+ {String(node.props.label ?? 'OK')}
272
+ </button>
273
+ );
274
+ }
@@ -0,0 +1,212 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { FileDiffDisplay, ViewDoc } from '@moxxy/sdk';
3
+ import type { ClientFrame, ServerFrame } from '../protocol';
4
+ import { applyView, canGoBack, currentEntry, goBack, initialNav, navigateTo, type NavState } from './view-store';
5
+
6
+ /** Reconnect backoff bounds for the surface WebSocket. */
7
+ const MIN_RECONNECT_MS = 500;
8
+ const MAX_RECONNECT_MS = 8_000;
9
+
10
+ /**
11
+ * The auth token, captured from `?t` at module load — BEFORE the address bar is
12
+ * cleaned (see stripTokenFromUrl) — so reconnects and the WS handshake keep
13
+ * working after the visible URL no longer carries it.
14
+ */
15
+ const CAPTURED_TOKEN: string =
16
+ typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('t') ?? '' : '';
17
+
18
+ function readAuthToken(): string {
19
+ return CAPTURED_TOKEN;
20
+ }
21
+
22
+ /**
23
+ * Drop the `?t=…` token from the visible URL (history-replace, no navigation)
24
+ * once it has been captured, so the bearer token isn't persisted to browser
25
+ * history / shoulder-surfed from the address bar. The WS still authenticates
26
+ * from {@link CAPTURED_TOKEN}. Safe no-op when there is no token or no History.
27
+ */
28
+ export function stripTokenFromUrl(): void {
29
+ if (typeof window === 'undefined' || !window.history?.replaceState) return;
30
+ const url = new URL(window.location.href);
31
+ if (!url.searchParams.has('t')) return;
32
+ url.searchParams.delete('t');
33
+ window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
34
+ }
35
+
36
+ /** A prose turn from the user or assistant, mirrored into the transcript. */
37
+ export interface TranscriptText {
38
+ readonly role: 'assistant' | 'user';
39
+ readonly text: string;
40
+ }
41
+
42
+ /** A structured file diff (from a Write/Edit tool result) shown inline in the stream. */
43
+ export interface TranscriptDiff {
44
+ readonly role: 'diff';
45
+ readonly display: FileDiffDisplay;
46
+ }
47
+
48
+ /** One entry in the chat stream — prose or a rendered diff. */
49
+ export type TranscriptMessage = TranscriptText | TranscriptDiff;
50
+
51
+ export interface ViewSocket {
52
+ readonly connected: boolean;
53
+ readonly view: { viewId: string; doc: ViewDoc } | null;
54
+ readonly canGoBack: boolean;
55
+ readonly messages: TranscriptMessage[];
56
+ readonly status: { text: string; error: boolean } | null;
57
+ /** Submit a form / click an action button → agent turn. */
58
+ dispatch(action: { name: string }, formValues: Record<string, string>): void;
59
+ /** Navigate to a named view — instant if cached, else ask the agent to build it. */
60
+ navigate(name: string): void;
61
+ goBack(): void;
62
+ sendPrompt(text: string): void;
63
+ }
64
+
65
+ /** Opens the surface WebSocket and reduces inbound frames into navigable view state. */
66
+ export function useViewSocket(): ViewSocket {
67
+ const [connected, setConnected] = useState(false);
68
+ const [nav, setNav] = useState<NavState>(initialNav);
69
+ const [messages, setMessages] = useState<TranscriptMessage[]>([]);
70
+ const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null);
71
+ const wsRef = useRef<WebSocket | null>(null);
72
+ const navRef = useRef<NavState>(initialNav);
73
+ const setNavState = useCallback((next: NavState) => {
74
+ navRef.current = next;
75
+ setNav(next);
76
+ }, []);
77
+
78
+ const sendAction = useCallback((action: { name: string }, formValues: Record<string, string>) => {
79
+ const ws = wsRef.current;
80
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
81
+ const frame: ClientFrame = {
82
+ kind: 'action',
83
+ actionId: Math.random().toString(36).slice(2),
84
+ viewId: currentEntry(navRef.current)?.viewId ?? null,
85
+ action,
86
+ formValues,
87
+ };
88
+ ws.send(JSON.stringify(frame));
89
+ setStatus({ text: 'working…', error: false });
90
+ }, []);
91
+
92
+ useEffect(() => {
93
+ // The token is captured ONCE here, before main.tsx may strip `?t` from the
94
+ // visible address bar (history.replaceState) to keep it out of browser
95
+ // history. Reconnects reuse this captured copy.
96
+ const token = readAuthToken();
97
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
98
+ // Base path the surface is served under (e.g. `/web` behind the proxy relay,
99
+ // `''` locally) — injected into the page by the server.
100
+ const base = (window as unknown as { __MOXXY_BASE__?: string }).__MOXXY_BASE__ ?? '';
101
+ const wsUrl = `${proto}://${window.location.host}${base}/ws?t=${encodeURIComponent(token)}`;
102
+
103
+ let disposed = false;
104
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
105
+ let backoffMs = MIN_RECONNECT_MS;
106
+
107
+ const handleMessage = (ev: MessageEvent): void => {
108
+ let frame: ServerFrame;
109
+ try {
110
+ frame = JSON.parse(String(ev.data)) as ServerFrame;
111
+ } catch {
112
+ return;
113
+ }
114
+ if (frame.kind === 'view') {
115
+ setNavState(applyView(navRef.current, frame));
116
+ setStatus(null);
117
+ } else if (frame.kind === 'message') {
118
+ setMessages((prev) => [...prev, { role: frame.role, text: frame.text }]);
119
+ } else if (frame.kind === 'file-diff') {
120
+ setMessages((prev) => [...prev, { role: 'diff', display: frame.display }]);
121
+ } else if (frame.kind === 'status') {
122
+ if (frame.phase === 'done') setStatus(null);
123
+ else if (frame.phase === 'error') setStatus({ text: frame.text || 'error', error: true });
124
+ else setStatus({ text: frame.text || 'working…', error: false });
125
+ } else if (frame.kind === 'ack') {
126
+ // A rejected action (e.g. the agent is mid-turn) would otherwise leave
127
+ // the optimistic "working…" spinner up forever — clear it with a notice.
128
+ // An accepted action keeps the working status until its turn completes.
129
+ if (!frame.accepted) {
130
+ setStatus({
131
+ text: frame.reason === 'busy' ? 'agent is busy — try again in a moment' : 'action rejected',
132
+ error: true,
133
+ });
134
+ }
135
+ }
136
+ };
137
+
138
+ const connect = (): void => {
139
+ if (disposed) return;
140
+ const ws = new WebSocket(wsUrl);
141
+ wsRef.current = ws;
142
+ ws.onopen = () => {
143
+ backoffMs = MIN_RECONNECT_MS;
144
+ setConnected(true);
145
+ };
146
+ ws.onmessage = handleMessage;
147
+ ws.onclose = () => {
148
+ wsRef.current = null;
149
+ setConnected(false);
150
+ if (disposed) return;
151
+ // Tunnels (cloudflared/ngrok) and mobile networks drop routinely; the
152
+ // server also closes all sockets on retunnel/stop. Reconnect with capped
153
+ // exponential backoff instead of going permanently dead.
154
+ reconnectTimer = setTimeout(connect, backoffMs);
155
+ backoffMs = Math.min(backoffMs * 2, MAX_RECONNECT_MS);
156
+ };
157
+ // An 'error' that doesn't also fire 'close' would otherwise strand us; the
158
+ // browser always follows a failed/closed socket with 'close', so the
159
+ // reconnect is driven from there. The explicit handler just suppresses the
160
+ // unhandled-error console noise.
161
+ ws.onerror = () => undefined;
162
+ };
163
+
164
+ connect();
165
+ return () => {
166
+ disposed = true;
167
+ if (reconnectTimer) clearTimeout(reconnectTimer);
168
+ const ws = wsRef.current;
169
+ wsRef.current = null;
170
+ if (ws) {
171
+ ws.onclose = null;
172
+ ws.close();
173
+ }
174
+ };
175
+ }, [setNavState]);
176
+
177
+ const navigate = useCallback(
178
+ (name: string) => {
179
+ const next = navigateTo(navRef.current, name);
180
+ if (next) {
181
+ setNavState(next); // cached → instant, no agent turn
182
+ } else {
183
+ sendAction({ name: `navigate:${name}` }, {}); // uncached → ask the agent to build it
184
+ }
185
+ },
186
+ [sendAction, setNavState],
187
+ );
188
+
189
+ const back = useCallback(() => setNavState(goBack(navRef.current)), [setNavState]);
190
+
191
+ const sendPrompt = useCallback((text: string) => {
192
+ const ws = wsRef.current;
193
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
194
+ const frame: ClientFrame = { kind: 'prompt', text };
195
+ ws.send(JSON.stringify(frame));
196
+ setMessages((prev) => [...prev, { role: 'user', text }]);
197
+ setStatus({ text: 'working…', error: false });
198
+ }, []);
199
+
200
+ const entry = currentEntry(nav);
201
+ return {
202
+ connected,
203
+ view: entry ? { viewId: entry.viewId, doc: entry.doc } : null,
204
+ canGoBack: canGoBack(nav),
205
+ messages,
206
+ status,
207
+ dispatch: sendAction,
208
+ navigate,
209
+ goBack: back,
210
+ sendPrompt,
211
+ };
212
+ }
Binary file
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Render-time URL-scheme allow-list for agent-authored `href`/`src` values.
3
+ *
4
+ * VERBATIM COPY of `isSafeViewUrl` in `packages/sdk/src/view-renderer.ts` —
5
+ * the canonical check the parser (`parseView`) and `validateDoc` enforce.
6
+ * The browser bundle is built standalone (esbuild, platform: browser, see
7
+ * `scripts/build-web.mjs`) and cannot import the sdk root, which drags in
8
+ * node builtins — so the check is duplicated here. KEEP THE TWO IN LOCKSTEP.
9
+ *
10
+ * Decision (audit A44): allowed schemes are `https:`, `http:`, `mailto:`,
11
+ * `tel:`, plus relative/fragment URLs; `data:` only as an `img src` and only
12
+ * `data:image/*`. Everything else — notably `javascript:`, `vbscript:`,
13
+ * `data:text/*` — is rejected. Views are shared with third parties, so a
14
+ * `javascript:` link is click-XSS; this is the second wall in case an AST
15
+ * ever reaches the renderer without passing the parser.
16
+ */
17
+ export function isSafeViewUrl(url: string, attr: string): boolean {
18
+ // Make the SAFETY DECISION on the form the browser actually resolves: the
19
+ // HTML5 URL parser strips ALL ASCII whitespace and C0 control chars (tab,
20
+ // newline, CR, …) from anywhere in a URL before resolving the scheme, so
21
+ // `java\tscript:` resolves to `javascript:` on click. Collapse every
22
+ // U+0000–U+0020 char (all ASCII whitespace + C0 controls, which also covers
23
+ // the leading/trailing space the old `trim()` handled) before checking;
24
+ // removing these from a genuinely-safe http/https/mailto/relative URL still
25
+ // resolves to the same scheme, so valid links keep working. The RAW `url` is
26
+ // still what render.tsx blocks or emits — only this gate sees the collapsed
27
+ // form. Keep in lockstep with `packages/sdk/src/view-renderer.ts`.
28
+ const u = url.replace(/[\u0000-\u0020]/g, '').toLowerCase();
29
+ if (u.startsWith('javascript:') || u.startsWith('vbscript:')) return false;
30
+ if (u.startsWith('data:')) return attr === 'src' && u.startsWith('data:image/');
31
+ if (/^[a-z][a-z0-9+.-]*:/.test(u)) return /^(https?:|mailto:|tel:)/.test(u);
32
+ return true; // relative / fragment
33
+ }