@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
|
@@ -64,10 +64,29 @@ export interface RegistryKind<TDef> {
|
|
|
64
64
|
readonly defs: (plugin: Plugin) => ReadonlyArray<TDef>;
|
|
65
65
|
/** Extract the registry key from a def (mostly `.name`; surfaces use `.kind`). */
|
|
66
66
|
readonly nameOf: (def: TDef) => string;
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Register a single def into its registry (uses `register` or `replace`).
|
|
69
|
+
* `trusted` is true for statically-registered builtins and false for
|
|
70
|
+
* discovered (untrusted) plugins; most kinds ignore it, but the isolator
|
|
71
|
+
* registry uses it to refuse letting a discovered plugin shadow a builtin.
|
|
72
|
+
*
|
|
73
|
+
* Returns `false` when the registration was REFUSED without taking effect
|
|
74
|
+
* (currently only the isolator registry, when an untrusted plugin tries to
|
|
75
|
+
* shadow a trusted name). PluginHost must NOT track a refused registration
|
|
76
|
+
* for rollback — unregistering it on a later mid-`applyPlugin` failure would
|
|
77
|
+
* delete a name this plugin never actually owned. `void`/`true` means applied.
|
|
78
|
+
*/
|
|
79
|
+
readonly register: (opts: PluginHostOptions, def: TDef, trusted: boolean) => void | boolean;
|
|
69
80
|
/** Remove a previously-registered name from its registry. */
|
|
70
81
|
readonly unregister: (opts: PluginHostOptions, name: string) => void;
|
|
82
|
+
/**
|
|
83
|
+
* True when `register` is an override (`replace`) rather than a throw-on-
|
|
84
|
+
* duplicate insert. Such kinds are last-wins and may clobber a name seeded by
|
|
85
|
+
* core or another plugin, so PluginHost must NOT roll them back when a later
|
|
86
|
+
* kind throws mid-`applyPlugin` (unregistering would delete a def this plugin
|
|
87
|
+
* didn't actually own).
|
|
88
|
+
*/
|
|
89
|
+
readonly overrideOnRegister?: boolean;
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
/**
|
|
@@ -124,6 +143,7 @@ export const REGISTRY_KINDS: ReadonlyArray<RegistryKind<unknown>> = [
|
|
|
124
143
|
// viewRenderers use `replace` (override-allowed) on the register side.
|
|
125
144
|
register: (o, v: ViewRendererDef) => o.viewRenderers.replace(v),
|
|
126
145
|
unregister: (o, n) => o.viewRenderers.unregister(n),
|
|
146
|
+
overrideOnRegister: true,
|
|
127
147
|
} satisfies RegistryKind<ViewRendererDef>,
|
|
128
148
|
{
|
|
129
149
|
kind: 'tunnelProvider',
|
|
@@ -133,6 +153,7 @@ export const REGISTRY_KINDS: ReadonlyArray<RegistryKind<unknown>> = [
|
|
|
133
153
|
// tunnelProviders use `replace` (override-allowed) on the register side.
|
|
134
154
|
register: (o, t: TunnelProviderDef) => o.tunnelProviders.replace(t),
|
|
135
155
|
unregister: (o, n) => o.tunnelProviders.unregister(n),
|
|
156
|
+
overrideOnRegister: true,
|
|
136
157
|
} satisfies RegistryKind<TunnelProviderDef>,
|
|
137
158
|
{
|
|
138
159
|
kind: 'channel',
|
|
@@ -196,7 +217,8 @@ export const REGISTRY_KINDS: ReadonlyArray<RegistryKind<unknown>> = [
|
|
|
196
217
|
recordField: 'isolatorNames',
|
|
197
218
|
defs: (p) => p.isolators ?? [],
|
|
198
219
|
nameOf: (i: Isolator) => i.name,
|
|
199
|
-
register: (o, i: Isolator) =>
|
|
220
|
+
register: (o, i: Isolator, trusted): boolean =>
|
|
221
|
+
o.isolators.register(i, { trusted, logger: o.logger }),
|
|
200
222
|
unregister: (o, n) => o.isolators.unregister(n),
|
|
201
223
|
} satisfies RegistryKind<Isolator>,
|
|
202
224
|
{
|
package/src/plugins/toposort.ts
CHANGED
|
@@ -33,27 +33,55 @@ export function toposortPluginManifests(
|
|
|
33
33
|
const order: ResolvedPluginManifest[] = [];
|
|
34
34
|
const visited = new Set<string>();
|
|
35
35
|
const onStack = new Set<string>();
|
|
36
|
-
|
|
36
|
+
// Path of names from the current root, mirroring the old recursion stack so a
|
|
37
|
+
// detected cycle reports the same participating names.
|
|
38
|
+
const path: string[] = [];
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
40
|
+
// Explicit-stack iterative DFS (instead of recursion) so a pathological deep
|
|
41
|
+
// linear dependency chain — depth is plugin-author/package-controlled via
|
|
42
|
+
// package.json#moxxy.requirements — can't overflow the call stack and turn a
|
|
43
|
+
// load-time ordering concern into a process crash.
|
|
44
|
+
type Frame = { name: string; deps: ReadonlyArray<string>; next: number };
|
|
45
|
+
|
|
46
|
+
const visitRoot = (root: string): void => {
|
|
47
|
+
if (visited.has(root)) return;
|
|
48
|
+
const rootManifest = byPackage.get(root);
|
|
49
|
+
if (!rootManifest) return; // unknown dep — leave for readiness gate
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
stack.
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
const stack: Frame[] = [{ name: root, deps: pluginDeps(rootManifest.requirements), next: 0 }];
|
|
52
|
+
onStack.add(root);
|
|
53
|
+
path.push(root);
|
|
54
|
+
|
|
55
|
+
while (stack.length > 0) {
|
|
56
|
+
const frame = stack[stack.length - 1];
|
|
57
|
+
if (!frame) break;
|
|
58
|
+
if (frame.next < frame.deps.length) {
|
|
59
|
+
const dep = frame.deps[frame.next++];
|
|
60
|
+
if (dep === undefined) continue;
|
|
61
|
+
if (visited.has(dep)) continue;
|
|
62
|
+
if (onStack.has(dep)) {
|
|
63
|
+
const startIdx = path.indexOf(dep);
|
|
64
|
+
throw new PluginCycleError(path.slice(startIdx).concat(dep));
|
|
65
|
+
}
|
|
66
|
+
const depManifest = byPackage.get(dep);
|
|
67
|
+
if (!depManifest) continue; // unknown dep — leave for readiness gate
|
|
68
|
+
onStack.add(dep);
|
|
69
|
+
path.push(dep);
|
|
70
|
+
stack.push({ name: dep, deps: pluginDeps(depManifest.requirements), next: 0 });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// All deps emitted → post-order emit this frame, same as the recursive
|
|
74
|
+
// version pushed after its dependency loop.
|
|
75
|
+
const done = stack.pop()!;
|
|
76
|
+
onStack.delete(done.name);
|
|
77
|
+
path.pop();
|
|
78
|
+
visited.add(done.name);
|
|
79
|
+
const manifest = byPackage.get(done.name);
|
|
80
|
+
if (manifest) order.push(manifest);
|
|
81
|
+
}
|
|
54
82
|
};
|
|
55
83
|
|
|
56
|
-
for (const m of manifests)
|
|
84
|
+
for (const m of manifests) visitRoot(m.packageName);
|
|
57
85
|
return order;
|
|
58
86
|
}
|
|
59
87
|
|
package/src/preferences.test.ts
CHANGED
|
@@ -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';
|
|
@@ -10,13 +10,18 @@ import { loadPreferences, preferencesPath, savePreferences } from './preferences
|
|
|
10
10
|
let tmpHome: string;
|
|
11
11
|
let savedHome: string | undefined;
|
|
12
12
|
let savedUserProfile: string | undefined;
|
|
13
|
+
let savedMoxxyHome: string | undefined;
|
|
13
14
|
|
|
14
15
|
beforeEach(async () => {
|
|
15
16
|
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-'));
|
|
16
17
|
savedHome = process.env.HOME;
|
|
17
18
|
savedUserProfile = process.env.USERPROFILE;
|
|
19
|
+
savedMoxxyHome = process.env.MOXXY_HOME;
|
|
18
20
|
process.env.HOME = tmpHome;
|
|
19
21
|
process.env.USERPROFILE = tmpHome;
|
|
22
|
+
// The default-home tests below assert the `~/.moxxy` fallback, so MOXXY_HOME
|
|
23
|
+
// must be unset for them (it would otherwise win).
|
|
24
|
+
delete process.env.MOXXY_HOME;
|
|
20
25
|
await fs.mkdir(path.join(tmpHome, '.moxxy'), { recursive: true });
|
|
21
26
|
});
|
|
22
27
|
|
|
@@ -25,6 +30,8 @@ afterEach(async () => {
|
|
|
25
30
|
else process.env.HOME = savedHome;
|
|
26
31
|
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
27
32
|
else process.env.USERPROFILE = savedUserProfile;
|
|
33
|
+
if (savedMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
34
|
+
else process.env.MOXXY_HOME = savedMoxxyHome;
|
|
28
35
|
await fs.rm(tmpHome, { recursive: true, force: true });
|
|
29
36
|
});
|
|
30
37
|
|
|
@@ -36,6 +43,28 @@ describe('preferences store', () => {
|
|
|
36
43
|
expect(preferencesPath()).toBe(path.join(tmpHome, '.moxxy', 'preferences.json'));
|
|
37
44
|
});
|
|
38
45
|
|
|
46
|
+
it('honors $MOXXY_HOME so preferences follow the relocated data dir', async () => {
|
|
47
|
+
const altHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-alt-'));
|
|
48
|
+
const prevMoxxyHome = process.env.MOXXY_HOME;
|
|
49
|
+
process.env.MOXXY_HOME = altHome;
|
|
50
|
+
try {
|
|
51
|
+
// Path now lives directly under MOXXY_HOME, NOT under ~/.moxxy.
|
|
52
|
+
expect(preferencesPath()).toBe(path.join(altHome, 'preferences.json'));
|
|
53
|
+
await savePreferences({ model: 'relocated' });
|
|
54
|
+
// Written to the relocated dir, not the homedir fallback.
|
|
55
|
+
const raw = await fs.readFile(path.join(altHome, 'preferences.json'), 'utf8');
|
|
56
|
+
expect(JSON.parse(raw).model).toBe('relocated');
|
|
57
|
+
// The homedir fallback location must remain empty.
|
|
58
|
+
await expect(
|
|
59
|
+
fs.readFile(path.join(tmpHome, '.moxxy', 'preferences.json'), 'utf8'),
|
|
60
|
+
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
61
|
+
} finally {
|
|
62
|
+
if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
63
|
+
else process.env.MOXXY_HOME = prevMoxxyHome;
|
|
64
|
+
await fs.rm(altHome, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
39
68
|
it('returns an empty object when the file is missing', async () => {
|
|
40
69
|
expect(await loadPreferences()).toEqual({});
|
|
41
70
|
});
|
|
@@ -90,6 +119,28 @@ describe('preferences store', () => {
|
|
|
90
119
|
expect(loaded.mode).toBe('goal');
|
|
91
120
|
});
|
|
92
121
|
|
|
122
|
+
it('swallows a write failure: logs to stderr but never throws (best-effort contract)', async () => {
|
|
123
|
+
// savePreferences is documented best-effort — a persist failure must NOT
|
|
124
|
+
// bubble out and break the slash-command / shutdown that triggered it. Force
|
|
125
|
+
// an unwritable target by pointing MOXXY_HOME *under a regular file*, so the
|
|
126
|
+
// atomic writer's `mkdir(dirname)` fails with ENOTDIR.
|
|
127
|
+
const blocker = path.join(tmpHome, 'not-a-dir');
|
|
128
|
+
await fs.writeFile(blocker, 'x', 'utf8');
|
|
129
|
+
const prevMoxxyHome = process.env.MOXXY_HOME;
|
|
130
|
+
process.env.MOXXY_HOME = path.join(blocker, 'nested'); // dirname is a file → mkdir fails
|
|
131
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
132
|
+
try {
|
|
133
|
+
// Must resolve, not reject — the pick still took effect in-session.
|
|
134
|
+
await expect(savePreferences({ model: 'm' })).resolves.toBeUndefined();
|
|
135
|
+
// The failure is surfaced (not silently swallowed) on stderr.
|
|
136
|
+
expect(stderr).toHaveBeenCalled();
|
|
137
|
+
} finally {
|
|
138
|
+
stderr.mockRestore();
|
|
139
|
+
if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
140
|
+
else process.env.MOXXY_HOME = prevMoxxyHome;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
93
144
|
it('keeps ALL distinct keys present under many overlapping writers', async () => {
|
|
94
145
|
const patches: Array<Partial<Record<`k${number}`, string>>> = [];
|
|
95
146
|
for (let i = 0; i < 25; i++) patches.push({ [`k${i}`]: `v${i}` });
|
package/src/preferences.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
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 { createMutex, migrateModeName } from '@moxxy/sdk';
|
|
5
|
-
import { writeFileAtomic } from '@moxxy/sdk/server';
|
|
3
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
6
|
* User-level runtime preferences persisted at ~/.moxxy/preferences.json.
|
|
@@ -28,7 +26,10 @@ export interface MoxxyPreferences {
|
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
export function preferencesPath(): string {
|
|
31
|
-
|
|
29
|
+
// Route through `moxxyPath` so a `$MOXXY_HOME` override relocates preferences
|
|
30
|
+
// alongside the rest of the data dir (config.yaml, vault, providers.json, …).
|
|
31
|
+
// Identical to `~/.moxxy/preferences.json` when MOXXY_HOME is unset.
|
|
32
|
+
return moxxyPath('preferences.json');
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -55,7 +55,16 @@ export class ActiveBackendRegistry<TDef extends { name: string }, TInstance> {
|
|
|
55
55
|
replace(def: TDef, instance?: TInstance): void {
|
|
56
56
|
this.defs.set(def.name, def);
|
|
57
57
|
this.instances.delete(def.name);
|
|
58
|
-
if (instance)
|
|
58
|
+
if (instance) {
|
|
59
|
+
this.instances.set(def.name, instance);
|
|
60
|
+
} else if (this.active === def.name && !this.opts.buildOnRead) {
|
|
61
|
+
// For a non-buildOnRead registry, getActive() reads the cached instance
|
|
62
|
+
// directly and throws when it's missing. Dropping the active def's cache
|
|
63
|
+
// (e.g. a hot-reloaded plugin re-registering via replace) would otherwise
|
|
64
|
+
// strand getActive() until the next setActive. buildOnRead registries
|
|
65
|
+
// self-heal on read, so they don't need this.
|
|
66
|
+
this.instances.set(def.name, this.buildInstance(def.name));
|
|
67
|
+
}
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
unregister(name: string): void {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ChannelDef, ChannelFactoryDeps } from '@moxxy/sdk';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { ChannelRegistryImpl } from './channels.js';
|
|
4
|
+
|
|
5
|
+
const deps = {} as ChannelFactoryDeps;
|
|
6
|
+
|
|
7
|
+
function fakeChannel(
|
|
8
|
+
name: string,
|
|
9
|
+
isAvailable?: ChannelDef['isAvailable'],
|
|
10
|
+
): ChannelDef {
|
|
11
|
+
return {
|
|
12
|
+
name,
|
|
13
|
+
description: `channel ${name}`,
|
|
14
|
+
create: () => ({}) as never,
|
|
15
|
+
...(isAvailable ? { isAvailable } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ChannelRegistryImpl.listWithAvailability', () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('treats a channel without isAvailable as ok and maps a thrown probe to not-ok', async () => {
|
|
25
|
+
const reg = new ChannelRegistryImpl();
|
|
26
|
+
reg.register(fakeChannel('always'));
|
|
27
|
+
reg.register(
|
|
28
|
+
fakeChannel('throws', async () => {
|
|
29
|
+
throw new Error('boom');
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
const out = await reg.listWithAvailability(deps);
|
|
33
|
+
const byName = Object.fromEntries(out.map((o) => [o.def.name, o.availability]));
|
|
34
|
+
expect(byName.always).toEqual({ ok: true });
|
|
35
|
+
expect(byName.throws).toEqual({ ok: false, reason: 'boom' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not let one hung probe block the others or the listing (bounded timeout)', async () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
const reg = new ChannelRegistryImpl();
|
|
41
|
+
// This probe never resolves — without a timeout it would wedge the listing.
|
|
42
|
+
reg.register(fakeChannel('hung', () => new Promise<never>(() => {})));
|
|
43
|
+
reg.register(fakeChannel('fast', async () => ({ ok: true })));
|
|
44
|
+
|
|
45
|
+
const promise = reg.listWithAvailability(deps);
|
|
46
|
+
// Advance past the probe timeout so the hung probe resolves to not-ok.
|
|
47
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
48
|
+
const out = await promise;
|
|
49
|
+
const byName = Object.fromEntries(out.map((o) => [o.def.name, o.availability]));
|
|
50
|
+
expect(byName.fast).toEqual({ ok: true });
|
|
51
|
+
expect(byName.hung.ok).toBe(false);
|
|
52
|
+
expect(byName.hung.reason).toMatch(/timed out/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('runs probes in parallel — a slow probe does not starve a later fast one', async () => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
const reg = new ChannelRegistryImpl();
|
|
58
|
+
let fastRan = false;
|
|
59
|
+
reg.register(
|
|
60
|
+
fakeChannel(
|
|
61
|
+
'slow',
|
|
62
|
+
() => new Promise<{ ok: true }>((resolve) => setTimeout(() => resolve({ ok: true }), 2_000)),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
reg.register(
|
|
66
|
+
fakeChannel('fast', async () => {
|
|
67
|
+
fastRan = true;
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
const promise = reg.listWithAvailability(deps);
|
|
72
|
+
// The fast probe should already have started (parallel), before the slow
|
|
73
|
+
// one's timer is even advanced.
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
expect(fastRan).toBe(true);
|
|
76
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
77
|
+
await promise;
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -6,11 +6,17 @@ import type {
|
|
|
6
6
|
} from '@moxxy/sdk';
|
|
7
7
|
import { DefMapRegistry } from './def-map-registry.js';
|
|
8
8
|
|
|
9
|
+
/** Upper bound on a single channel availability probe. `isAvailable` hooks can
|
|
10
|
+
* do network/process probes (tunnel reachability, binary presence); a hung one
|
|
11
|
+
* (dead socket, no DNS) must not block the whole listing — which typically
|
|
12
|
+
* backs a UI picker — indefinitely. */
|
|
13
|
+
const AVAILABILITY_PROBE_TIMEOUT_MS = 4_000;
|
|
14
|
+
|
|
9
15
|
/**
|
|
10
16
|
* Flat name→def registry of channels ({@link DefMapRegistry}) plus
|
|
11
17
|
* `listWithAvailability`, which pairs each channel with its current
|
|
12
18
|
* availability (treating channels without an `isAvailable` hook as
|
|
13
|
-
* `{ ok: true }` and mapping a thrown probe to `{ ok: false }`).
|
|
19
|
+
* `{ ok: true }` and mapping a thrown OR timed-out probe to `{ ok: false }`).
|
|
14
20
|
*/
|
|
15
21
|
export class ChannelRegistryImpl extends DefMapRegistry<ChannelDef> implements ChannelRegistry {
|
|
16
22
|
constructor() {
|
|
@@ -20,23 +26,32 @@ export class ChannelRegistryImpl extends DefMapRegistry<ChannelDef> implements C
|
|
|
20
26
|
async listWithAvailability(
|
|
21
27
|
deps: ChannelFactoryDeps,
|
|
22
28
|
): Promise<ReadonlyArray<{ def: ChannelDef; availability: ChannelAvailability }>> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
// Probe in parallel — a serial loop meant one slow probe also starved every
|
|
30
|
+
// fast probe queued behind it — and bound each probe with a timeout so a
|
|
31
|
+
// single hung channel can't wedge the whole listing.
|
|
32
|
+
return Promise.all(
|
|
33
|
+
[...this.defs.values()].map(async (def) => ({
|
|
34
|
+
def,
|
|
35
|
+
availability: await this.probe(def, deps),
|
|
36
|
+
})),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async probe(def: ChannelDef, deps: ChannelFactoryDeps): Promise<ChannelAvailability> {
|
|
41
|
+
if (!def.isAvailable) return { ok: true };
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
try {
|
|
44
|
+
const timeout = new Promise<ChannelAvailability>((resolve) => {
|
|
45
|
+
timer = setTimeout(
|
|
46
|
+
() => resolve({ ok: false, reason: 'availability check timed out' }),
|
|
47
|
+
AVAILABILITY_PROBE_TIMEOUT_MS,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
return await Promise.race([Promise.resolve(def.isAvailable(deps)), timeout]);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
53
|
+
} finally {
|
|
54
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
39
55
|
}
|
|
40
|
-
return out;
|
|
41
56
|
}
|
|
42
57
|
}
|
|
@@ -58,6 +58,71 @@ describe('CommandRegistry', () => {
|
|
|
58
58
|
expect(reg.get('m')).toBeUndefined();
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it('replace() refuses to hijack an alias owned by a DIFFERENT command', () => {
|
|
62
|
+
const reg = new CommandRegistry();
|
|
63
|
+
reg.register(fakeCommand('compact', { aliases: ['c'] }));
|
|
64
|
+
reg.register(fakeCommand('clear'));
|
|
65
|
+
// Replacing /clear with an alias 'c' already owned by /compact must throw,
|
|
66
|
+
// not silently steal the alias (register() guards this; replace() now too).
|
|
67
|
+
expect(() => reg.replace(fakeCommand('clear', { aliases: ['c'] }))).toThrow(
|
|
68
|
+
/alias already in use/,
|
|
69
|
+
);
|
|
70
|
+
// /compact still owns 'c'.
|
|
71
|
+
expect(reg.get('c')?.name).toBe('compact');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('replace() refuses an alias that collides with another command primary name', () => {
|
|
75
|
+
const reg = new CommandRegistry();
|
|
76
|
+
reg.register(fakeCommand('compact'));
|
|
77
|
+
reg.register(fakeCommand('mode', { aliases: ['m'] }));
|
|
78
|
+
// Re-defining /mode with an alias equal to another command's PRIMARY name
|
|
79
|
+
// would make get() ambiguous — reject it.
|
|
80
|
+
expect(() => reg.replace(fakeCommand('mode', { aliases: ['compact'] }))).toThrow(
|
|
81
|
+
/already in use/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('replace() still allows re-adding the command own primary name as an alias edge case is fine', () => {
|
|
86
|
+
const reg = new CommandRegistry();
|
|
87
|
+
reg.register(fakeCommand('mode', { aliases: ['m'] }));
|
|
88
|
+
// Replacing with the SAME alias set must not throw on the alias it already
|
|
89
|
+
// owns (cleared-then-readded).
|
|
90
|
+
expect(() => reg.replace(fakeCommand('mode', { aliases: ['m'], description: 'x' }))).not.toThrow();
|
|
91
|
+
expect(reg.get('m')?.name).toBe('mode');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('register() leaves NO partial state when a later alias collides', () => {
|
|
95
|
+
const reg = new CommandRegistry();
|
|
96
|
+
reg.register(fakeCommand('compact', { aliases: ['cc'] }));
|
|
97
|
+
// The new command's FIRST alias is free, its SECOND ('cc') collides. The
|
|
98
|
+
// registration must throw atomically: neither the command nor its first
|
|
99
|
+
// alias may be left behind.
|
|
100
|
+
expect(() =>
|
|
101
|
+
reg.register(fakeCommand('clear', { aliases: ['fresh', 'cc'] })),
|
|
102
|
+
).toThrow(/alias already in use/);
|
|
103
|
+
expect(reg.has('clear')).toBe(false);
|
|
104
|
+
expect(reg.get('fresh')).toBeUndefined();
|
|
105
|
+
// The first command is untouched.
|
|
106
|
+
expect(reg.get('cc')?.name).toBe('compact');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('replace() leaves the PRIOR definition intact when a new alias collides', () => {
|
|
110
|
+
const reg = new CommandRegistry();
|
|
111
|
+
reg.register(fakeCommand('compact', { aliases: ['c'] }));
|
|
112
|
+
reg.register(fakeCommand('mode', { aliases: ['m'] }));
|
|
113
|
+
// Replacing /mode where the SECOND new alias ('c') is owned by /compact must
|
|
114
|
+
// throw without first destroying /mode's existing alias 'm' (the prior bug:
|
|
115
|
+
// delete-prior-then-add left the registry corrupted on a mid-loop throw).
|
|
116
|
+
expect(() =>
|
|
117
|
+
reg.replace(fakeCommand('mode', { aliases: ['m2', 'c'] })),
|
|
118
|
+
).toThrow(/alias already in use/);
|
|
119
|
+
// /mode's original def + alias survive untouched.
|
|
120
|
+
expect(reg.get('m')?.name).toBe('mode');
|
|
121
|
+
expect(reg.get('m2')).toBeUndefined();
|
|
122
|
+
// /compact still owns 'c'.
|
|
123
|
+
expect(reg.get('c')?.name).toBe('compact');
|
|
124
|
+
});
|
|
125
|
+
|
|
61
126
|
it('listForChannel filters channel-scoped commands', () => {
|
|
62
127
|
const reg = new CommandRegistry();
|
|
63
128
|
reg.register(fakeCommand('everywhere'));
|
|
@@ -22,25 +22,42 @@ export class CommandRegistry {
|
|
|
22
22
|
if (this.aliases.has(cmd.name)) {
|
|
23
23
|
throw new Error(`Command name already in use as an alias: /${cmd.name}`);
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
// Validate EVERY alias up front, before mutating any state, so a collision
|
|
26
|
+
// on the second alias can't leave the command half-registered (def set +
|
|
27
|
+
// some aliases) — the registry stays atomic on a throw.
|
|
26
28
|
for (const alias of cmd.aliases ?? []) {
|
|
27
29
|
if (this.aliases.has(alias) || this.commands.has(alias)) {
|
|
28
30
|
throw new Error(`Command alias already in use: /${alias}`);
|
|
29
31
|
}
|
|
30
|
-
this.aliases.set(alias, cmd.name);
|
|
31
32
|
}
|
|
33
|
+
this.commands.set(cmd.name, cmd);
|
|
34
|
+
for (const alias of cmd.aliases ?? []) this.aliases.set(alias, cmd.name);
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
replace(cmd: CommandDef): void {
|
|
35
|
-
// Clean up any aliases of the prior definition before re-adding.
|
|
36
38
|
const prior = this.commands.get(cmd.name);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const priorAliases = new Set(prior?.aliases ?? []);
|
|
40
|
+
// Validate the new aliases BEFORE mutating anything, mirroring register()'s
|
|
41
|
+
// guards so replace() can't silently hijack an alias owned by a different
|
|
42
|
+
// command or shadow another command's primary name. Validating first (rather
|
|
43
|
+
// than deleting the prior aliases then re-adding) keeps replace() atomic: a
|
|
44
|
+
// collision on a later alias must not have already destroyed the prior
|
|
45
|
+
// definition's aliases. An alias the prior def itself owned is exempt (it's
|
|
46
|
+
// about to be re-added under the same name).
|
|
41
47
|
for (const alias of cmd.aliases ?? []) {
|
|
42
|
-
this.aliases.
|
|
48
|
+
const owner = this.aliases.get(alias);
|
|
49
|
+
if (owner && owner !== cmd.name && !priorAliases.has(alias)) {
|
|
50
|
+
throw new Error(`Command alias already in use: /${alias}`);
|
|
51
|
+
}
|
|
52
|
+
if (this.commands.has(alias) && alias !== cmd.name) {
|
|
53
|
+
throw new Error(`Command alias already in use as a command name: /${alias}`);
|
|
54
|
+
}
|
|
43
55
|
}
|
|
56
|
+
// All validated — now mutate. Clean up the prior definition's aliases, then
|
|
57
|
+
// install the new def + its aliases.
|
|
58
|
+
for (const alias of priorAliases) this.aliases.delete(alias);
|
|
59
|
+
this.commands.set(cmd.name, cmd);
|
|
60
|
+
for (const alias of cmd.aliases ?? []) this.aliases.set(alias, cmd.name);
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
unregister(name: string): void {
|
|
@@ -32,6 +32,43 @@ describe('IsolatorRegistry (core contribution collection)', () => {
|
|
|
32
32
|
expect(reg.get('worker')).toBe(second);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it('refuses to let a discovered (untrusted) isolator shadow a trusted builtin', () => {
|
|
36
|
+
const reg = new IsolatorRegistry();
|
|
37
|
+
const trusted = fakeIsolator('worker');
|
|
38
|
+
const rogue = fakeIsolator('worker');
|
|
39
|
+
// Static builtins register as trusted (default).
|
|
40
|
+
expect(reg.register(trusted, { trusted: true })).toBe(true);
|
|
41
|
+
// A discovered plugin contributing the SAME name must not swap the impl the
|
|
42
|
+
// security layer resolves by config — the trusted one stays. register()
|
|
43
|
+
// returns false so the host knows the registration never took effect (and
|
|
44
|
+
// must not track it for rollback / unload).
|
|
45
|
+
expect(reg.register(rogue, { trusted: false })).toBe(false);
|
|
46
|
+
expect(reg.get('worker')).toBe(trusted);
|
|
47
|
+
expect(reg.list()).toHaveLength(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns true when a registration actually takes effect', () => {
|
|
51
|
+
const reg = new IsolatorRegistry();
|
|
52
|
+
expect(reg.register(fakeIsolator('docker'), { trusted: false })).toBe(true);
|
|
53
|
+
expect(reg.register(fakeIsolator('docker'), { trusted: false })).toBe(true); // overwrite still applied
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('lets a trusted registration still overwrite (builtin re-register / bundled+discovered same role)', () => {
|
|
57
|
+
const reg = new IsolatorRegistry();
|
|
58
|
+
const a = fakeIsolator('subprocess');
|
|
59
|
+
const b = fakeIsolator('subprocess');
|
|
60
|
+
reg.register(a, { trusted: true });
|
|
61
|
+
reg.register(b, { trusted: true });
|
|
62
|
+
expect(reg.get('subprocess')).toBe(b);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('allows a discovered isolator with a brand-new name', () => {
|
|
66
|
+
const reg = new IsolatorRegistry();
|
|
67
|
+
const novel = fakeIsolator('docker');
|
|
68
|
+
reg.register(novel, { trusted: false });
|
|
69
|
+
expect(reg.get('docker')).toBe(novel);
|
|
70
|
+
});
|
|
71
|
+
|
|
35
72
|
it('has no concept of an active isolator (selection stays with the security layer)', () => {
|
|
36
73
|
const reg = new IsolatorRegistry();
|
|
37
74
|
reg.register(fakeIsolator('wasm'));
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { Isolator } from '@moxxy/sdk';
|
|
2
|
+
import type { Logger } from '../logger.js';
|
|
3
|
+
|
|
4
|
+
export interface IsolatorRegisterOptions {
|
|
5
|
+
/**
|
|
6
|
+
* True when the isolator comes from a statically-registered (trusted)
|
|
7
|
+
* builtin, false for a discovered (untrusted) plugin. A discovered isolator
|
|
8
|
+
* is never allowed to shadow a name already claimed by a trusted one.
|
|
9
|
+
*/
|
|
10
|
+
readonly trusted?: boolean;
|
|
11
|
+
readonly logger?: Logger;
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
/**
|
|
4
15
|
* Collection of capability isolators contributed by plugins via
|
|
@@ -10,18 +21,54 @@ import type { Isolator } from '@moxxy/sdk';
|
|
|
10
21
|
* A contributed isolator is therefore NEVER auto-activated: registration only
|
|
11
22
|
* makes it available; the user must opt in by name, so a rogue plugin can't
|
|
12
23
|
* silently weaken isolation just by being installed.
|
|
24
|
+
*
|
|
25
|
+
* This is the one registry where "last wins" crosses a trust boundary: a
|
|
26
|
+
* discovered plugin contributing an isolator whose name matches a trusted
|
|
27
|
+
* builtin would silently swap the implementation the security layer resolves by
|
|
28
|
+
* `security.isolator`. So a discovered registration may NEVER overwrite a name
|
|
29
|
+
* a trusted registration already claimed; it's refused with a warning.
|
|
13
30
|
*/
|
|
14
31
|
export class IsolatorRegistry {
|
|
15
32
|
private readonly impls = new Map<string, Isolator>();
|
|
33
|
+
/** Names registered by a trusted (static) source — these are never shadowed. */
|
|
34
|
+
private readonly trustedNames = new Set<string>();
|
|
16
35
|
|
|
17
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Register an isolator by name. Returns `true` when the impl was stored,
|
|
38
|
+
* `false` when the registration was REFUSED (a discovered plugin trying to
|
|
39
|
+
* shadow a trusted name). The boolean lets the PluginHost avoid tracking a
|
|
40
|
+
* refused registration for rollback — otherwise a later mid-`applyPlugin`
|
|
41
|
+
* failure would `unregister(name)` and delete the very trusted isolator the
|
|
42
|
+
* refusal protected.
|
|
43
|
+
*/
|
|
44
|
+
register(iso: Isolator, opts: IsolatorRegisterOptions = {}): boolean {
|
|
45
|
+
const trusted = opts.trusted ?? true;
|
|
46
|
+
if (!trusted && this.trustedNames.has(iso.name)) {
|
|
47
|
+
// A discovered plugin must not shadow a trusted isolator: that would let
|
|
48
|
+
// it silently weaken an isolation boundary the user already opted into.
|
|
49
|
+
opts.logger?.warn(
|
|
50
|
+
'IsolatorRegistry: refusing to let a discovered plugin shadow a trusted isolator',
|
|
51
|
+
{ name: iso.name },
|
|
52
|
+
);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (this.impls.has(iso.name) && !trusted) {
|
|
56
|
+
// Replacing an existing (untrusted) entry from another discovered copy:
|
|
57
|
+
// allowed (same name → same role) but surfaced so it isn't silent.
|
|
58
|
+
opts.logger?.warn('IsolatorRegistry: a discovered isolator overwrote an existing name', {
|
|
59
|
+
name: iso.name,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
18
62
|
// Overwrite by name: an isolator may arrive via more than one path (a
|
|
19
63
|
// bundled built-in AND a discovered copy). Same name → same role; last wins.
|
|
20
64
|
this.impls.set(iso.name, iso);
|
|
65
|
+
if (trusted) this.trustedNames.add(iso.name);
|
|
66
|
+
return true;
|
|
21
67
|
}
|
|
22
68
|
|
|
23
69
|
unregister(name: string): void {
|
|
24
70
|
this.impls.delete(name);
|
|
71
|
+
this.trustedNames.delete(name);
|
|
25
72
|
}
|
|
26
73
|
|
|
27
74
|
get(name: string): Isolator | undefined {
|