@moxxy/core 0.5.0 → 0.5.1
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/dist/events/log.d.ts.map +1 -1
- package/dist/events/log.js +38 -1
- package/dist/events/log.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/permissions/engine.d.ts.map +1 -1
- package/dist/permissions/engine.js +80 -17
- package/dist/permissions/engine.js.map +1 -1
- package/dist/plugins/discovery.d.ts.map +1 -1
- package/dist/plugins/discovery.js +48 -7
- package/dist/plugins/discovery.js.map +1 -1
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +54 -11
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/lifecycle.d.ts.map +1 -1
- package/dist/plugins/lifecycle.js +7 -0
- package/dist/plugins/lifecycle.js.map +1 -1
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +34 -8
- package/dist/plugins/loader.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +21 -2
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +3 -1
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/plugins/toposort.d.ts.map +1 -1
- package/dist/plugins/toposort.js +43 -18
- package/dist/plugins/toposort.js.map +1 -1
- package/dist/preferences.d.ts.map +1 -1
- package/dist/preferences.js +5 -4
- package/dist/preferences.js.map +1 -1
- package/dist/registries/active-backend-registry.d.ts.map +1 -1
- package/dist/registries/active-backend-registry.js +10 -1
- package/dist/registries/active-backend-registry.js.map +1 -1
- package/dist/registries/channels.d.ts +2 -1
- package/dist/registries/channels.d.ts.map +1 -1
- package/dist/registries/channels.js +30 -20
- package/dist/registries/channels.js.map +1 -1
- package/dist/registries/commands.d.ts.map +1 -1
- package/dist/registries/commands.js +27 -8
- package/dist/registries/commands.js.map +1 -1
- package/dist/registries/isolators.d.ts +27 -1
- package/dist/registries/isolators.d.ts.map +1 -1
- package/dist/registries/isolators.js +35 -1
- package/dist/registries/isolators.js.map +1 -1
- package/dist/registries/modes.d.ts +4 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +15 -4
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/providers.d.ts +8 -0
- package/dist/registries/providers.d.ts.map +1 -1
- package/dist/registries/providers.js +8 -0
- package/dist/registries/providers.js.map +1 -1
- package/dist/registries/skills.d.ts +9 -0
- package/dist/registries/skills.d.ts.map +1 -1
- package/dist/registries/skills.js +22 -0
- package/dist/registries/skills.js.map +1 -1
- package/dist/requirements.d.ts.map +1 -1
- package/dist/requirements.js +7 -1
- package/dist/requirements.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +65 -17
- package/dist/run-turn.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +25 -3
- package/dist/session.js.map +1 -1
- package/dist/sessions/persistence.d.ts +15 -19
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -49
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +11 -2
- package/dist/skills/loader.js.map +1 -1
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +87 -36
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/events.js +33 -2
- package/dist/subagents/events.js.map +1 -1
- package/dist/subagents/registry.d.ts +22 -1
- package/dist/subagents/registry.d.ts.map +1 -1
- package/dist/subagents/registry.js +72 -2
- package/dist/subagents/registry.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +138 -46
- package/dist/subagents/run-child.js.map +1 -1
- package/dist/surfaces/host.d.ts +10 -0
- package/dist/surfaces/host.d.ts.map +1 -1
- package/dist/surfaces/host.js +41 -3
- package/dist/surfaces/host.js.map +1 -1
- package/dist/usage-stats.d.ts +7 -1
- package/dist/usage-stats.d.ts.map +1 -1
- package/dist/usage-stats.js +41 -15
- package/dist/usage-stats.js.map +1 -1
- package/dist/view/parse.d.ts.map +1 -1
- package/dist/view/parse.js +119 -19
- package/dist/view/parse.js.map +1 -1
- package/package.json +4 -4
- package/src/events/log.test.ts +45 -0
- package/src/events/log.ts +39 -1
- package/src/index.ts +0 -1
- package/src/permissions/engine.test.ts +60 -0
- package/src/permissions/engine.ts +81 -16
- package/src/plugins/discovery.test.ts +59 -1
- package/src/plugins/discovery.ts +57 -9
- package/src/plugins/host.test.ts +40 -0
- package/src/plugins/host.ts +48 -10
- package/src/plugins/lifecycle.test.ts +29 -0
- package/src/plugins/lifecycle.ts +7 -0
- package/src/plugins/loader.ts +45 -7
- package/src/plugins/registry-kinds.test.ts +53 -0
- package/src/plugins/registry-kinds.ts +25 -3
- package/src/plugins/toposort.ts +45 -17
- package/src/preferences.test.ts +52 -1
- package/src/preferences.ts +5 -4
- package/src/registries/active-backend-registry.ts +10 -1
- package/src/registries/channels.test.ts +79 -0
- package/src/registries/channels.ts +33 -18
- package/src/registries/commands.test.ts +65 -0
- package/src/registries/commands.ts +25 -8
- package/src/registries/isolators.test.ts +37 -0
- package/src/registries/isolators.ts +48 -1
- package/src/registries/modes.test.ts +21 -1
- package/src/registries/modes.ts +15 -2
- package/src/registries/providers.ts +8 -0
- package/src/registries/semantics.test.ts +33 -1
- package/src/registries/skills.ts +28 -0
- package/src/registries/transcribers.test.ts +13 -0
- package/src/requirements.test.ts +35 -0
- package/src/requirements.ts +7 -1
- package/src/run-turn.test.ts +219 -0
- package/src/run-turn.ts +68 -18
- package/src/session.test.ts +24 -0
- package/src/session.ts +24 -3
- package/src/sessions/page.test.ts +18 -39
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -68
- package/src/skills/loader.test.ts +18 -0
- package/src/skills/loader.ts +12 -1
- package/src/skills/synthesize.test.ts +59 -0
- package/src/skills/synthesize.ts +89 -36
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +32 -2
- package/src/subagents/registry.test.ts +106 -0
- package/src/subagents/registry.ts +77 -3
- package/src/subagents/run-child.test.ts +125 -2
- package/src/subagents/run-child.ts +150 -47
- package/src/surfaces/host.test.ts +71 -1
- package/src/surfaces/host.ts +39 -2
- package/src/usage-stats.test.ts +99 -1
- package/src/usage-stats.ts +42 -17
- package/src/view/parse-extended.test.ts +80 -0
- package/src/view/parse.test.ts +14 -0
- package/src/view/parse.ts +113 -18
package/src/usage-stats.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
-
import * as os from 'node:os';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
2
|
import { addModelTotals, createMutex, type ModelUsageTotals } from '@moxxy/sdk';
|
|
5
|
-
import { writeFileAtomic } from '@moxxy/sdk/server';
|
|
3
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
4
|
+
import { z } from 'zod';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Cross-session token usage, persisted at ~/.moxxy/usage.json. A forward-going
|
|
@@ -30,13 +29,36 @@ export interface UsageStatsFile {
|
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
export function usageStatsPath(): string {
|
|
33
|
-
|
|
32
|
+
// Route through `moxxyPath` so a `$MOXXY_HOME` override relocates the usage
|
|
33
|
+
// aggregate alongside the rest of the data dir. Identical to
|
|
34
|
+
// `~/.moxxy/usage.json` when MOXXY_HOME is unset.
|
|
35
|
+
return moxxyPath('usage.json');
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
function emptyStats(): UsageStatsFile {
|
|
37
39
|
return { version: 1, updatedAt: new Date().toISOString(), models: {} };
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
// Validates the on-disk shape so a hand-edited or partially-written file with a
|
|
43
|
+
// non-numeric counter (e.g. `inputTokens: "100"`) can't flow into
|
|
44
|
+
// `addModelTotals` and corrupt the persisted aggregate via string concatenation.
|
|
45
|
+
// A failed parse falls through to `emptyStats()`, exactly like malformed JSON.
|
|
46
|
+
const storedModelUsageSchema = z.object({
|
|
47
|
+
calls: z.number(),
|
|
48
|
+
inputTokens: z.number(),
|
|
49
|
+
outputTokens: z.number(),
|
|
50
|
+
cacheReadTokens: z.number(),
|
|
51
|
+
cacheCreationTokens: z.number(),
|
|
52
|
+
firstSeen: z.string(),
|
|
53
|
+
lastSeen: z.string(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const usageStatsFileSchema = z.object({
|
|
57
|
+
version: z.literal(1),
|
|
58
|
+
updatedAt: z.string(),
|
|
59
|
+
models: z.record(z.string(), storedModelUsageSchema),
|
|
60
|
+
});
|
|
61
|
+
|
|
40
62
|
/**
|
|
41
63
|
* Read the usage aggregate. Returns an empty file when missing or unparseable —
|
|
42
64
|
* usage stats are an optional, non-load-blocking layer.
|
|
@@ -44,17 +66,12 @@ function emptyStats(): UsageStatsFile {
|
|
|
44
66
|
export async function loadUsageStats(filePath: string = usageStatsPath()): Promise<UsageStatsFile> {
|
|
45
67
|
try {
|
|
46
68
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
typeof (parsed as UsageStatsFile).models === 'object' &&
|
|
52
|
-
(parsed as UsageStatsFile).models !== null
|
|
53
|
-
) {
|
|
54
|
-
return parsed as UsageStatsFile;
|
|
55
|
-
}
|
|
69
|
+
const result = usageStatsFileSchema.safeParse(JSON.parse(raw));
|
|
70
|
+
if (result.success) return result.data;
|
|
71
|
+
// shape-invalid (e.g. a non-numeric counter) — start fresh rather than let
|
|
72
|
+
// a corrupt entry poison the aggregate via string-concat addition.
|
|
56
73
|
} catch {
|
|
57
|
-
// missing or malformed — start fresh
|
|
74
|
+
// missing or malformed JSON — start fresh
|
|
58
75
|
}
|
|
59
76
|
return emptyStats();
|
|
60
77
|
}
|
|
@@ -97,7 +114,7 @@ export async function mergeUsageStats(
|
|
|
97
114
|
if (d.calls === 0) continue;
|
|
98
115
|
const existing = models[key];
|
|
99
116
|
models[key] = existing
|
|
100
|
-
? { ...addModelTotals(existing, d), firstSeen: existing.firstSeen, lastSeen: now }
|
|
117
|
+
? { ...addModelTotals(existing, d), firstSeen: existing.firstSeen ?? now, lastSeen: now }
|
|
101
118
|
: { ...d, firstSeen: now, lastSeen: now };
|
|
102
119
|
}
|
|
103
120
|
const next: UsageStatsFile = { version: 1, updatedAt: now, models };
|
|
@@ -113,7 +130,15 @@ export async function mergeUsageStats(
|
|
|
113
130
|
});
|
|
114
131
|
}
|
|
115
132
|
|
|
116
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Reset the aggregate to empty (the user-facing `/usage clear`). Runs inside the
|
|
135
|
+
* same `mergeMutex` as `mergeUsageStats` so a clear can't interleave with an
|
|
136
|
+
* in-flight merge's read-modify-write — otherwise the merge could write its
|
|
137
|
+
* stale-plus-delta snapshot back after the clear lands and resurrect the cleared
|
|
138
|
+
* aggregate (or the clear could clobber a concurrent merge).
|
|
139
|
+
*/
|
|
117
140
|
export async function clearUsageStats(filePath: string = usageStatsPath()): Promise<void> {
|
|
118
|
-
await
|
|
141
|
+
await mergeMutex.run(async () => {
|
|
142
|
+
await writeAtomic(emptyStats(), filePath);
|
|
143
|
+
});
|
|
119
144
|
}
|
|
@@ -60,6 +60,21 @@ describe('attribute coercion', () => {
|
|
|
60
60
|
hasErr('<view><heading level="5">x</heading></view>', /must be ≤ 3/);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it('rejects JS numeric-literal syntax (hex/binary/octal/exponent) — only plain decimals', () => {
|
|
64
|
+
// `Number()` would silently turn these into surprising values: `0x4`→4 (a
|
|
65
|
+
// human reads hex 16), `0b100`→4, `1e1`→10. The agent must get a clear error
|
|
66
|
+
// instead of a guessed value that doesn't match what it typed.
|
|
67
|
+
hasErr('<view><grid cols="0x4"></grid></view>', /must be a number/);
|
|
68
|
+
hasErr('<view><grid cols="0b100"></grid></view>', /must be a number/);
|
|
69
|
+
hasErr('<view><grid cols="0o7"></grid></view>', /must be a number/);
|
|
70
|
+
hasErr('<view><grid cols="1e1"></grid></view>', /must be a number/);
|
|
71
|
+
hasErr('<view><grid cols="Infinity"></grid></view>', /must be a number/);
|
|
72
|
+
// Plain decimals (incl. whitespace-padded, signed, fractional) still coerce.
|
|
73
|
+
expect(find(ok('<view><grid cols=" 3 "></grid></view>').root, 'grid')!.props.cols).toBe(3);
|
|
74
|
+
expect(find(ok('<view><image src="/x" w="1.5" /></view>').root, 'image')!.props.w).toBe(1.5);
|
|
75
|
+
expect(find(ok('<view><image src="/x" w="-2" /></view>').root, 'image')!.props.w).toBe(-2);
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
it('validates each enum attribute', () => {
|
|
64
79
|
ok('<view><stack gap="lg" align="center"></stack></view>');
|
|
65
80
|
ok('<view><row justify="between"></row></view>');
|
|
@@ -87,6 +102,23 @@ describe('text + entities + comments', () => {
|
|
|
87
102
|
expect(firstText(find(doc.root, 'text')!)).toBe('a & b <tag> "q"');
|
|
88
103
|
});
|
|
89
104
|
|
|
105
|
+
it('decodes decimal + hex numeric character references in text', () => {
|
|
106
|
+
const doc = ok('<view><text><< ☃ ☺</text></view>');
|
|
107
|
+
expect(firstText(find(doc.root, 'text')!)).toBe('<< ☃ ☺');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('decodes entities in a single pass — a double-encoded ampersand is not re-decoded', () => {
|
|
111
|
+
// `&#58;` must become the literal `:`, not `:` (which a chained
|
|
112
|
+
// replace of `&`-then-`:` would wrongly produce).
|
|
113
|
+
const doc = ok('<view><text>a &#58; b</text></view>');
|
|
114
|
+
expect(firstText(find(doc.root, 'text')!)).toBe('a : b');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('leaves an out-of-range / malformed numeric reference as literal text', () => {
|
|
118
|
+
const doc = ok('<view><text>&#xZZ; � ok</text></view>');
|
|
119
|
+
expect(firstText(find(doc.root, 'text')!)).toBe('&#xZZ; � ok');
|
|
120
|
+
});
|
|
121
|
+
|
|
90
122
|
it('drops whitespace-only text nodes', () => {
|
|
91
123
|
const doc = ok('<view>\n <stack>\n <text>hi</text>\n </stack>\n</view>');
|
|
92
124
|
const stack = find(doc.root, 'stack')!;
|
|
@@ -162,6 +194,19 @@ describe('security rejections', () => {
|
|
|
162
194
|
hasErr('<view><image src="data:text/html,<script>" />\n</view>', /disallowed URL scheme/);
|
|
163
195
|
hasErr('<view><link href="ftp://x">y</link></view>', /disallowed URL scheme/);
|
|
164
196
|
});
|
|
197
|
+
|
|
198
|
+
it('numeric-entity-obfuscated scheme is decoded then rejected (no relative-URL bypass)', () => {
|
|
199
|
+
// A browser decodes `:`/`:` to `:` at click time, so the parser
|
|
200
|
+
// must too BEFORE the scheme check — otherwise `javascript:alert(1)`
|
|
201
|
+
// looks like a harmless relative URL here yet executes as javascript: in the
|
|
202
|
+
// shared view. (Named-only entity decoding missed these → live click-XSS.)
|
|
203
|
+
hasErr('<view><link href="javascript:alert(1)">x</link></view>', /disallowed URL scheme/);
|
|
204
|
+
hasErr('<view><link href="javascript:alert(1)">x</link></view>', /disallowed URL scheme/);
|
|
205
|
+
hasErr('<view><link href="javascript:alert(1)">x</link></view>', /disallowed URL scheme/);
|
|
206
|
+
hasErr('<view><image src="data:text/html,x" /></view>', /disallowed URL scheme/);
|
|
207
|
+
// A double-encoded ampersand must NOT be re-decoded into a live scheme.
|
|
208
|
+
ok('<view><link href="https://x/?a&#58;b">y</link></view>');
|
|
209
|
+
});
|
|
165
210
|
});
|
|
166
211
|
|
|
167
212
|
describe('action extraction', () => {
|
|
@@ -252,4 +297,39 @@ describe('large / deep documents', () => {
|
|
|
252
297
|
const doc = ok(`<view>${inner}</view>`);
|
|
253
298
|
expect(firstText(doc.root)).toBe('deep');
|
|
254
299
|
});
|
|
300
|
+
|
|
301
|
+
it('rejects nesting past the depth cap instead of overflowing the stack', () => {
|
|
302
|
+
let inner = '<text>deep</text>';
|
|
303
|
+
for (let i = 0; i < 1000; i++) inner = `<stack>${inner}</stack>`;
|
|
304
|
+
const r = parse(`<view>${inner}</view>`);
|
|
305
|
+
expect(r.ok).toBe(false);
|
|
306
|
+
if (r.ok) return;
|
|
307
|
+
expect(r.errors.some((e) => /view nesting too deep/.test(e.message))).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('brace attribute values', () => {
|
|
312
|
+
it('strips outer braces and treats the inner text as the literal value', () => {
|
|
313
|
+
const doc = ok('<view><text>{plain}</text><image src="/x.png" alt={hello} /></view>');
|
|
314
|
+
expect(find(doc.root, 'image')!.props.alt).toBe('hello');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('runs the URL-scheme check on the de-braced value (no { } bypass)', () => {
|
|
318
|
+
// The braces must NOT classify `{javascript:…}` as a harmless relative URL.
|
|
319
|
+
hasErr('<view><link href={javascript:alert(1)}>x</link></view>', /disallowed URL scheme/);
|
|
320
|
+
ok('<view><link href={https://ok.test/}>x</link></view>');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('component allow-list / expander coverage', () => {
|
|
325
|
+
it('rejects a component-flagged tag that has no registered expander', () => {
|
|
326
|
+
const allow = [
|
|
327
|
+
{ tag: 'view', attrs: {}, allowedChildren: 'any' as const },
|
|
328
|
+
{ tag: 'widget', attrs: {}, allowedChildren: 'none' as const, component: true },
|
|
329
|
+
];
|
|
330
|
+
const r = parseView('<view><widget /></view>', allow);
|
|
331
|
+
expect(r.ok).toBe(false);
|
|
332
|
+
if (r.ok) return;
|
|
333
|
+
expect(r.errors.some((e) => /no registered expander/.test(e.message))).toBe(true);
|
|
334
|
+
});
|
|
255
335
|
});
|
package/src/view/parse.test.ts
CHANGED
|
@@ -229,4 +229,18 @@ describe('validateDoc', () => {
|
|
|
229
229
|
expect(errsFor('link', { href: 'https://example.com' })).toEqual([]);
|
|
230
230
|
expect(errsFor('image', { src: 'data:image/png;base64,AAAA' })).toEqual([]);
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
it('rejects a pathologically deep hand-built AST instead of overflowing the stack', () => {
|
|
234
|
+
// A validator that itself throws RangeError is worse than one that rejects.
|
|
235
|
+
// Build a tree far deeper than the parser's source-length budget would allow.
|
|
236
|
+
let root: ViewNode = { kind: 'element', tag: 'stack', props: {}, children: [] };
|
|
237
|
+
for (let i = 0; i < 20000; i++) {
|
|
238
|
+
root = { kind: 'element', tag: 'stack', props: {}, children: [root] };
|
|
239
|
+
}
|
|
240
|
+
let errs: ReadonlyArray<{ message: string }> = [];
|
|
241
|
+
expect(() => {
|
|
242
|
+
errs = validateDoc({ root }, DEFAULT_VIEW_TAGS);
|
|
243
|
+
}).not.toThrow();
|
|
244
|
+
expect(errs.some((e) => /view nesting too deep/.test(e.message))).toBe(true);
|
|
245
|
+
});
|
|
232
246
|
});
|
package/src/view/parse.ts
CHANGED
|
@@ -19,6 +19,16 @@ import type {
|
|
|
19
19
|
* nodes. The browser renderer applies the same allow-list as defense in depth.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Hard cap on view-tree nesting depth, honored by every recursive descent
|
|
24
|
+
* (convert/expand/validateDoc). Comfortably above any legitimate UI but far
|
|
25
|
+
* below Node's native call-stack limit, so a hostile or malformed AST yields a
|
|
26
|
+
* normal {@link ViewParseError} instead of an uncaught `RangeError: Maximum
|
|
27
|
+
* call stack size exceeded`. parseView already converts a thrown overflow into
|
|
28
|
+
* a clean result; validateDoc has no such guard, so the cap is the wall there.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_VIEW_DEPTH = 256;
|
|
31
|
+
|
|
22
32
|
// ---------------------------------------------------------------------------
|
|
23
33
|
// Tokenizer
|
|
24
34
|
// ---------------------------------------------------------------------------
|
|
@@ -169,7 +179,13 @@ function parseAttrs(s: string): RawAttr[] {
|
|
|
169
179
|
}
|
|
170
180
|
i++;
|
|
171
181
|
}
|
|
172
|
-
|
|
182
|
+
// There is no JSX-expression evaluation behind `{...}`; strip the outer
|
|
183
|
+
// braces and treat the inner text as the literal value so it flows
|
|
184
|
+
// through coerceValue (incl. the href/src URL-scheme check) on the
|
|
185
|
+
// de-braced string — keeping the braces would smuggle a `{javascript:…}`
|
|
186
|
+
// past isSafeViewUrl as a bogus relative URL.
|
|
187
|
+
const raw = s.slice(start, i);
|
|
188
|
+
value = raw.startsWith('{') && raw.endsWith('}') ? raw.slice(1, -1) : raw;
|
|
173
189
|
} else {
|
|
174
190
|
while (i < n && !/\s/.test(s[i]!)) {
|
|
175
191
|
value += s[i]!;
|
|
@@ -228,14 +244,47 @@ function build(tokens: Token[], src: string): BNode {
|
|
|
228
244
|
return elements[0]!;
|
|
229
245
|
}
|
|
230
246
|
|
|
247
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
248
|
+
lt: '<',
|
|
249
|
+
gt: '>',
|
|
250
|
+
quot: '"',
|
|
251
|
+
apos: "'",
|
|
252
|
+
amp: '&',
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Decode the named + numeric HTML character references a browser would resolve.
|
|
257
|
+
* Done in a SINGLE pass (one regex) so each `&…;` is consumed exactly once: a
|
|
258
|
+
* double-encoded `&#58;` yields the literal `:` rather than being
|
|
259
|
+
* re-decoded to `:` (which a naive chain of `.replace()` calls would do).
|
|
260
|
+
*
|
|
261
|
+
* Numeric references (`:`, `:`) MUST be decoded here, not just the
|
|
262
|
+
* named ones: this output feeds the href/src URL-scheme check in coerceValue,
|
|
263
|
+
* and a browser decodes `javascript:alert(1)` to `javascript:` at click
|
|
264
|
+
* time. Decoding numeric refs first means isSafeViewUrl sees the same `:` the
|
|
265
|
+
* browser will, so an entity-obfuscated `javascript:`/`data:text` scheme is
|
|
266
|
+
* rejected instead of sailing through as a bogus relative URL. The SDK check
|
|
267
|
+
* additionally strips C0 control chars, so a numeric ref that decodes to one
|
|
268
|
+
* cannot smuggle a scheme either.
|
|
269
|
+
*/
|
|
231
270
|
function decodeEntities(s: string): string {
|
|
232
|
-
return s
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
271
|
+
if (s.indexOf('&') === -1) return s;
|
|
272
|
+
return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body: string) => {
|
|
273
|
+
if (body[0] === '#') {
|
|
274
|
+
const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
275
|
+
// Reject out-of-range / non-character code points (surrogates, > U+10FFFF);
|
|
276
|
+
// leave the raw text untouched rather than emit U+FFFD or throw.
|
|
277
|
+
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
|
|
278
|
+
return whole;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
return String.fromCodePoint(code);
|
|
282
|
+
} catch {
|
|
283
|
+
return whole;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body]! : whole;
|
|
287
|
+
});
|
|
239
288
|
}
|
|
240
289
|
|
|
241
290
|
// ---------------------------------------------------------------------------
|
|
@@ -262,7 +311,13 @@ function coerceValue(
|
|
|
262
311
|
return '';
|
|
263
312
|
}
|
|
264
313
|
if (spec.type === 'number') {
|
|
265
|
-
|
|
314
|
+
// Only accept a plain decimal the agent actually wrote. JS `Number()` also
|
|
315
|
+
// parses `0x10`/`0b1`/`0o7`/`1e3` — so `cols="0x4"` would silently become 4
|
|
316
|
+
// (a human reads hex 16) and `cols="1e1"` → 10, surprising the author.
|
|
317
|
+
// Require an optional sign + digits with at most one decimal point; reject
|
|
318
|
+
// everything else with a clear error instead of guessing.
|
|
319
|
+
const trimmed = value.trim();
|
|
320
|
+
const num = /^[+-]?(\d+\.?\d*|\.\d+)$/.test(trimmed) ? Number(trimmed) : NaN;
|
|
266
321
|
if (!Number.isFinite(num)) {
|
|
267
322
|
errors.push({ message: `<${tag}> attribute "${name}" must be a number` });
|
|
268
323
|
return 0;
|
|
@@ -326,11 +381,16 @@ function coerceAttrs(b: BNode, spec: ViewTagSpec | undefined, errors: ViewParseE
|
|
|
326
381
|
return out;
|
|
327
382
|
}
|
|
328
383
|
|
|
329
|
-
function convert(b: BNode, specByTag: Map<string, ViewTagSpec>, errors: ViewParseError[]): ViewNode {
|
|
384
|
+
function convert(b: BNode, specByTag: Map<string, ViewTagSpec>, errors: ViewParseError[], depth = 0): ViewNode {
|
|
330
385
|
const spec = specByTag.get(b.tag);
|
|
331
386
|
if (!spec) errors.push({ message: `unknown tag <${b.tag}>` });
|
|
332
387
|
const props = coerceAttrs(b, spec, errors);
|
|
333
388
|
const children: ViewNode[] = [];
|
|
389
|
+
if (depth >= MAX_VIEW_DEPTH) {
|
|
390
|
+
// Bound the descent rather than recurse into a native stack overflow.
|
|
391
|
+
errors.push({ message: `<${b.tag}> view nesting too deep (max ${MAX_VIEW_DEPTH})` });
|
|
392
|
+
return finalizeNode({ kind: 'element', tag: b.tag, props, children }, spec);
|
|
393
|
+
}
|
|
334
394
|
for (const c of b.children) {
|
|
335
395
|
if (!isBNode(c)) {
|
|
336
396
|
const v = c.text.replace(/\s+/g, ' ').trim();
|
|
@@ -347,7 +407,7 @@ function convert(b: BNode, specByTag: Map<string, ViewTagSpec>, errors: ViewPars
|
|
|
347
407
|
errors.push({ message: `<${b.tag}> may not contain <${c.tag}>` });
|
|
348
408
|
}
|
|
349
409
|
}
|
|
350
|
-
children.push(convert(c, specByTag, errors));
|
|
410
|
+
children.push(convert(c, specByTag, errors, depth + 1));
|
|
351
411
|
}
|
|
352
412
|
}
|
|
353
413
|
return finalizeNode({ kind: 'element', tag: b.tag, props, children }, spec);
|
|
@@ -442,10 +502,37 @@ function expandResults(node: Extract<ViewNode, { kind: 'element' }>): ViewNode {
|
|
|
442
502
|
return el('stack', { gap: 'md' }, cards);
|
|
443
503
|
}
|
|
444
504
|
|
|
445
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Per-component expanders keyed by tag. A {@link ViewTagSpec} marked
|
|
507
|
+
* `component: true` MUST have an entry here; expand() asserts that invariant so
|
|
508
|
+
* an unexpanded component can never reach a frontend that only knows
|
|
509
|
+
* primitives. Add a new rich component by registering its expander, not by
|
|
510
|
+
* editing expand()'s control flow.
|
|
511
|
+
*/
|
|
512
|
+
const EXPANDERS: Record<string, (node: Extract<ViewNode, { kind: 'element' }>) => ViewNode> = {
|
|
513
|
+
results: expandResults,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
function expand(
|
|
517
|
+
node: ViewNode,
|
|
518
|
+
specByTag: Map<string, ViewTagSpec>,
|
|
519
|
+
errors: ViewParseError[],
|
|
520
|
+
depth = 0,
|
|
521
|
+
): ViewNode {
|
|
446
522
|
if (node.kind !== 'element') return node;
|
|
447
|
-
if (
|
|
448
|
-
|
|
523
|
+
if (depth >= MAX_VIEW_DEPTH) {
|
|
524
|
+
errors.push({ message: `<${node.tag}> view nesting too deep (max ${MAX_VIEW_DEPTH})` });
|
|
525
|
+
return node;
|
|
526
|
+
}
|
|
527
|
+
const expander = EXPANDERS[node.tag];
|
|
528
|
+
if (expander) return expander(node);
|
|
529
|
+
// A component-flagged tag with no registered expander would survive to the
|
|
530
|
+
// renderer as a non-primitive — reject it instead of leaking it through.
|
|
531
|
+
if (specByTag.get(node.tag)?.component) {
|
|
532
|
+
errors.push({ message: `<${node.tag}> is a component with no registered expander` });
|
|
533
|
+
return node;
|
|
534
|
+
}
|
|
535
|
+
return { ...node, children: node.children.map((c) => expand(c, specByTag, errors, depth + 1)) };
|
|
449
536
|
}
|
|
450
537
|
|
|
451
538
|
// ---------------------------------------------------------------------------
|
|
@@ -460,7 +547,8 @@ export function parseView(source: string, allowList: ReadonlyArray<ViewTagSpec>)
|
|
|
460
547
|
const errors: ViewParseError[] = [];
|
|
461
548
|
let root = convert(rootB, specByTag, errors);
|
|
462
549
|
if (errors.length) return { ok: false, errors };
|
|
463
|
-
root = expand(root);
|
|
550
|
+
root = expand(root, specByTag, errors);
|
|
551
|
+
if (errors.length) return { ok: false, errors };
|
|
464
552
|
const title = root.kind === 'element' && root.tag === 'view' && typeof root.props.title === 'string' ? root.props.title : undefined;
|
|
465
553
|
return { ok: true, doc: { root, ...(title ? { title } : {}) } };
|
|
466
554
|
} catch (e) {
|
|
@@ -473,8 +561,15 @@ export function parseView(source: string, allowList: ReadonlyArray<ViewTagSpec>)
|
|
|
473
561
|
export function validateDoc(doc: ViewDoc, allowList: ReadonlyArray<ViewTagSpec>): ViewParseError[] {
|
|
474
562
|
const specByTag = new Map(allowList.map((s) => [s.tag, s]));
|
|
475
563
|
const errors: ViewParseError[] = [];
|
|
476
|
-
const walk = (node: ViewNode) => {
|
|
564
|
+
const walk = (node: ViewNode, depth: number) => {
|
|
477
565
|
if (node.kind !== 'element') return;
|
|
566
|
+
if (depth >= MAX_VIEW_DEPTH) {
|
|
567
|
+
// Match parseView's hardening: a deep AST handed straight to validateDoc
|
|
568
|
+
// (over the transport, or from a replacement renderer) must reject, not
|
|
569
|
+
// throw an uncaught RangeError at the caller.
|
|
570
|
+
errors.push({ message: `<${node.tag}> view nesting too deep (max ${MAX_VIEW_DEPTH})` });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
478
573
|
const spec = specByTag.get(node.tag);
|
|
479
574
|
if (!spec) {
|
|
480
575
|
errors.push({ message: `unknown tag <${node.tag}>` });
|
|
@@ -507,10 +602,10 @@ export function validateDoc(doc: ViewDoc, allowList: ReadonlyArray<ViewTagSpec>)
|
|
|
507
602
|
} else if (spec.allowedChildren !== 'any' && c.value.trim()) {
|
|
508
603
|
errors.push({ message: `<${node.tag}> may not contain text` });
|
|
509
604
|
}
|
|
510
|
-
walk(c);
|
|
605
|
+
walk(c, depth + 1);
|
|
511
606
|
}
|
|
512
607
|
};
|
|
513
|
-
walk(doc.root);
|
|
608
|
+
walk(doc.root, 0);
|
|
514
609
|
return errors;
|
|
515
610
|
}
|
|
516
611
|
|