@moxxy/core 0.4.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.
Files changed (151) hide show
  1. package/dist/events/log.d.ts.map +1 -1
  2. package/dist/events/log.js +38 -1
  3. package/dist/events/log.js.map +1 -1
  4. package/dist/permissions/engine.d.ts.map +1 -1
  5. package/dist/permissions/engine.js +80 -17
  6. package/dist/permissions/engine.js.map +1 -1
  7. package/dist/plugins/discovery.d.ts.map +1 -1
  8. package/dist/plugins/discovery.js +48 -7
  9. package/dist/plugins/discovery.js.map +1 -1
  10. package/dist/plugins/host.d.ts.map +1 -1
  11. package/dist/plugins/host.js +54 -11
  12. package/dist/plugins/host.js.map +1 -1
  13. package/dist/plugins/lifecycle.d.ts.map +1 -1
  14. package/dist/plugins/lifecycle.js +7 -0
  15. package/dist/plugins/lifecycle.js.map +1 -1
  16. package/dist/plugins/loader.d.ts +9 -0
  17. package/dist/plugins/loader.d.ts.map +1 -1
  18. package/dist/plugins/loader.js +34 -8
  19. package/dist/plugins/loader.js.map +1 -1
  20. package/dist/plugins/registry-kinds.d.ts +21 -2
  21. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  22. package/dist/plugins/registry-kinds.js +3 -1
  23. package/dist/plugins/registry-kinds.js.map +1 -1
  24. package/dist/plugins/toposort.d.ts.map +1 -1
  25. package/dist/plugins/toposort.js +43 -18
  26. package/dist/plugins/toposort.js.map +1 -1
  27. package/dist/preferences.d.ts.map +1 -1
  28. package/dist/preferences.js +5 -4
  29. package/dist/preferences.js.map +1 -1
  30. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  31. package/dist/registries/active-backend-registry.js +10 -1
  32. package/dist/registries/active-backend-registry.js.map +1 -1
  33. package/dist/registries/channels.d.ts +2 -1
  34. package/dist/registries/channels.d.ts.map +1 -1
  35. package/dist/registries/channels.js +30 -20
  36. package/dist/registries/channels.js.map +1 -1
  37. package/dist/registries/commands.d.ts.map +1 -1
  38. package/dist/registries/commands.js +27 -8
  39. package/dist/registries/commands.js.map +1 -1
  40. package/dist/registries/isolators.d.ts +27 -1
  41. package/dist/registries/isolators.d.ts.map +1 -1
  42. package/dist/registries/isolators.js +35 -1
  43. package/dist/registries/isolators.js.map +1 -1
  44. package/dist/registries/modes.d.ts +4 -0
  45. package/dist/registries/modes.d.ts.map +1 -1
  46. package/dist/registries/modes.js +15 -4
  47. package/dist/registries/modes.js.map +1 -1
  48. package/dist/registries/providers.d.ts +8 -0
  49. package/dist/registries/providers.d.ts.map +1 -1
  50. package/dist/registries/providers.js +8 -0
  51. package/dist/registries/providers.js.map +1 -1
  52. package/dist/registries/skills.d.ts +9 -0
  53. package/dist/registries/skills.d.ts.map +1 -1
  54. package/dist/registries/skills.js +22 -0
  55. package/dist/registries/skills.js.map +1 -1
  56. package/dist/requirements.d.ts.map +1 -1
  57. package/dist/requirements.js +7 -1
  58. package/dist/requirements.js.map +1 -1
  59. package/dist/run-turn.d.ts.map +1 -1
  60. package/dist/run-turn.js +65 -17
  61. package/dist/run-turn.js.map +1 -1
  62. package/dist/session.d.ts +9 -0
  63. package/dist/session.d.ts.map +1 -1
  64. package/dist/session.js +25 -3
  65. package/dist/session.js.map +1 -1
  66. package/dist/sessions/persistence.d.ts +15 -0
  67. package/dist/sessions/persistence.d.ts.map +1 -1
  68. package/dist/sessions/persistence.js +116 -13
  69. package/dist/sessions/persistence.js.map +1 -1
  70. package/dist/skills/loader.d.ts.map +1 -1
  71. package/dist/skills/loader.js +11 -2
  72. package/dist/skills/loader.js.map +1 -1
  73. package/dist/skills/synthesize.d.ts.map +1 -1
  74. package/dist/skills/synthesize.js +87 -36
  75. package/dist/skills/synthesize.js.map +1 -1
  76. package/dist/subagents/events.js +33 -2
  77. package/dist/subagents/events.js.map +1 -1
  78. package/dist/subagents/registry.d.ts +22 -1
  79. package/dist/subagents/registry.d.ts.map +1 -1
  80. package/dist/subagents/registry.js +72 -2
  81. package/dist/subagents/registry.js.map +1 -1
  82. package/dist/subagents/run-child.d.ts.map +1 -1
  83. package/dist/subagents/run-child.js +138 -46
  84. package/dist/subagents/run-child.js.map +1 -1
  85. package/dist/surfaces/host.d.ts +10 -0
  86. package/dist/surfaces/host.d.ts.map +1 -1
  87. package/dist/surfaces/host.js +41 -3
  88. package/dist/surfaces/host.js.map +1 -1
  89. package/dist/usage-stats.d.ts +7 -1
  90. package/dist/usage-stats.d.ts.map +1 -1
  91. package/dist/usage-stats.js +41 -15
  92. package/dist/usage-stats.js.map +1 -1
  93. package/dist/view/parse.d.ts.map +1 -1
  94. package/dist/view/parse.js +119 -19
  95. package/dist/view/parse.js.map +1 -1
  96. package/package.json +4 -4
  97. package/src/events/log.test.ts +45 -0
  98. package/src/events/log.ts +39 -1
  99. package/src/permissions/engine.test.ts +60 -0
  100. package/src/permissions/engine.ts +81 -16
  101. package/src/plugins/discovery.test.ts +59 -1
  102. package/src/plugins/discovery.ts +57 -9
  103. package/src/plugins/host.test.ts +40 -0
  104. package/src/plugins/host.ts +48 -10
  105. package/src/plugins/lifecycle.test.ts +29 -0
  106. package/src/plugins/lifecycle.ts +7 -0
  107. package/src/plugins/loader.ts +45 -7
  108. package/src/plugins/registry-kinds.test.ts +53 -0
  109. package/src/plugins/registry-kinds.ts +25 -3
  110. package/src/plugins/toposort.ts +45 -17
  111. package/src/preferences.test.ts +52 -1
  112. package/src/preferences.ts +5 -4
  113. package/src/registries/active-backend-registry.ts +10 -1
  114. package/src/registries/channels.test.ts +79 -0
  115. package/src/registries/channels.ts +33 -18
  116. package/src/registries/commands.test.ts +65 -0
  117. package/src/registries/commands.ts +25 -8
  118. package/src/registries/isolators.test.ts +37 -0
  119. package/src/registries/isolators.ts +48 -1
  120. package/src/registries/modes.test.ts +21 -1
  121. package/src/registries/modes.ts +15 -2
  122. package/src/registries/providers.ts +8 -0
  123. package/src/registries/semantics.test.ts +33 -1
  124. package/src/registries/skills.ts +28 -0
  125. package/src/registries/transcribers.test.ts +13 -0
  126. package/src/requirements.test.ts +35 -0
  127. package/src/requirements.ts +7 -1
  128. package/src/run-turn.test.ts +219 -0
  129. package/src/run-turn.ts +68 -18
  130. package/src/session.test.ts +24 -0
  131. package/src/session.ts +24 -3
  132. package/src/sessions/page.test.ts +17 -0
  133. package/src/sessions/persistence.test.ts +93 -1
  134. package/src/sessions/persistence.ts +138 -30
  135. package/src/skills/loader.test.ts +18 -0
  136. package/src/skills/loader.ts +12 -1
  137. package/src/skills/synthesize.test.ts +59 -0
  138. package/src/skills/synthesize.ts +89 -36
  139. package/src/subagents/events.test.ts +56 -0
  140. package/src/subagents/events.ts +32 -2
  141. package/src/subagents/registry.test.ts +106 -0
  142. package/src/subagents/registry.ts +77 -3
  143. package/src/subagents/run-child.test.ts +125 -2
  144. package/src/subagents/run-child.ts +150 -47
  145. package/src/surfaces/host.test.ts +71 -1
  146. package/src/surfaces/host.ts +39 -2
  147. package/src/usage-stats.test.ts +99 -1
  148. package/src/usage-stats.ts +42 -17
  149. package/src/view/parse-extended.test.ts +80 -0
  150. package/src/view/parse.test.ts +14 -0
  151. package/src/view/parse.ts +113 -18
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { promises as fs } from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
@@ -74,4 +74,102 @@ describe('usage-stats store', () => {
74
74
  await fs.writeFile(statsPath, '{ not json', 'utf8');
75
75
  expect((await loadUsageStats(statsPath)).models).toEqual({});
76
76
  });
77
+
78
+ it('rejects a shape-valid file with a non-numeric counter (no string-concat corruption)', async () => {
79
+ // A hand-edited / partially-written file where a counter is a string used to
80
+ // pass the old `typeof models === 'object'` cast and flow straight into
81
+ // addModelTotals, corrupting the aggregate via "100" + 50 = "10050".
82
+ await fs.writeFile(
83
+ statsPath,
84
+ JSON.stringify({
85
+ version: 1,
86
+ updatedAt: new Date().toISOString(),
87
+ models: {
88
+ 'anthropic/opus': {
89
+ calls: 1,
90
+ inputTokens: '100',
91
+ outputTokens: 0,
92
+ cacheReadTokens: 0,
93
+ cacheCreationTokens: 0,
94
+ firstSeen: new Date().toISOString(),
95
+ lastSeen: new Date().toISOString(),
96
+ },
97
+ },
98
+ }),
99
+ 'utf8',
100
+ );
101
+ // Shape-invalid → fall through to empty, exactly like malformed JSON.
102
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
103
+
104
+ // And a subsequent merge produces purely numeric totals (no concatenation).
105
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 50 }) }, statsPath);
106
+ const file = await loadUsageStats(statsPath);
107
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(50);
108
+ expect(typeof file.models['anthropic/opus']!.inputTokens).toBe('number');
109
+ });
110
+
111
+ it('clear and merge serialize on the same mutex (clear cannot resurrect a cleared aggregate)', async () => {
112
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 100 }) }, statsPath);
113
+ // Fire a clear and a merge concurrently. Whatever the interleaving, the two
114
+ // must serialize against each other so the file is never the stale
115
+ // pre-clear snapshot reconstituted by a merge that read before the clear.
116
+ // The only sound end-states are: empty (merge ran then clear), or exactly
117
+ // the merged delta (clear ran then merge) — never the resurrected old 100
118
+ // plus delta.
119
+ await Promise.all([
120
+ clearUsageStats(statsPath),
121
+ mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 7 }) }, statsPath),
122
+ ]);
123
+ const file = await loadUsageStats(statsPath);
124
+ const entry = file.models['anthropic/opus'];
125
+ if (entry) {
126
+ // clear-then-merge ordering: only the post-clear delta survives.
127
+ expect(entry.inputTokens).toBe(7);
128
+ } else {
129
+ // merge-then-clear ordering: aggregate is empty.
130
+ expect(file.models).toEqual({});
131
+ }
132
+ });
133
+
134
+ it('swallows a write failure on merge: logs to stderr, returns the merged file, never throws', async () => {
135
+ // mergeUsageStats runs on shutdown and is documented best-effort — losing one
136
+ // session's stats must never block shutdown. Force an unwritable target by
137
+ // pointing the file *under a regular file* so the atomic writer's
138
+ // `mkdir(dirname)` fails with ENOTDIR.
139
+ const blocker = path.join(tmpDir, 'blocker');
140
+ await fs.writeFile(blocker, 'x', 'utf8');
141
+ const unwritable = path.join(blocker, 'nested', 'usage.json');
142
+ const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
143
+ try {
144
+ const result = await mergeUsageStats(
145
+ { 'anthropic/opus': totals({ inputTokens: 5 }) },
146
+ unwritable,
147
+ );
148
+ // Resolves with the in-memory merged file (the caller still sees the delta),
149
+ // and surfaces the persist failure on stderr rather than throwing.
150
+ expect(result.models['anthropic/opus']!.inputTokens).toBe(5);
151
+ expect(stderr).toHaveBeenCalled();
152
+ } finally {
153
+ stderr.mockRestore();
154
+ }
155
+ });
156
+
157
+ it('clearUsageStats rejects (loud) if it cannot write — a clear that did not persist must not look successful', async () => {
158
+ // Unlike merge, clear is an explicit user action (`/usage clear`); silently
159
+ // swallowing its failure would tell the user the aggregate was wiped when it
160
+ // was not. We pin the current behavior so a future refactor can't quietly
161
+ // turn it into a no-op success.
162
+ const blocker = path.join(tmpDir, 'clear-blocker');
163
+ await fs.writeFile(blocker, 'x', 'utf8');
164
+ const unwritable = path.join(blocker, 'nested', 'usage.json');
165
+ await expect(clearUsageStats(unwritable)).rejects.toBeInstanceOf(Error);
166
+ });
167
+
168
+ it('coalesces a missing firstSeen on merge rather than emitting undefined', async () => {
169
+ // Defense in depth: even if an entry somehow lacks firstSeen, a merge must
170
+ // not propagate `firstSeen: undefined` forward.
171
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 1 }) }, statsPath);
172
+ const file = await loadUsageStats(statsPath);
173
+ expect(file.models['anthropic/opus']!.firstSeen).toBeTruthy();
174
+ });
77
175
  });
@@ -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
- return path.join(os.homedir(), '.moxxy', 'usage.json');
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 parsed = JSON.parse(raw) as unknown;
48
- if (
49
- parsed &&
50
- typeof parsed === 'object' &&
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
- /** Reset the aggregate to empty (the user-facing `/usage clear`). */
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 writeAtomic(emptyStats(), filePath);
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>&#x3C;&#60; &#9731; &#x263A;</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
+ // `&amp;#58;` must become the literal `&#58;`, not `:` (which a chained
112
+ // replace of `&amp;`-then-`&#58;` would wrongly produce).
113
+ const doc = ok('<view><text>a &amp;#58; b</text></view>');
114
+ expect(firstText(find(doc.root, 'text')!)).toBe('a &#58; b');
115
+ });
116
+
117
+ it('leaves an out-of-range / malformed numeric reference as literal text', () => {
118
+ const doc = ok('<view><text>&#xZZ; &#999999999; ok</text></view>');
119
+ expect(firstText(find(doc.root, 'text')!)).toBe('&#xZZ; &#999999999; 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 `&#58;`/`&#x3a;` to `:` at click time, so the parser
200
+ // must too BEFORE the scheme check — otherwise `javascript&#58;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&#58;alert(1)">x</link></view>', /disallowed URL scheme/);
204
+ hasErr('<view><link href="javascript&#x3a;alert(1)">x</link></view>', /disallowed URL scheme/);
205
+ hasErr('<view><link href="java&#115;cript:alert(1)">x</link></view>', /disallowed URL scheme/);
206
+ hasErr('<view><image src="data&#58;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&amp;#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
  });
@@ -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
- value = s.slice(start, i); // keep braces; raw
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 `&amp;#58;` yields the literal `&#58;` rather than being
259
+ * re-decoded to `:` (which a naive chain of `.replace()` calls would do).
260
+ *
261
+ * Numeric references (`&#58;`, `&#x3a;`) 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&#58;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
- .replace(/&lt;/g, '<')
234
- .replace(/&gt;/g, '>')
235
- .replace(/&quot;/g, '"')
236
- .replace(/&#39;/g, "'")
237
- .replace(/&apos;/g, "'")
238
- .replace(/&amp;/g, '&');
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
- const num = Number(value);
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
- function expand(node: ViewNode): ViewNode {
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 (node.tag === 'results') return expandResults(node);
448
- return { ...node, children: node.children.map(expand) };
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