@moxxy/plugin-channel-web 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel.d.ts +152 -0
  3. package/dist/channel.d.ts.map +1 -0
  4. package/dist/channel.js +595 -0
  5. package/dist/channel.js.map +1 -0
  6. package/dist/index.d.ts +53 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +171 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/projector.d.ts +17 -0
  11. package/dist/projector.d.ts.map +1 -0
  12. package/dist/projector.js +96 -0
  13. package/dist/projector.js.map +1 -0
  14. package/dist/protocol.d.ts +145 -0
  15. package/dist/protocol.d.ts.map +1 -0
  16. package/dist/protocol.js +33 -0
  17. package/dist/protocol.js.map +1 -0
  18. package/dist/public/app.js +54 -0
  19. package/dist/public/index.html +178 -0
  20. package/dist/tunnel-settings.d.ts +17 -0
  21. package/dist/tunnel-settings.d.ts.map +1 -0
  22. package/dist/tunnel-settings.js +42 -0
  23. package/dist/tunnel-settings.js.map +1 -0
  24. package/package.json +71 -0
  25. package/src/channel.test.ts +514 -0
  26. package/src/channel.ts +669 -0
  27. package/src/discovery.test.ts +40 -0
  28. package/src/frontend/chat.test.ts +47 -0
  29. package/src/frontend/chat.tsx +138 -0
  30. package/src/frontend/index.html +178 -0
  31. package/src/frontend/main.tsx +87 -0
  32. package/src/frontend/render-diff.test.ts +86 -0
  33. package/src/frontend/render-diff.tsx +66 -0
  34. package/src/frontend/render.test.ts +166 -0
  35. package/src/frontend/render.tsx +274 -0
  36. package/src/frontend/socket.ts +212 -0
  37. package/src/frontend/url-safety.test.ts +0 -0
  38. package/src/frontend/url-safety.ts +33 -0
  39. package/src/frontend/view-store.test.ts +104 -0
  40. package/src/frontend/view-store.ts +120 -0
  41. package/src/index.ts +212 -0
  42. package/src/projector.test.ts +172 -0
  43. package/src/projector.ts +95 -0
  44. package/src/protocol.test.ts +59 -0
  45. package/src/protocol.ts +105 -0
  46. package/src/tunnel-settings.test.ts +64 -0
  47. package/src/tunnel-settings.ts +57 -0
  48. package/src/tunnel-tools.test.ts +120 -0
@@ -0,0 +1,95 @@
1
+ import { isFileDiffDisplay, type MoxxyEvent, type ViewDoc } from '@moxxy/sdk';
2
+ import type { ServerFrame } from './protocol.js';
3
+
4
+ /**
5
+ * Cap on in-flight present_view calls awaiting their tool_result. A normal turn
6
+ * has at most a handful; entries only linger when a turn is aborted/errors before
7
+ * the tool resolves (no matching tool_result ever arrives). Bound it so an
8
+ * accumulation of aborts over a very long session can't leak unboundedly.
9
+ */
10
+ const MAX_PENDING_CALLS = 64;
11
+
12
+ /**
13
+ * Folds the session's event stream into {@link ServerFrame}s for the browser.
14
+ * Stateful: it correlates a `present_view` `tool_call_requested` with its
15
+ * `tool_result` (to read the validated AST) and tracks the last view id so each
16
+ * new view `replaces` the prior one. One projector per channel (single-user
17
+ * surface); it renders ANY turn on the shared log, including ones the surface
18
+ * didn't initiate — that is what gives mirror-to-both for free.
19
+ */
20
+ export class EventProjector {
21
+ private readonly presentCalls = new Map<string, { fallbackText?: string }>();
22
+ private lastViewId: string | null = null;
23
+ private viewSeq = 0;
24
+
25
+ project(event: MoxxyEvent): ServerFrame[] {
26
+ switch (event.type) {
27
+ case 'user_prompt': {
28
+ // Hide the synthesized ui-action turns; show real user prompts.
29
+ if (!event.text.trim() || event.text.startsWith('[ui-action]')) return [];
30
+ return [{ kind: 'message', turnId: String(event.turnId), role: 'user', text: event.text }];
31
+ }
32
+ case 'tool_call_requested': {
33
+ if (event.name === 'present_view') {
34
+ const input = event.input as { fallbackText?: string } | undefined;
35
+ this.presentCalls.set(String(event.callId), { fallbackText: input?.fallbackText });
36
+ // Evict the oldest pending call(s) if a long run of aborted/errored
37
+ // turns left their tool_result-less entries stranded.
38
+ while (this.presentCalls.size > MAX_PENDING_CALLS) {
39
+ const oldest = this.presentCalls.keys().next().value;
40
+ if (oldest === undefined) break;
41
+ this.presentCalls.delete(oldest);
42
+ }
43
+ return [];
44
+ }
45
+ return [{ kind: 'status', turnId: String(event.turnId), phase: 'tool', text: `${event.name}…` }];
46
+ }
47
+ case 'tool_result': {
48
+ // Rich tool results (Write/Edit) carry a structured `display`; render
49
+ // the diff in the chat stream, independent of present_view correlation.
50
+ const display = (event.output as { display?: unknown } | undefined)?.display;
51
+ if (event.ok && isFileDiffDisplay(display)) {
52
+ return [{ kind: 'file-diff', turnId: String(event.turnId), display }];
53
+ }
54
+ const pending = this.presentCalls.get(String(event.callId));
55
+ if (!pending) return [];
56
+ this.presentCalls.delete(String(event.callId));
57
+ if (!event.ok) {
58
+ return [{ kind: 'status', turnId: String(event.turnId), phase: 'error', text: 'view failed to render' }];
59
+ }
60
+ const doc = (event.output as { ast?: ViewDoc } | undefined)?.ast;
61
+ if (!doc) return [];
62
+ const viewId = `v_${String(event.turnId)}_${++this.viewSeq}`;
63
+ const replaces = this.lastViewId;
64
+ this.lastViewId = viewId;
65
+ const name = doc.root.kind === 'element' && typeof doc.root.props.name === 'string' ? doc.root.props.name : undefined;
66
+ return [
67
+ {
68
+ kind: 'view',
69
+ viewId,
70
+ turnId: String(event.turnId),
71
+ replaces,
72
+ ...(name ? { name } : {}),
73
+ doc,
74
+ ...(pending.fallbackText ? { fallbackText: pending.fallbackText } : {}),
75
+ },
76
+ ];
77
+ }
78
+ case 'assistant_message': {
79
+ const frames: ServerFrame[] = [];
80
+ if (event.content.trim()) {
81
+ frames.push({ kind: 'message', turnId: String(event.turnId), role: 'assistant', text: event.content });
82
+ }
83
+ if (event.stopReason !== 'tool_use') {
84
+ frames.push({ kind: 'status', turnId: String(event.turnId), phase: 'done', text: '' });
85
+ }
86
+ return frames;
87
+ }
88
+ case 'error': {
89
+ return [{ kind: 'status', turnId: String(event.turnId), phase: 'error', text: event.message }];
90
+ }
91
+ default:
92
+ return [];
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { actionPrompt, clientFrameSchema } from './protocol.js';
3
+
4
+ describe('clientFrameSchema', () => {
5
+ it('accepts a well-formed prompt frame', () => {
6
+ const r = clientFrameSchema.safeParse({ kind: 'prompt', text: 'hi' });
7
+ expect(r.success).toBe(true);
8
+ });
9
+
10
+ it('accepts a well-formed action frame (with and without params)', () => {
11
+ const base = { kind: 'action', actionId: 'a1', viewId: null, formValues: { from: 'SFO' } };
12
+ expect(clientFrameSchema.safeParse({ ...base, action: { name: 'go' } }).success).toBe(true);
13
+ expect(
14
+ clientFrameSchema.safeParse({ ...base, viewId: 'v1', action: { name: 'go', params: { id: 1 } } })
15
+ .success,
16
+ ).toBe(true);
17
+ });
18
+
19
+ it.each([
20
+ ['prompt without text (the crasher)', { kind: 'prompt' }],
21
+ ['prompt with non-string text', { kind: 'prompt', text: 42 }],
22
+ ['action without action', { kind: 'action', actionId: 'a', viewId: null, formValues: {} }],
23
+ ['action without actionId', { kind: 'action', viewId: null, action: { name: 'x' }, formValues: {} }],
24
+ ['action with non-string formValues', { kind: 'action', actionId: 'a', viewId: null, action: { name: 'x' }, formValues: { a: 1 } }],
25
+ ['action with non-string action.name', { kind: 'action', actionId: 'a', viewId: null, action: { name: 7 }, formValues: {} }],
26
+ ['unknown kind', { kind: 'nonsense' }],
27
+ ['missing kind', {}],
28
+ ['non-object', 'hello'],
29
+ ['null', null],
30
+ ])('rejects %s', (_label, frame) => {
31
+ expect(clientFrameSchema.safeParse(frame).success).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe('actionPrompt', () => {
36
+ it('embeds the action + values as a fenced [ui-action] block', () => {
37
+ const p = actionPrompt({ name: 'search_flights' }, { from: 'SFO', to: 'JFK' });
38
+ expect(p).toContain('[ui-action]');
39
+ expect(p).toContain('```json');
40
+ const json = p.slice(p.indexOf('{'), p.lastIndexOf('}') + 1);
41
+ const parsed = JSON.parse(json) as { action: string; values: Record<string, string> };
42
+ expect(parsed.action).toBe('search_flights');
43
+ expect(parsed.values).toEqual({ from: 'SFO', to: 'JFK' });
44
+ });
45
+
46
+ it('includes params when present and is round-trippable JSON', () => {
47
+ const p = actionPrompt({ name: 'select', params: { id: 'UA42' } }, {});
48
+ const json = p.slice(p.indexOf('{'), p.lastIndexOf('}') + 1);
49
+ const parsed = JSON.parse(json) as { action: string; params: Record<string, unknown>; values: unknown };
50
+ expect(parsed.params).toEqual({ id: 'UA42' });
51
+ expect(parsed.values).toEqual({});
52
+ });
53
+
54
+ it('omits params key when absent', () => {
55
+ const p = actionPrompt({ name: 'x' }, { a: '1' });
56
+ const json = p.slice(p.indexOf('{'), p.lastIndexOf('}') + 1);
57
+ expect(JSON.parse(json)).not.toHaveProperty('params');
58
+ });
59
+ });
@@ -0,0 +1,105 @@
1
+ import { z } from 'zod';
2
+ import type { FileDiffDisplay, ViewDoc } from '@moxxy/sdk';
3
+
4
+ /**
5
+ * Wire protocol between the web surface server and the browser. Plain JSON over
6
+ * a WebSocket. Shared by the channel (Node) and the frontend (browser); keep it
7
+ * DOM-free so esbuild can bundle it for the browser. (The frontend imports
8
+ * ONLY types from this module, so the zod runtime never reaches the browser
9
+ * bundle.)
10
+ */
11
+
12
+ /** Server → browser. */
13
+ export type ServerFrame =
14
+ | { readonly kind: 'hello'; readonly sessionId?: string }
15
+ /**
16
+ * A new view to render.
17
+ *
18
+ * INVARIANT: the in-place-vs-push decision is driven SOLELY by the logical
19
+ * `name` (a re-render of the screen already on top updates in place; a
20
+ * different name pushes a history entry — see `applyView`). `replaces`
21
+ * carries the prior `viewId` purely as provenance/diagnostic context (the
22
+ * single-user projector emits the id of the view this one supersedes); it
23
+ * is NOT a navigation directive and the frontend reducer does not branch on
24
+ * it. Do not reintroduce `replaces`-based in-place logic without reconciling
25
+ * it with the name-keyed history/back model in view-store.ts.
26
+ */
27
+ | {
28
+ readonly kind: 'view';
29
+ readonly viewId: string;
30
+ readonly turnId: string;
31
+ readonly replaces: string | null;
32
+ /** Logical screen name (from `<view name>`), for client-side navigation/caching. */
33
+ readonly name?: string;
34
+ readonly doc: ViewDoc;
35
+ readonly fallbackText?: string;
36
+ }
37
+ /** Lightweight turn status (thinking / running a tool / done / error). */
38
+ | {
39
+ readonly kind: 'status';
40
+ readonly turnId: string;
41
+ readonly phase: 'thinking' | 'tool' | 'done' | 'error';
42
+ readonly text: string;
43
+ }
44
+ /** Assistant or user prose, mirrored into the transcript. */
45
+ | { readonly kind: 'message'; readonly turnId: string; readonly role: 'assistant' | 'user'; readonly text: string }
46
+ /** A structured file diff (from a Write/Edit tool result), rendered in the chat stream. */
47
+ | { readonly kind: 'file-diff'; readonly turnId: string; readonly display: FileDiffDisplay }
48
+ /** Acknowledge an inbound action. */
49
+ | { readonly kind: 'ack'; readonly actionId: string; readonly accepted: boolean; readonly reason?: string };
50
+
51
+ /** Browser → server. */
52
+ export type ClientFrame =
53
+ /** Free-text prompt typed into the surface's input box. */
54
+ | { readonly kind: 'prompt'; readonly text: string }
55
+ /** A form submission / button click from a rendered view. */
56
+ | {
57
+ readonly kind: 'action';
58
+ readonly actionId: string;
59
+ readonly viewId: string | null;
60
+ readonly action: { readonly name: string; readonly params?: Record<string, unknown> };
61
+ readonly formValues: Record<string, string>;
62
+ };
63
+
64
+ /**
65
+ * Runtime validator for browser → server frames. The WS endpoint is
66
+ * internet-exposed via tunnels, so every inbound frame MUST be validated
67
+ * before any field access — a cast after JSON.parse let `{"kind":"prompt"}`
68
+ * throw inside the ws 'message' listener and take the whole process down.
69
+ * Mirrors {@link ClientFrame}; the drift guard below keeps them in sync.
70
+ */
71
+ export const clientFrameSchema = z.discriminatedUnion('kind', [
72
+ z.object({ kind: z.literal('prompt'), text: z.string() }),
73
+ z.object({
74
+ kind: z.literal('action'),
75
+ actionId: z.string(),
76
+ viewId: z.string().nullable(),
77
+ action: z.object({ name: z.string(), params: z.record(z.unknown()).optional() }),
78
+ formValues: z.record(z.string()),
79
+ }),
80
+ ]);
81
+
82
+ /** Compile-time drift guard: schema output and ClientFrame must stay mutually assignable. */
83
+ type AssertTrue<T extends true> = T;
84
+ export type _ClientFrameSchemaInSync = AssertTrue<
85
+ z.infer<typeof clientFrameSchema> extends ClientFrame
86
+ ? ClientFrame extends z.infer<typeof clientFrameSchema>
87
+ ? true
88
+ : false
89
+ : false
90
+ >;
91
+
92
+ /**
93
+ * Synthesize the user-turn prompt for a view action. The agent is taught (via
94
+ * the build-view skill) to read `[ui-action]` blocks and respond by calling
95
+ * present_view again. Kept here so the channel and any test share one format.
96
+ */
97
+ export function actionPrompt(action: { name: string; params?: Record<string, unknown> }, formValues: Record<string, string>): string {
98
+ const payload = JSON.stringify({ action: action.name, ...(action.params ? { params: action.params } : {}), values: formValues });
99
+ return [
100
+ '[ui-action] The user interacted with the presented view.',
101
+ '```json',
102
+ payload,
103
+ '```',
104
+ ].join('\n');
105
+ }
@@ -0,0 +1,64 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { normalizeTunnelName, readTunnelSetting, readWebSettings, writeTunnelSetting } from './tunnel-settings.js';
6
+
7
+ let dir: string;
8
+ let file: string;
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(path.join(os.tmpdir(), 'mox-web-'));
11
+ file = path.join(dir, 'web.json');
12
+ });
13
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
14
+
15
+ describe('tunnel-settings', () => {
16
+ it('normalizes "none"/local aliases to localhost', () => {
17
+ for (const a of ['none', 'None', 'local', 'off', 'loopback', 'LOOPBACK']) {
18
+ expect(normalizeTunnelName(a)).toBe('localhost');
19
+ }
20
+ expect(normalizeTunnelName('Proxy')).toBe('proxy');
21
+ });
22
+
23
+ it('returns undefined / {} when the file is missing', () => {
24
+ expect(readTunnelSetting(file)).toBeUndefined();
25
+ expect(readWebSettings(file)).toEqual({});
26
+ });
27
+
28
+ it('round-trips a written setting (normalized)', async () => {
29
+ await writeTunnelSetting('proxy', file);
30
+ expect(readTunnelSetting(file)).toBe('proxy');
31
+ await writeTunnelSetting('none', file);
32
+ expect(readTunnelSetting(file)).toBe('localhost');
33
+ });
34
+
35
+ it('tolerates a corrupt file', async () => {
36
+ await writeTunnelSetting('proxy', file);
37
+ // overwrite with garbage
38
+ rmSync(file);
39
+ expect(readTunnelSetting(file)).toBeUndefined();
40
+ });
41
+
42
+ // invariant 5: concurrent read-merge-write of web.json must not lose an
43
+ // update. Without the mutex the two writes both read the same snapshot and
44
+ // the second clobbers the first; with it the file ends last-writer-wins and
45
+ // stays well-formed.
46
+ it('serializes concurrent writeTunnelSetting (well-formed, one wins)', async () => {
47
+ await Promise.all([
48
+ writeTunnelSetting('cloudflared', file),
49
+ writeTunnelSetting('ngrok', file),
50
+ ]);
51
+ const survivor = readTunnelSetting(file);
52
+ expect(['cloudflared', 'ngrok']).toContain(survivor);
53
+ // The file must be valid JSON of the expected shape (no torn/partial write).
54
+ expect(readWebSettings(file)).toEqual({ tunnel: survivor });
55
+ });
56
+
57
+ it('survives many overlapping writers with a well-formed result', async () => {
58
+ const names = ['cloudflared', 'ngrok', 'localhost', 'cloudflared', 'ngrok'];
59
+ await Promise.all(names.map((n) => writeTunnelSetting(n, file)));
60
+ const survivor = readTunnelSetting(file);
61
+ expect(names).toContain(survivor);
62
+ expect(readWebSettings(file)).toEqual({ tunnel: survivor });
63
+ });
64
+ });
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createMutex, z } from '@moxxy/sdk';
3
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
4
+
5
+ /** Validates the on-disk web.json shape; a corrupt/foreign file is discarded. */
6
+ const webSettingsSchema = z.object({ tunnel: z.string().optional() });
7
+
8
+ /**
9
+ * Per-instance mutex serializing the read-merge-write of `web.json`. The atomic
10
+ * write prevents torn files, but two concurrent writes would otherwise both
11
+ * read the same snapshot and the second would clobber the first's merge.
12
+ * Mirrors provider-admin/store.ts.
13
+ */
14
+ const writeMutex = createMutex();
15
+
16
+ /**
17
+ * Persisted, user/agent-changeable choice of tunnel provider for the web
18
+ * surface — stored at `~/.moxxy/web.json`. Mirrors the providers.json pattern:
19
+ * the agent's `web_set_tunnel` tool writes it; the web plugin's onInit applies
20
+ * it on boot. Absent file → callers fall back to defaults.
21
+ */
22
+ export interface WebSettings {
23
+ /** Active tunnel provider name (e.g. 'proxy', 'localhost'). */
24
+ readonly tunnel?: string;
25
+ }
26
+
27
+ export function webSettingsPath(): string {
28
+ return moxxyPath('web.json');
29
+ }
30
+
31
+ /** Normalize user-friendly aliases for "no tunnel" to the localhost provider. */
32
+ export function normalizeTunnelName(name: string): string {
33
+ const n = name.trim().toLowerCase();
34
+ if (n === 'none' || n === 'local' || n === 'off' || n === 'loopback') return 'localhost';
35
+ return n;
36
+ }
37
+
38
+ export function readWebSettings(file = webSettingsPath()): WebSettings {
39
+ try {
40
+ const parsed = webSettingsSchema.safeParse(JSON.parse(readFileSync(file, 'utf8')));
41
+ return parsed.success ? parsed.data : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ export function readTunnelSetting(file = webSettingsPath()): string | undefined {
48
+ const t = readWebSettings(file).tunnel;
49
+ return typeof t === 'string' && t ? normalizeTunnelName(t) : undefined;
50
+ }
51
+
52
+ export async function writeTunnelSetting(name: string, file = webSettingsPath()): Promise<void> {
53
+ await writeMutex.run(async () => {
54
+ const next: WebSettings = { ...readWebSettings(file), tunnel: normalizeTunnelName(name) };
55
+ await writeFileAtomic(file, JSON.stringify(next, null, 2));
56
+ });
57
+ }
@@ -0,0 +1,120 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { LifecycleHooks, ToolContext, ToolDef } from '@moxxy/sdk';
6
+ import { buildWebChannelPlugin, readTunnelSetting, writeTunnelSetting, type TunnelControls } from './index.js';
7
+
8
+ const ctx = {} as ToolContext;
9
+
10
+ /** In-memory tunnel registry for the tools to drive. */
11
+ function fakeTunnels(available = new Set(['localhost', 'proxy'])): TunnelControls {
12
+ let active = 'localhost';
13
+ return {
14
+ list: () => ['localhost', 'proxy'],
15
+ active: () => active,
16
+ setActive: (n) => {
17
+ active = n;
18
+ },
19
+ isAvailable: (n) => Promise.resolve(available.has(n)),
20
+ };
21
+ }
22
+
23
+ let dir: string;
24
+ let file: string;
25
+ beforeEach(() => {
26
+ dir = mkdtempSync(path.join(os.tmpdir(), 'mox-tt-'));
27
+ file = path.join(dir, 'web.json');
28
+ });
29
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
30
+
31
+ function build(opts: Parameters<typeof buildWebChannelPlugin>[0]) {
32
+ const plugin = buildWebChannelPlugin(opts);
33
+ const tool = (n: string): ToolDef => {
34
+ const t = plugin.tools?.find((x) => x.name === n);
35
+ if (!t) throw new Error(`missing tool ${n}`);
36
+ return t;
37
+ };
38
+ return { plugin, set: tool('web_set_tunnel'), status: tool('web_tunnel_status') };
39
+ }
40
+
41
+ describe('web_tunnel_status', () => {
42
+ it('reports the active provider and options', () => {
43
+ const { status } = build({ tunnels: fakeTunnels(), settingsFile: file });
44
+ expect(status.handler({}, ctx)).toEqual({ active: 'localhost', available: ['none', 'localhost', 'proxy'] });
45
+ });
46
+ });
47
+
48
+ describe('web_set_tunnel', () => {
49
+ it('switches provider, persists, and reports the url via live retunnel', async () => {
50
+ const tunnels = fakeTunnels();
51
+ let retunnelled = 0;
52
+ const { set } = build({
53
+ tunnels,
54
+ settingsFile: file,
55
+ getControls: () => ({
56
+ retunnel: () => {
57
+ retunnelled++;
58
+ return Promise.resolve('https://abc123.proxy.moxxy.ai/web');
59
+ },
60
+ }),
61
+ });
62
+ const r = (await set.handler({ provider: 'proxy' }, ctx)) as { ok: boolean; active: string; url?: string };
63
+ expect(r.ok).toBe(true);
64
+ expect(r.active).toBe('proxy');
65
+ expect(r.url).toContain('proxy.moxxy.ai');
66
+ expect(retunnelled).toBe(1);
67
+ expect(tunnels.active()).toBe('proxy');
68
+ expect(readTunnelSetting(file)).toBe('proxy');
69
+ });
70
+
71
+ it('maps "none" to localhost', async () => {
72
+ const tunnels = fakeTunnels();
73
+ const { set } = build({ tunnels, settingsFile: file });
74
+ const r = (await set.handler({ provider: 'none' }, ctx)) as { active: string };
75
+ expect(r.active).toBe('localhost');
76
+ expect(readTunnelSetting(file)).toBe('localhost');
77
+ });
78
+
79
+ it('rejects an unknown provider', async () => {
80
+ const { set } = build({ tunnels: fakeTunnels(), settingsFile: file });
81
+ const r = (await set.handler({ provider: 'wireguard' }, ctx)) as { ok: boolean; error: string };
82
+ expect(r.ok).toBe(false);
83
+ expect(r.error).toMatch(/unknown tunnel/);
84
+ });
85
+
86
+ it('rejects an unavailable provider and does not persist', async () => {
87
+ const tunnels = fakeTunnels(new Set(['localhost'])); // proxy relay unreachable
88
+ const { set } = build({ tunnels, settingsFile: file });
89
+ const r = (await set.handler({ provider: 'proxy' }, ctx)) as { ok: boolean; error?: string };
90
+ expect(r.ok).toBe(false);
91
+ expect(r.error).toMatch(/not available/i);
92
+ expect(readTunnelSetting(file)).toBeUndefined();
93
+ });
94
+ });
95
+
96
+ describe('onInit applies the persisted / default tunnel', () => {
97
+ const fireInit = (hooks: LifecycleHooks | undefined) => (hooks?.onInit as (() => void) | undefined)?.();
98
+
99
+ it('applies a persisted setting on boot', async () => {
100
+ await writeTunnelSetting('proxy', file);
101
+ const tunnels = fakeTunnels();
102
+ const { plugin } = build({ tunnels, settingsFile: file });
103
+ fireInit(plugin.hooks);
104
+ expect(tunnels.active()).toBe('proxy');
105
+ });
106
+
107
+ it('falls back to the configured default when nothing is persisted', () => {
108
+ const tunnels = fakeTunnels();
109
+ const { plugin } = build({ tunnels, settingsFile: file, defaultTunnel: 'proxy' });
110
+ fireInit(plugin.hooks);
111
+ expect(tunnels.active()).toBe('proxy');
112
+ });
113
+
114
+ it('keeps the seeded default when neither is set', () => {
115
+ const tunnels = fakeTunnels();
116
+ const { plugin } = build({ tunnels, settingsFile: file });
117
+ fireInit(plugin.hooks);
118
+ expect(tunnels.active()).toBe('localhost');
119
+ });
120
+ });