@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.
- package/LICENSE +21 -0
- package/dist/channel.d.ts +152 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +595 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +171 -0
- package/dist/index.js.map +1 -0
- package/dist/projector.d.ts +17 -0
- package/dist/projector.d.ts.map +1 -0
- package/dist/projector.js +96 -0
- package/dist/projector.js.map +1 -0
- package/dist/protocol.d.ts +145 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +33 -0
- package/dist/protocol.js.map +1 -0
- package/dist/public/app.js +54 -0
- package/dist/public/index.html +178 -0
- package/dist/tunnel-settings.d.ts +17 -0
- package/dist/tunnel-settings.d.ts.map +1 -0
- package/dist/tunnel-settings.js +42 -0
- package/dist/tunnel-settings.js.map +1 -0
- package/package.json +71 -0
- package/src/channel.test.ts +514 -0
- package/src/channel.ts +669 -0
- package/src/discovery.test.ts +40 -0
- package/src/frontend/chat.test.ts +47 -0
- package/src/frontend/chat.tsx +138 -0
- package/src/frontend/index.html +178 -0
- package/src/frontend/main.tsx +87 -0
- package/src/frontend/render-diff.test.ts +86 -0
- package/src/frontend/render-diff.tsx +66 -0
- package/src/frontend/render.test.ts +166 -0
- package/src/frontend/render.tsx +274 -0
- package/src/frontend/socket.ts +212 -0
- package/src/frontend/url-safety.test.ts +0 -0
- package/src/frontend/url-safety.ts +33 -0
- package/src/frontend/view-store.test.ts +104 -0
- package/src/frontend/view-store.ts +120 -0
- package/src/index.ts +212 -0
- package/src/projector.test.ts +172 -0
- package/src/projector.ts +95 -0
- package/src/protocol.test.ts +59 -0
- package/src/protocol.ts +105 -0
- package/src/tunnel-settings.test.ts +64 -0
- package/src/tunnel-settings.ts +57 -0
- 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('<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
|
+
}
|