@moxxy/sdk 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/assert.d.ts +19 -0
  2. package/dist/assert.d.ts.map +1 -0
  3. package/dist/assert.js +29 -0
  4. package/dist/assert.js.map +1 -0
  5. package/dist/compactor-helpers.d.ts.map +1 -1
  6. package/dist/compactor-helpers.js +6 -3
  7. package/dist/compactor-helpers.js.map +1 -1
  8. package/dist/events.d.ts +1 -1
  9. package/dist/events.d.ts.map +1 -1
  10. package/dist/frontmatter.d.ts +40 -0
  11. package/dist/frontmatter.d.ts.map +1 -0
  12. package/dist/frontmatter.js +161 -0
  13. package/dist/frontmatter.js.map +1 -0
  14. package/dist/fs-utils.d.ts +8 -0
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +29 -0
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/index.d.ts +6 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/json-file-store.d.ts +84 -0
  23. package/dist/json-file-store.d.ts.map +1 -0
  24. package/dist/json-file-store.js +56 -0
  25. package/dist/json-file-store.js.map +1 -0
  26. package/dist/mode.d.ts +10 -0
  27. package/dist/mode.d.ts.map +1 -1
  28. package/dist/mode.js.map +1 -1
  29. package/dist/semver.d.ts +16 -0
  30. package/dist/semver.d.ts.map +1 -0
  31. package/dist/semver.js +28 -0
  32. package/dist/semver.js.map +1 -0
  33. package/dist/tool-dispatch.d.ts.map +1 -1
  34. package/dist/tool-dispatch.js +5 -2
  35. package/dist/tool-dispatch.js.map +1 -1
  36. package/dist/types.test-d.js +5 -0
  37. package/dist/types.test-d.js.map +1 -1
  38. package/dist/view-renderer.d.ts +6 -0
  39. package/dist/view-renderer.d.ts.map +1 -1
  40. package/dist/view-renderer.js +18 -1
  41. package/dist/view-renderer.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/assert.test.ts +35 -0
  44. package/src/assert.ts +28 -0
  45. package/src/compactor-helpers.test.ts +52 -0
  46. package/src/compactor-helpers.ts +6 -2
  47. package/src/events.ts +8 -1
  48. package/src/frontmatter.test.ts +141 -0
  49. package/src/frontmatter.ts +159 -0
  50. package/src/fs-utils.test.ts +47 -1
  51. package/src/fs-utils.ts +31 -0
  52. package/src/index.ts +22 -2
  53. package/src/json-file-store.test.ts +207 -0
  54. package/src/json-file-store.ts +144 -0
  55. package/src/mode.ts +10 -0
  56. package/src/semver.test.ts +43 -0
  57. package/src/semver.ts +27 -0
  58. package/src/tool-dispatch.ts +5 -2
  59. package/src/types.test-d.ts +13 -0
  60. package/src/view-renderer.test.ts +57 -0
  61. package/src/view-renderer.ts +18 -1
@@ -0,0 +1,144 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createMutex, type Mutex } from './mutex.js';
3
+ import { writeFileAtomic, type WriteFileAtomicOptions } from './fs-utils.js';
4
+
5
+ /**
6
+ * Generic whole-file JSON collection store: the single home for the repeated
7
+ * "id-collection" persistence skeleton (invariant #5). It owns the in-memory
8
+ * cache, the per-instance write mutex, the read-modify-write `.slice()` copy,
9
+ * and the crash-atomic whole-file write via {@link writeFileAtomic} — so each
10
+ * domain store (scheduler, webhooks, …) no longer re-derives that atomicity
11
+ * by hand and can't silently regress it (forget the mutex, forget the copy,
12
+ * hand-roll a non-unique tmp).
13
+ *
14
+ * The store persists `{ [itemsKey]: T[], ...extraFileFields }` — by default
15
+ * `{ version: 1, items: [...] }`. The shape and key are configurable so a
16
+ * domain store keeps its exact on-disk format.
17
+ *
18
+ * Parsing/validation and corruption policy stay with the caller via
19
+ * {@link JsonFileStoreOptions.load}: it receives the raw file contents (or
20
+ * `null` when the file is absent) and returns the validated item array. This
21
+ * keeps each store's bespoke corrupt handling (silent reset vs. quarantine
22
+ * aside) byte-for-byte unchanged while centralizing the atomicity invariant.
23
+ *
24
+ * SDK-internal-dep-free: uses only node builtins + the SDK's own
25
+ * {@link createMutex} / {@link writeFileAtomic}.
26
+ */
27
+ export interface JsonFileStoreOptions<T extends { id: string }> {
28
+ /** Absolute path to the JSON file. */
29
+ readonly file: string;
30
+ /**
31
+ * Parse + validate the file contents into the item array. Called once per
32
+ * load with the raw UTF-8 string, or `null` when the file does not exist
33
+ * (ENOENT). Owns all corruption policy (quarantine, silent reset, throw).
34
+ * Any non-ENOENT read error is surfaced through {@link onReadError} first.
35
+ */
36
+ readonly load: (raw: string | null) => T[] | Promise<T[]>;
37
+ /**
38
+ * Called when reading the file fails with a non-ENOENT error (permissions,
39
+ * I/O, …). Return an item array to recover, or throw to refuse to operate
40
+ * (the safe direction when the file may hold the only copy of secrets).
41
+ * Defaults to re-throwing the original error.
42
+ */
43
+ readonly onReadError?: (err: unknown) => T[] | Promise<T[]>;
44
+ /**
45
+ * Property name the items array is stored under. Default `'items'`.
46
+ * (scheduler uses `'schedules'`, webhooks `'triggers'`.)
47
+ */
48
+ readonly itemsKey?: string;
49
+ /**
50
+ * Extra top-level fields written alongside the items. Default
51
+ * `{ version: 1 }`. Pass `{}` for a versionless file.
52
+ */
53
+ readonly fileFields?: Record<string, unknown>;
54
+ /**
55
+ * How the persisted object is serialized. Default: pretty 2-space JSON
56
+ * (matches the scheduler/webhooks format). Receives the full file object.
57
+ */
58
+ readonly stringify?: (fileObject: Record<string, unknown>) => string;
59
+ /** Atomic-write options forwarded to {@link writeFileAtomic} (e.g. `mode`). */
60
+ readonly writeOptions?: WriteFileAtomicOptions;
61
+ }
62
+
63
+ /**
64
+ * The collection store handle. Domain stores compose this and add their own
65
+ * id/createdAt minting and bespoke methods on top of {@link mutate}.
66
+ */
67
+ export interface JsonFileStore<T extends { id: string }> {
68
+ /** Loaded snapshot (a fresh shallow copy; safe to mutate by the caller). */
69
+ read(): Promise<T[]>;
70
+ /** Find a single item by id, or `null`. */
71
+ get(id: string): Promise<T | null>;
72
+ /**
73
+ * Read-modify-write under the write mutex. The mutator receives a fresh
74
+ * shallow copy of the current items; whatever it returns becomes the new
75
+ * state and is persisted atomically before {@link mutate} resolves.
76
+ */
77
+ mutate(fn: (items: T[]) => T[] | Promise<T[]>): Promise<void>;
78
+ /** Drop the cache so the next access re-reads from disk. */
79
+ invalidate(): void;
80
+ }
81
+
82
+ const prettyJson = (value: Record<string, unknown>): string => JSON.stringify(value, null, 2);
83
+
84
+ export function createJsonFileStore<T extends { id: string }>(
85
+ opts: JsonFileStoreOptions<T>,
86
+ ): JsonFileStore<T> {
87
+ const {
88
+ file,
89
+ load,
90
+ onReadError,
91
+ itemsKey = 'items',
92
+ fileFields = { version: 1 },
93
+ stringify = prettyJson,
94
+ writeOptions,
95
+ } = opts;
96
+
97
+ let cache: T[] | null = null;
98
+ const mutex: Mutex = createMutex();
99
+
100
+ async function ensureLoaded(): Promise<void> {
101
+ if (cache) return;
102
+ let raw: string | null;
103
+ try {
104
+ raw = await readFile(file, 'utf8');
105
+ } catch (err) {
106
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
107
+ raw = null;
108
+ } else if (onReadError) {
109
+ cache = await onReadError(err);
110
+ return;
111
+ } else {
112
+ throw err;
113
+ }
114
+ }
115
+ cache = await load(raw);
116
+ }
117
+
118
+ async function persist(items: T[]): Promise<void> {
119
+ const payload = stringify({ ...fileFields, [itemsKey]: items });
120
+ await writeFileAtomic(file, payload, writeOptions ?? {});
121
+ }
122
+
123
+ return {
124
+ async read(): Promise<T[]> {
125
+ await ensureLoaded();
126
+ return cache!.slice();
127
+ },
128
+ async get(id: string): Promise<T | null> {
129
+ await ensureLoaded();
130
+ return cache!.find((item) => item.id === id) ?? null;
131
+ },
132
+ async mutate(fn): Promise<void> {
133
+ await mutex.run(async () => {
134
+ await ensureLoaded();
135
+ const updated = await fn(cache!.slice());
136
+ cache = updated;
137
+ await persist(updated);
138
+ });
139
+ },
140
+ invalidate(): void {
141
+ cache = null;
142
+ },
143
+ };
144
+ }
package/src/mode.ts CHANGED
@@ -64,6 +64,16 @@ export interface ElisionSettings {
64
64
  export interface ModeContext {
65
65
  readonly sessionId: SessionId;
66
66
  readonly turnId: TurnId;
67
+ /**
68
+ * The session's working directory and environment, mirrored from
69
+ * {@link AppContext}. Threaded into the `dispatchToolCall` hook context so
70
+ * `onToolCall` hooks that gate on cwd/env (path-based policy/security hooks)
71
+ * see the real per-session values rather than empty placeholders. Tools
72
+ * themselves get cwd via the tool registry's default; this carries the same
73
+ * truth to the hook layer so the two never disagree.
74
+ */
75
+ readonly cwd: string;
76
+ readonly env: Readonly<Record<string, string | undefined>>;
67
77
  readonly model: string;
68
78
  readonly systemPrompt?: string;
69
79
  readonly provider: LLMProvider;
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compareSemver, parseSemverCore } from './semver.js';
3
+
4
+ describe('compareSemver', () => {
5
+ it('orders by major, then minor, then patch', () => {
6
+ expect(compareSemver('1.0.0', '2.0.0')).toBeLessThan(0);
7
+ expect(compareSemver('1.2.0', '1.1.9')).toBeGreaterThan(0);
8
+ expect(compareSemver('1.1.2', '1.1.10')).toBeLessThan(0);
9
+ expect(compareSemver('3.4.5', '3.4.5')).toBe(0);
10
+ });
11
+
12
+ it('normalizes to -1 / 0 / 1', () => {
13
+ expect(compareSemver('0.0.1', '0.0.2')).toBe(-1);
14
+ expect(compareSemver('0.0.2', '0.0.1')).toBe(1);
15
+ expect(compareSemver('0.0.1', '0.0.1')).toBe(0);
16
+ });
17
+
18
+ it('ignores prerelease/build suffixes (bare-tag precedence)', () => {
19
+ expect(compareSemver('1.0.0-rc.1', '1.0.0')).toBe(0);
20
+ expect(compareSemver('1.0.0+build2', '1.0.0')).toBe(0);
21
+ expect(compareSemver('1.0.0', '1.0.0+build9')).toBe(0);
22
+ });
23
+
24
+ it('treats missing/garbage segments as 0', () => {
25
+ expect(compareSemver('1', '1.0.0')).toBe(0);
26
+ expect(compareSemver('1.2', '1.2.0')).toBe(0);
27
+ expect(compareSemver('vX.Y.Z', '0.0.0')).toBe(0);
28
+ });
29
+
30
+ it('sorts ascending with a deterministic call-site tie-break', () => {
31
+ const tags = ['1.2.0', '1.10.0', '1.2.0+b', '1.1.0'];
32
+ const sorted = [...tags].sort((a, b) => compareSemver(a, b) || a.localeCompare(b));
33
+ expect(sorted).toEqual(['1.1.0', '1.2.0', '1.2.0+b', '1.10.0']);
34
+ });
35
+ });
36
+
37
+ describe('parseSemverCore', () => {
38
+ it('extracts the numeric core', () => {
39
+ expect(parseSemverCore('12.3.45-rc.1')).toEqual([12, 3, 45]);
40
+ expect(parseSemverCore('0.0.0')).toEqual([0, 0, 0]);
41
+ expect(parseSemverCore('7')).toEqual([7, 0, 0]);
42
+ });
43
+ });
package/src/semver.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Minimal numeric `major.minor.patch` comparison. Any prerelease/build suffix
3
+ * (`-rc.1`, `+build2`) is ignored — moxxy release tags are bare `x.y.z`, so this
4
+ * is the precedence the update/check and release paths actually need, without
5
+ * pulling in a full semver dependency.
6
+ *
7
+ * Returns a negative number if `a < b`, positive if `a > b`, `0` if the numeric
8
+ * cores are equal. **When ordering a list where two entries can share a core**
9
+ * (e.g. build-metadata variants), add an explicit, deterministic tie-break at
10
+ * the call site — `Array.prototype.sort` is not guaranteed stable across all
11
+ * inputs, so a `0` here would leave their relative order unspecified.
12
+ */
13
+ export function compareSemver(a: string, b: string): number {
14
+ const pa = parseSemverCore(a);
15
+ const pb = parseSemverCore(b);
16
+ for (let i = 0; i < 3; i += 1) {
17
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
18
+ if (d !== 0) return d < 0 ? -1 : 1;
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ /** Parse the leading `major.minor.patch` of a version/tag into a 3-tuple. */
24
+ export function parseSemverCore(s: string): [number, number, number] {
25
+ const parts = (s.split('-')[0] ?? '').split('.').map((n) => Number.parseInt(n, 10) || 0);
26
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
27
+ }
@@ -26,9 +26,12 @@ export async function* dispatchToolCall(
26
26
  try {
27
27
  const verdict = await ctx.hooks.dispatchToolCall({
28
28
  sessionId: ctx.sessionId,
29
- cwd: '',
29
+ // Hand the hook the session's real cwd/env (mirrored onto ModeContext by
30
+ // run-turn / the subagent runtime). Previously hardcoded '' / {}, which
31
+ // silently defeated path-based onToolCall policy/security hooks.
32
+ cwd: ctx.cwd,
30
33
  log: ctx.log,
31
- env: {},
34
+ env: ctx.env,
32
35
  turnId: ctx.turnId,
33
36
  iteration,
34
37
  call: { callId: asToolCallId(t.id), name: t.name, input: t.input },
@@ -2,9 +2,11 @@ import { describe, expectTypeOf, it } from 'vitest';
2
2
  import type {
3
3
  MoxxyEvent,
4
4
  MoxxyEventOfType,
5
+ PluginRegisteredEvent,
5
6
  ToolCallRequestedEvent,
6
7
  UserPromptEvent,
7
8
  } from './events.js';
9
+ import type { PluginKind } from './plugin.js';
8
10
 
9
11
  describe('event union narrowing', () => {
10
12
  it('MoxxyEventOfType narrows correctly', () => {
@@ -27,3 +29,14 @@ describe('event union narrowing', () => {
27
29
  expectTypeOf(handle).returns.toEqualTypeOf<string | null>();
28
30
  });
29
31
  });
32
+
33
+ describe('PluginRegisteredEvent.kind stays in sync with PluginKind', () => {
34
+ it('the inlined kind union equals PluginKind member-for-member', () => {
35
+ // events.ts inlines the kind union (to avoid an events↔plugin value cycle)
36
+ // with a "keep in sync with PluginKind" note. This bidirectional equality
37
+ // fails the build the moment PluginKind grows a member the inlined union
38
+ // lacks (or vice versa) — exactly the drift the note was meant to prevent.
39
+ type RegisteredKind = PluginRegisteredEvent['kind'][number];
40
+ expectTypeOf<RegisteredKind>().toEqualTypeOf<PluginKind>();
41
+ });
42
+ });
@@ -3,11 +3,38 @@ import {
3
3
  VIEW_PRIMITIVES,
4
4
  VIEW_COMPONENTS,
5
5
  DEFAULT_VIEW_TAGS,
6
+ countNodes,
7
+ isSafeViewUrl,
6
8
  defineViewRenderer,
7
9
  defineTunnelProvider,
10
+ type ViewNode,
8
11
  type ViewTagSpec,
9
12
  } from './index.js';
10
13
 
14
+ describe('countNodes', () => {
15
+ const text = (value: string): ViewNode => ({ kind: 'text', value });
16
+ const el = (tag: string, children: ViewNode[] = []): ViewNode => ({
17
+ kind: 'element',
18
+ tag,
19
+ props: {},
20
+ children,
21
+ });
22
+
23
+ it('counts a single text node as 1', () => {
24
+ expect(countNodes(text('hi'))).toBe(1);
25
+ });
26
+
27
+ it('counts an element plus its descendants', () => {
28
+ const tree = el('col', [el('row', [text('a'), text('b')]), text('c')]);
29
+ // col + row + a + b + c = 5
30
+ expect(countNodes(tree)).toBe(5);
31
+ });
32
+
33
+ it('counts a leaf element as 1', () => {
34
+ expect(countNodes(el('hr'))).toBe(1);
35
+ });
36
+ });
37
+
11
38
  describe('view vocabulary integrity', () => {
12
39
  const all = DEFAULT_VIEW_TAGS;
13
40
 
@@ -88,3 +115,33 @@ describe('define factories freeze their specs', () => {
88
115
  expect(Object.isFrozen(def)).toBe(true);
89
116
  });
90
117
  });
118
+
119
+ describe('isSafeViewUrl', () => {
120
+ it('rejects javascript/vbscript/data-text schemes', () => {
121
+ expect(isSafeViewUrl('javascript:alert(1)', 'href')).toBe(false);
122
+ expect(isSafeViewUrl('vbscript:msgbox', 'href')).toBe(false);
123
+ expect(isSafeViewUrl('data:text/html,<x>', 'src')).toBe(false);
124
+ });
125
+
126
+ it('rejects schemes split by whitespace/control chars (audit u72-1)', () => {
127
+ // a browser strips tab/newline/CR before scheme resolution, so these
128
+ // collapse to javascript: on click; the gate must normalize the same way
129
+ expect(isSafeViewUrl('java\tscript:alert(1)', 'href')).toBe(false);
130
+ expect(isSafeViewUrl('java\nscript:alert(1)', 'href')).toBe(false);
131
+ expect(isSafeViewUrl('java\rscript:alert(1)', 'href')).toBe(false);
132
+ expect(isSafeViewUrl('\u0000javascript:alert(1)', 'href')).toBe(false);
133
+ expect(isSafeViewUrl(' javascript:alert(1)', 'href')).toBe(false);
134
+ });
135
+
136
+ it('allows safe schemes + relative + data-image src', () => {
137
+ expect(isSafeViewUrl('https://example.com/x', 'href')).toBe(true);
138
+ expect(isSafeViewUrl('http://example.com', 'href')).toBe(true);
139
+ expect(isSafeViewUrl('mailto:a@b.com', 'href')).toBe(true);
140
+ expect(isSafeViewUrl('tel:+1555', 'href')).toBe(true);
141
+ expect(isSafeViewUrl('/relative/path', 'href')).toBe(true);
142
+ expect(isSafeViewUrl('#frag', 'href')).toBe(true);
143
+ expect(isSafeViewUrl(' https://example.com ', 'href')).toBe(true);
144
+ expect(isSafeViewUrl('data:image/png;base64,AAAA', 'src')).toBe(true);
145
+ expect(isSafeViewUrl('data:image/png;base64,AAAA', 'href')).toBe(false);
146
+ });
147
+ });
@@ -108,7 +108,14 @@ export interface ViewRendererDef {
108
108
  * parties, so a `javascript:` link is click-XSS on a shared surface.
109
109
  */
110
110
  export function isSafeViewUrl(url: string, attr: string): boolean {
111
- const u = url.trim().toLowerCase();
111
+ // Strip ALL ASCII whitespace + C0 control chars (U+0000–U+0020) anywhere in
112
+ // the URL before the scheme check — the HTML URL parser removes tab/newline/CR
113
+ // before resolving the scheme, so `java\tscript:` would otherwise pass this
114
+ // gate and then execute as `javascript:` on click (audit u72-1). The frontend
115
+ // copy in @moxxy/plugin-channel-web/src/frontend/url-safety.ts is a verbatim
116
+ // lockstep duplicate (the browser bundle can't import this root) — keep both
117
+ // in sync.
118
+ const u = url.replace(/[\u0000-\u0020]/g, '').toLowerCase();
112
119
  if (u.startsWith('javascript:') || u.startsWith('vbscript:')) return false;
113
120
  if (u.startsWith('data:')) return attr === 'src' && u.startsWith('data:image/');
114
121
  if (/^[a-z][a-z0-9+.-]*:/.test(u)) return /^(https?:|mailto:|tel:)/.test(u);
@@ -176,3 +183,13 @@ export const VIEW_COMPONENTS: ReadonlyArray<ViewTagSpec> = [
176
183
 
177
184
  /** The default renderer's full allow-list (primitives + components). */
178
185
  export const DEFAULT_VIEW_TAGS: ReadonlyArray<ViewTagSpec> = [...VIEW_PRIMITIVES, ...VIEW_COMPONENTS];
186
+
187
+ /**
188
+ * Count element + text nodes in a view tree (the `nodeCount` a `present_view`
189
+ * tool reports). Single source of truth: core's parser and the plugin-view tool
190
+ * both import this rather than re-implementing the recursion.
191
+ */
192
+ export function countNodes(node: ViewNode): number {
193
+ if (node.kind === 'text') return 1;
194
+ return 1 + node.children.reduce((sum, child) => sum + countNodes(child), 0);
195
+ }