@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.
- 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/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 -0
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -13
- 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/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 +17 -0
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -30
- package/src/skills/loader.test.ts +18 -0
- package/src/skills/loader.ts +12 -1
- package/src/skills/synthesize.test.ts +59 -0
- package/src/skills/synthesize.ts +89 -36
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +32 -2
- package/src/subagents/registry.test.ts +106 -0
- package/src/subagents/registry.ts +77 -3
- package/src/subagents/run-child.test.ts +125 -2
- package/src/subagents/run-child.ts +150 -47
- package/src/surfaces/host.test.ts +71 -1
- package/src/surfaces/host.ts +39 -2
- package/src/usage-stats.test.ts +99 -1
- package/src/usage-stats.ts +42 -17
- package/src/view/parse-extended.test.ts +80 -0
- package/src/view/parse.test.ts +14 -0
- package/src/view/parse.ts +113 -18
package/src/run-turn.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EmittedEvent, ModeContext, MoxxyEvent, RunTurnOptions } from '@moxxy/sdk';
|
|
1
|
+
import type { EmittedEvent, LLMProvider, ModeContext, MoxxyEvent, RunTurnOptions } from '@moxxy/sdk';
|
|
2
2
|
import type { SessionRuntime } from './session-runtime.js';
|
|
3
3
|
import { createSubagentSpawner } from './subagents.js';
|
|
4
4
|
|
|
@@ -20,16 +20,11 @@ export async function* runTurn(
|
|
|
20
20
|
// Use a caller-supplied turnId when present (the runner mints it up front so
|
|
21
21
|
// it can return the id before the turn runs); otherwise mint one here.
|
|
22
22
|
const turnId = opts.turnId ?? session.startTurn().turnId;
|
|
23
|
-
const provider = session.providers.getActive();
|
|
24
|
-
const model = opts.model ?? provider.models[0]?.id ?? 'default';
|
|
25
|
-
// Record the resolution so out-of-band spawns (workflow triggers) can
|
|
26
|
-
// inherit the conversation's current model. Last-writer-wins when turns
|
|
27
|
-
// run concurrently — see the field's doc in SessionRuntime.
|
|
28
|
-
session.lastResolvedModel = model;
|
|
29
23
|
|
|
30
24
|
const queue: MoxxyEvent[] = [];
|
|
31
25
|
const waiters: Array<() => void> = [];
|
|
32
26
|
let done = false;
|
|
27
|
+
let completed = false;
|
|
33
28
|
let strategyError: unknown = null;
|
|
34
29
|
// A mode can ask (via ctx.requestModeSwitch) to hand off to another mode
|
|
35
30
|
// once this turn finishes — applied after the strategy drains, below.
|
|
@@ -42,6 +37,12 @@ export async function* runTurn(
|
|
|
42
37
|
wake();
|
|
43
38
|
});
|
|
44
39
|
|
|
40
|
+
// Generator-scoped controller so an early consumer return/throw (HTTP client
|
|
41
|
+
// disconnect, channel teardown) can abort the in-flight strategy instead of
|
|
42
|
+
// leaving it to run the whole agentic loop to completion in the background
|
|
43
|
+
// (burning tokens, holding resources) while the abandoned `finally` blocks on
|
|
44
|
+
// `strategyPromise`.
|
|
45
|
+
const turnController = new AbortController();
|
|
45
46
|
let strategyPromise: Promise<void> | null = null;
|
|
46
47
|
|
|
47
48
|
try {
|
|
@@ -56,12 +57,46 @@ export async function* runTurn(
|
|
|
56
57
|
: {}),
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
// Resolve provider + model AFTER the prompt is recorded so a
|
|
61
|
+
// missing/misconfigured provider doesn't silently discard the user's
|
|
62
|
+
// prompt or orphan the turnId. On failure, append a structured error event
|
|
63
|
+
// (channels see a normal failed turn) and rethrow.
|
|
64
|
+
let provider: LLMProvider;
|
|
65
|
+
let model: string;
|
|
66
|
+
try {
|
|
67
|
+
provider = session.providers.getActive();
|
|
68
|
+
const resolvedModel = opts.model ?? provider.models[0]?.id;
|
|
69
|
+
if (!resolvedModel) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Active provider '${provider.name}' has no models configured`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
model = resolvedModel;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
await session.log.append({
|
|
78
|
+
type: 'error',
|
|
79
|
+
sessionId: session.id,
|
|
80
|
+
turnId,
|
|
81
|
+
source: 'system',
|
|
82
|
+
kind: 'fatal',
|
|
83
|
+
message,
|
|
84
|
+
});
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
// Record the resolution so out-of-band spawns (workflow triggers) can
|
|
88
|
+
// inherit the conversation's current model. Last-writer-wins when turns
|
|
89
|
+
// run concurrently — see the field's doc in SessionRuntime.
|
|
90
|
+
session.lastResolvedModel = model;
|
|
91
|
+
|
|
59
92
|
const strategy = session.modes.getActive();
|
|
60
|
-
// Combine the session's signal
|
|
61
|
-
// so
|
|
62
|
-
const effectiveSignal =
|
|
63
|
-
|
|
64
|
-
|
|
93
|
+
// Combine the session's signal, the per-turn one (if provided), and the
|
|
94
|
+
// generator-scoped abandonment signal so any of them firing cancels the turn.
|
|
95
|
+
const effectiveSignal = AbortSignal.any(
|
|
96
|
+
opts.signal
|
|
97
|
+
? [session.signal, opts.signal, turnController.signal]
|
|
98
|
+
: [session.signal, turnController.signal],
|
|
99
|
+
);
|
|
65
100
|
// The session's working dir + environment, mirrored onto the ModeContext
|
|
66
101
|
// so the shared tool dispatcher can hand onToolCall hooks the real cwd/env
|
|
67
102
|
// (path-based policy hooks gate on these) instead of empty placeholders.
|
|
@@ -105,16 +140,21 @@ export async function* runTurn(
|
|
|
105
140
|
const turnStartCtx = { ...appCtx, turnId, iteration: 0 };
|
|
106
141
|
|
|
107
142
|
strategyPromise = (async () => {
|
|
143
|
+
let started = false;
|
|
108
144
|
try {
|
|
109
145
|
await session.dispatcher.dispatchTurnStart(turnStartCtx);
|
|
146
|
+
started = true;
|
|
110
147
|
for await (const _ of strategy.run(ctx)) {
|
|
111
148
|
// Events are surfaced via the log subscription above.
|
|
112
149
|
void _;
|
|
113
150
|
}
|
|
114
|
-
await session.dispatcher.dispatchTurnEnd(turnStartCtx);
|
|
115
151
|
} catch (err) {
|
|
116
152
|
strategyError = err;
|
|
117
153
|
} finally {
|
|
154
|
+
// turnEnd must pair with turnStart even when the strategy throws/aborts,
|
|
155
|
+
// so plugins that allocate turn-scoped state in onTurnStart (spans,
|
|
156
|
+
// timers, token meters) always get the matching teardown.
|
|
157
|
+
if (started) await session.dispatcher.dispatchTurnEnd(turnStartCtx);
|
|
118
158
|
done = true;
|
|
119
159
|
wake();
|
|
120
160
|
}
|
|
@@ -125,15 +165,25 @@ export async function* runTurn(
|
|
|
125
165
|
if (done) break;
|
|
126
166
|
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
127
167
|
}
|
|
168
|
+
completed = true;
|
|
128
169
|
} finally {
|
|
129
170
|
unsubscribe();
|
|
171
|
+
// If the consumer abandoned iteration early (broke out of the `for await`,
|
|
172
|
+
// an outer error), `completed` is still false: abort the strategy so it
|
|
173
|
+
// unwinds promptly instead of running the full loop in the background while
|
|
174
|
+
// this `finally` blocks on `strategyPromise`.
|
|
175
|
+
if (!completed) turnController.abort('runTurn iteration abandoned');
|
|
130
176
|
if (strategyPromise) await strategyPromise;
|
|
131
177
|
// Apply a mode hand-off the strategy requested, now that the turn has
|
|
132
|
-
// fully drained. Only on clean completion
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
178
|
+
// fully drained. Only on clean completion: a thrown turn (strategyError) OR
|
|
179
|
+
// an ABANDONED turn (consumer broke out early — `completed` is false, and a
|
|
180
|
+
// mode that returns cleanly on `signal.aborted` leaves strategyError null)
|
|
181
|
+
// keeps the current mode, so an unwatched/cancelled turn can't silently
|
|
182
|
+
// flip the session into a different mode behind the user's back. An unknown
|
|
183
|
+
// target is ignored so a bad name can't wedge the session. The registry's
|
|
184
|
+
// setActive triggers the runner's InfoChanged broadcast, so channels see the
|
|
185
|
+
// new mode.
|
|
186
|
+
if (requestedModeSwitch && completed && !strategyError) {
|
|
137
187
|
try {
|
|
138
188
|
session.modes.setActive(requestedModeSwitch);
|
|
139
189
|
} catch {
|
package/src/session.test.ts
CHANGED
|
@@ -72,6 +72,30 @@ describe('Session', () => {
|
|
|
72
72
|
expect(s.signal.aborted).toBe(true);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('close() still runs plugin onShutdown when surface teardown rejects', async () => {
|
|
76
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
77
|
+
const onShutdown = vi.fn();
|
|
78
|
+
s.pluginHost.registerStatic(
|
|
79
|
+
definePlugin({ name: 'p', version: '0.0.0', hooks: { onShutdown } }),
|
|
80
|
+
);
|
|
81
|
+
// A flaky native surface (PTY/browser) throwing during teardown must not
|
|
82
|
+
// pre-empt the plugin shutdown hooks — those are how plugins flush state.
|
|
83
|
+
vi.spyOn(s.surfaces, 'closeAll').mockRejectedValue(new Error('PTY teardown blew up'));
|
|
84
|
+
|
|
85
|
+
await expect(s.close()).resolves.toBeUndefined();
|
|
86
|
+
expect(onShutdown).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(s.signal.aborted).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('appContext env is a frozen, stable snapshot (reused across calls)', () => {
|
|
91
|
+
const s = new Session({ cwd: '/tmp', silent: true });
|
|
92
|
+
const a = s.appContext();
|
|
93
|
+
const b = s.appContext();
|
|
94
|
+
// Memoized: the same frozen object is handed out, not a fresh clone each call.
|
|
95
|
+
expect(a.env).toBe(b.env);
|
|
96
|
+
expect(Object.isFrozen(a.env)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
75
99
|
it('close() is idempotent', async () => {
|
|
76
100
|
const s = new Session({ cwd: '/tmp', silent: true });
|
|
77
101
|
const onShutdown = vi.fn();
|
package/src/session.ts
CHANGED
|
@@ -332,8 +332,17 @@ export class Session implements ClientSession, SessionRuntime {
|
|
|
332
332
|
this.closed = true;
|
|
333
333
|
try {
|
|
334
334
|
// Tear down any open surfaces (PTYs, browser screencasts) before the
|
|
335
|
-
// plugin shutdown hooks dispose their underlying resources.
|
|
336
|
-
|
|
335
|
+
// plugin shutdown hooks dispose their underlying resources. Isolated in
|
|
336
|
+
// its own try/catch: a flaky native surface throwing during teardown must
|
|
337
|
+
// not pre-empt the plugin shutdown hooks below, which are how plugins
|
|
338
|
+
// flush state (memory journal, vault, audit logs).
|
|
339
|
+
try {
|
|
340
|
+
await this.surfaces.closeAll();
|
|
341
|
+
} catch (err) {
|
|
342
|
+
this.logger.warn('surface teardown failed during close', {
|
|
343
|
+
error: err instanceof Error ? err.message : String(err),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
337
346
|
await this.dispatcher.dispatchShutdown(this.appContext());
|
|
338
347
|
} finally {
|
|
339
348
|
// Drop THIS session's retained child sessions (the workflow `awaitInput`
|
|
@@ -348,13 +357,25 @@ export class Session implements ClientSession, SessionRuntime {
|
|
|
348
357
|
}
|
|
349
358
|
|
|
350
359
|
private closed = false;
|
|
360
|
+
/**
|
|
361
|
+
* Memoized one-time snapshot of `process.env`. `appContext()` is called per
|
|
362
|
+
* dispatched event (hundreds per turn during a streaming reply), per turn,
|
|
363
|
+
* and on every shutdown — eagerly spreading the whole env each time is
|
|
364
|
+
* thousands of O(envVars) clones of GC pressure for a value that does not
|
|
365
|
+
* change over a session's life. Frozen so a plugin can't mutate the shared
|
|
366
|
+
* snapshot out from under another.
|
|
367
|
+
*/
|
|
368
|
+
private envSnapshot: Readonly<NodeJS.ProcessEnv> | null = null;
|
|
351
369
|
|
|
352
370
|
appContext(): AppContext {
|
|
371
|
+
if (!this.envSnapshot) {
|
|
372
|
+
this.envSnapshot = Object.freeze({ ...process.env });
|
|
373
|
+
}
|
|
353
374
|
return {
|
|
354
375
|
sessionId: this.id,
|
|
355
376
|
cwd: this.cwd,
|
|
356
377
|
log: this.log.asReader(),
|
|
357
|
-
env:
|
|
378
|
+
env: this.envSnapshot,
|
|
358
379
|
};
|
|
359
380
|
}
|
|
360
381
|
|
|
@@ -78,6 +78,23 @@ describe('pageEvents (pure newest-first paging)', () => {
|
|
|
78
78
|
expect(pageEvents([], 3, 5)).toEqual({ events: [], prevCursor: null });
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
it('a non-finite `before` cursor degrades to the newest page (no NaN prevCursor)', () => {
|
|
82
|
+
const log = makeLog(10);
|
|
83
|
+
// `pageEvents` is exported public API; a corrupt cursor (NaN / Infinity from
|
|
84
|
+
// a hand-edited or wire-mangled page request) must not poison the result.
|
|
85
|
+
// Worst case if unguarded: every `seq < NaN` is false → empty page with
|
|
86
|
+
// `prevCursor: NaN`, which JSON-serializes to `null` and wedges the walk.
|
|
87
|
+
for (const bad of [NaN, Infinity, -Infinity, 1.5] as number[]) {
|
|
88
|
+
const page = pageEvents(log, bad, 3);
|
|
89
|
+
// Treated as the newest page (before=null) — last `limit` events.
|
|
90
|
+
expect(page.events.map((e) => e.seq)).toEqual([7, 8, 9]);
|
|
91
|
+
expect(page.prevCursor).toBe(7);
|
|
92
|
+
expect(Number.isNaN(page.prevCursor as number)).toBe(false);
|
|
93
|
+
}
|
|
94
|
+
// Empty-log path also never emits a NaN cursor for a bad `before`.
|
|
95
|
+
expect(pageEvents([], NaN, 5)).toEqual({ events: [], prevCursor: null });
|
|
96
|
+
});
|
|
97
|
+
|
|
81
98
|
it('walking all pages reconstructs the full log exactly once, in order', () => {
|
|
82
99
|
const log = makeLog(23);
|
|
83
100
|
const collected: number[] = [];
|
|
@@ -4,7 +4,14 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
5
|
import { EventLog } from '../events/log.js';
|
|
6
6
|
import type { Logger } from '../logger.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
SessionPersistence,
|
|
9
|
+
deleteSession,
|
|
10
|
+
readEventPage,
|
|
11
|
+
readIndex,
|
|
12
|
+
restoreEvents,
|
|
13
|
+
type SessionMeta,
|
|
14
|
+
} from './persistence.js';
|
|
8
15
|
|
|
9
16
|
interface CapturedLine {
|
|
10
17
|
readonly level: 'debug' | 'info' | 'warn' | 'error';
|
|
@@ -226,6 +233,91 @@ describe('SessionPersistence', () => {
|
|
|
226
233
|
expect(again.lines).toHaveLength(0);
|
|
227
234
|
});
|
|
228
235
|
|
|
236
|
+
it('rejects a path-traversal session id before touching the filesystem', async () => {
|
|
237
|
+
const dir = await makeTempDir();
|
|
238
|
+
// A secret file OUTSIDE the sessions dir that a traversal id would target.
|
|
239
|
+
const secretName = `traversal-target-${Date.now()}.jsonl`;
|
|
240
|
+
const secret = path.join(dir, '..', secretName);
|
|
241
|
+
await fs.writeFile(secret, 'do not delete me', 'utf8');
|
|
242
|
+
try {
|
|
243
|
+
await expect(restoreEvents('../etc/passwd', dir)).rejects.toThrow(/invalid session id/i);
|
|
244
|
+
await expect(readEventPage('a/../../b', { before: null, limit: 5 }, dir)).rejects.toThrow(
|
|
245
|
+
/invalid session id/i,
|
|
246
|
+
);
|
|
247
|
+
// deleteSession uses force:true — a traversal delete would otherwise
|
|
248
|
+
// silently succeed. The id resolving to our secret must be rejected so
|
|
249
|
+
// the file stays intact.
|
|
250
|
+
const evil = `..${path.sep}${secretName.replace(/\.jsonl$/, '')}`;
|
|
251
|
+
await expect(deleteSession(evil, dir)).rejects.toThrow(/invalid session id/i);
|
|
252
|
+
await expect(fs.readFile(secret, 'utf8')).resolves.toBe('do not delete me');
|
|
253
|
+
} finally {
|
|
254
|
+
await fs.rm(secret, { force: true });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('firstPrompt label is sliced on code-point boundaries (no split surrogate pair)', async () => {
|
|
259
|
+
const dir = await makeTempDir();
|
|
260
|
+
const id = '01SURROGATE0000000000000AA';
|
|
261
|
+
const log = new EventLog();
|
|
262
|
+
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
|
|
263
|
+
const detach = persistence.attach(log);
|
|
264
|
+
// 80 emoji (each a surrogate pair) + a trailing char. A naive
|
|
265
|
+
// `slice(0, 80)` on UTF-16 code units would cut emoji #41 in half, leaving a
|
|
266
|
+
// lone surrogate at the boundary.
|
|
267
|
+
const text = '😀'.repeat(90) + 'x';
|
|
268
|
+
await log.append({
|
|
269
|
+
type: 'user_prompt',
|
|
270
|
+
sessionId: id as never,
|
|
271
|
+
turnId: 't1' as never,
|
|
272
|
+
source: 'user',
|
|
273
|
+
text,
|
|
274
|
+
});
|
|
275
|
+
await persistence.flush();
|
|
276
|
+
const [row] = await readIndex(dir);
|
|
277
|
+
const label = row!.firstPrompt!;
|
|
278
|
+
// Exactly 80 code points, all whole emoji — the cut never split a surrogate
|
|
279
|
+
// pair. A naive slice(0, 80) would yield 80 UTF-16 units = 40 emoji and the
|
|
280
|
+
// label would round-trip fine, but the worst case (cut INSIDE a pair) is
|
|
281
|
+
// what we guard: rebuild from code points must reproduce the label exactly.
|
|
282
|
+
expect([...label]).toHaveLength(80);
|
|
283
|
+
expect([...label].every((cp) => cp === '😀')).toBe(true);
|
|
284
|
+
// No lone (unpaired) surrogate survives a JSON round-trip as itself.
|
|
285
|
+
expect(JSON.parse(JSON.stringify(label))).toBe(label);
|
|
286
|
+
|
|
287
|
+
detach();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('a user_prompt with a non-string text does not crash the persistence listener', async () => {
|
|
291
|
+
const dir = await makeTempDir();
|
|
292
|
+
const id = '01BADTEXT00000000000000000';
|
|
293
|
+
const { logger } = captureLogger();
|
|
294
|
+
const log = new EventLog();
|
|
295
|
+
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir, logger });
|
|
296
|
+
const detach = persistence.attach(log);
|
|
297
|
+
|
|
298
|
+
// A hostile / hand-built event whose `text` is not a string. `firstPromptLabel`
|
|
299
|
+
// runs `[...text]` inside the log listener chain — without the coercion this
|
|
300
|
+
// throws, latching the misleading "persistence degraded" warning and dropping
|
|
301
|
+
// the row. It must instead degrade gracefully (label coerced, write succeeds).
|
|
302
|
+
await log.append({
|
|
303
|
+
type: 'user_prompt',
|
|
304
|
+
sessionId: id as never,
|
|
305
|
+
turnId: 't1' as never,
|
|
306
|
+
source: 'user',
|
|
307
|
+
text: null as unknown as string,
|
|
308
|
+
});
|
|
309
|
+
await persistence.flush();
|
|
310
|
+
await persistence.settleWrites();
|
|
311
|
+
|
|
312
|
+
// The disk write was not poisoned by the bad label.
|
|
313
|
+
expect(persistence.degraded).toBe(false);
|
|
314
|
+
const [row] = await readIndex(dir);
|
|
315
|
+
expect(row?.id).toBe(id);
|
|
316
|
+
expect(typeof row?.firstPrompt).toBe('string');
|
|
317
|
+
|
|
318
|
+
detach();
|
|
319
|
+
});
|
|
320
|
+
|
|
229
321
|
it('two concurrent sessions both survive (no shared-index clobber)', async () => {
|
|
230
322
|
const dir = await makeTempDir();
|
|
231
323
|
const idA = '01AAAA00000000000000000001';
|
|
@@ -57,6 +57,49 @@ export function defaultSessionsDir(): string {
|
|
|
57
57
|
return path.join(os.homedir(), '.moxxy', 'sessions');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Session ids are ULIDs internally, but `restoreEvents`/`readEventPage`/
|
|
62
|
+
* `deleteSession` are public APIs reachable from the runner/desktop IPC and the
|
|
63
|
+
* CLI. Reject anything outside the ULID-safe charset before path-joining so a
|
|
64
|
+
* `../`-laden or absolute-ish id can't read or `fs.rm` files outside the
|
|
65
|
+
* sessions dir (deleteSession uses `force:true`, so a traversal delete would
|
|
66
|
+
* otherwise silently succeed). Defense-in-depth at the trust boundary.
|
|
67
|
+
*/
|
|
68
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
69
|
+
|
|
70
|
+
function assertSafeSessionId(sessionId: string): void {
|
|
71
|
+
if (!SAFE_SESSION_ID.test(sessionId)) {
|
|
72
|
+
throw new Error(`Invalid session id: ${JSON.stringify(sessionId)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Max concurrent fs operations in `readIndex` — bounds open file handles so a
|
|
77
|
+
* user with thousands of sessions can't hit EMFILE on the list/resume path. */
|
|
78
|
+
const READ_INDEX_CONCURRENCY = 32;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Map `fn` over `items` with at most `limit` calls in flight at once, preserving
|
|
82
|
+
* input order in the returned array. A small dependency-free concurrency limiter
|
|
83
|
+
* (the framework avoids extra deps for this).
|
|
84
|
+
*/
|
|
85
|
+
async function mapWithConcurrency<T, R>(
|
|
86
|
+
items: ReadonlyArray<T>,
|
|
87
|
+
limit: number,
|
|
88
|
+
fn: (item: T) => Promise<R>,
|
|
89
|
+
): Promise<R[]> {
|
|
90
|
+
const results = new Array<R>(items.length);
|
|
91
|
+
let cursor = 0;
|
|
92
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
93
|
+
for (;;) {
|
|
94
|
+
const index = cursor++;
|
|
95
|
+
if (index >= items.length) return;
|
|
96
|
+
results[index] = await fn(items[index]!);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
await Promise.all(workers);
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
60
103
|
/**
|
|
61
104
|
* Attaches a listener that streams every appended event to disk and
|
|
62
105
|
* keeps the index in sync. Returns an `unsubscribe` callback the
|
|
@@ -76,6 +119,15 @@ export class SessionPersistence {
|
|
|
76
119
|
* can flush.
|
|
77
120
|
*/
|
|
78
121
|
private writeQueue: Mutex = createMutex();
|
|
122
|
+
/**
|
|
123
|
+
* Single-flight guard for the sidecar (`writeIndex`) write. The debounced
|
|
124
|
+
* timer and an explicit `flush()` can both call `writeIndex()` concurrently;
|
|
125
|
+
* without serialization two `writeFileAtomic`s would race on `metaPath` and
|
|
126
|
+
* the on-disk result would be last-rename-wins between two `this.meta`
|
|
127
|
+
* snapshots non-deterministically. Chaining them makes the latest meta win
|
|
128
|
+
* deterministically and drops the redundant work.
|
|
129
|
+
*/
|
|
130
|
+
private indexWriteChain: Promise<void> = Promise.resolve();
|
|
79
131
|
/**
|
|
80
132
|
* Memoized one-time setup: `mkdir -p` the sessions dir and create the empty
|
|
81
133
|
* `.jsonl` (so resume lists the session before any event). Awaited by both
|
|
@@ -201,7 +253,7 @@ export class SessionPersistence {
|
|
|
201
253
|
lastActivity: new Date().toISOString(),
|
|
202
254
|
firstPrompt:
|
|
203
255
|
this.meta.firstPrompt ??
|
|
204
|
-
(event.type === 'user_prompt' ? event.text
|
|
256
|
+
(event.type === 'user_prompt' ? firstPromptLabel(event.text) : null),
|
|
205
257
|
};
|
|
206
258
|
this.scheduleIndexWrite();
|
|
207
259
|
const line = JSON.stringify(event) + '\n';
|
|
@@ -285,7 +337,20 @@ export class SessionPersistence {
|
|
|
285
337
|
this.indexTimer = timer;
|
|
286
338
|
}
|
|
287
339
|
|
|
288
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Serialize the sidecar write through `indexWriteChain` so a debounced write
|
|
342
|
+
* and a concurrent `flush()` never race on `metaPath`; resolves once THIS
|
|
343
|
+
* call's write (or a later one that supersedes it) has settled.
|
|
344
|
+
*/
|
|
345
|
+
private writeIndex(): Promise<void> {
|
|
346
|
+
const next = this.indexWriteChain.then(() => this.doWriteIndex());
|
|
347
|
+
// Keep the chain unbroken even if a write rejects (doWriteIndex already
|
|
348
|
+
// swallows, but guard the chain itself defensively).
|
|
349
|
+
this.indexWriteChain = next.catch(() => undefined);
|
|
350
|
+
return next;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async doWriteIndex(): Promise<void> {
|
|
289
354
|
try {
|
|
290
355
|
// Once closed (the final detach/flush write), don't re-create the dir or
|
|
291
356
|
// log file: setup already made them, and resurrecting a directory a
|
|
@@ -352,31 +417,29 @@ export async function readIndex(dir = defaultSessionsDir()): Promise<SessionMeta
|
|
|
352
417
|
} catch {
|
|
353
418
|
dirents = [];
|
|
354
419
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
);
|
|
420
|
+
// Cap fan-out: a user with thousands of sidecars in ~/.moxxy/sessions would
|
|
421
|
+
// otherwise open thousands of file handles at once (EMFILE) on every resume/
|
|
422
|
+
// list. Process in bounded batches instead.
|
|
423
|
+
const sidecarFiles = dirents.filter((d) => d.isFile() && d.name.endsWith('.meta.json'));
|
|
424
|
+
await mapWithConcurrency(sidecarFiles, READ_INDEX_CONCURRENCY, async (d) => {
|
|
425
|
+
try {
|
|
426
|
+
const raw = await fs.readFile(path.join(dir, d.name), 'utf8');
|
|
427
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
428
|
+
if (isSessionMeta(parsed)) byId.set(parsed.id, parsed);
|
|
429
|
+
} catch {
|
|
430
|
+
// skip a malformed/half-written sidecar
|
|
431
|
+
}
|
|
432
|
+
});
|
|
368
433
|
|
|
369
434
|
const metas = [...byId.values()];
|
|
370
|
-
const checks = await
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}),
|
|
379
|
-
);
|
|
435
|
+
const checks = await mapWithConcurrency(metas, READ_INDEX_CONCURRENCY, async (meta) => {
|
|
436
|
+
try {
|
|
437
|
+
await fs.access(path.join(dir, `${meta.id}.jsonl`));
|
|
438
|
+
return true;
|
|
439
|
+
} catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
380
443
|
return metas
|
|
381
444
|
.filter((_, index) => checks[index])
|
|
382
445
|
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
@@ -411,6 +474,7 @@ export async function restoreEvents(
|
|
|
411
474
|
dir = defaultSessionsDir(),
|
|
412
475
|
logger: Logger = createLogger(),
|
|
413
476
|
): Promise<MoxxyEvent[]> {
|
|
477
|
+
assertSafeSessionId(sessionId);
|
|
414
478
|
const logPath = path.join(dir, `${sessionId}.jsonl`);
|
|
415
479
|
let raw: string;
|
|
416
480
|
try {
|
|
@@ -448,8 +512,25 @@ export async function restoreEvents(
|
|
|
448
512
|
restoredEvents: events.length,
|
|
449
513
|
});
|
|
450
514
|
try {
|
|
451
|
-
|
|
452
|
-
|
|
515
|
+
// Conflict guard: only rewrite if the on-disk content still matches what
|
|
516
|
+
// we read. Another process (a desktop runner already attached + a CLI
|
|
517
|
+
// `resume` of the SAME id) may have appended/repaired between our read and
|
|
518
|
+
// this write; its appends are a newer snapshot than our re-sequenced one,
|
|
519
|
+
// and a blind `writeFileAtomic` would clobber them (silent history loss).
|
|
520
|
+
// The single-writer assumption the rest of persistence relies on does not
|
|
521
|
+
// hold across processes, so re-read and compare before the destructive
|
|
522
|
+
// rewrite. Restore still succeeds either way — the in-memory log is
|
|
523
|
+
// already repaired; only the next clean resume would re-run the repair.
|
|
524
|
+
const current = await fs.readFile(logPath, 'utf8').catch(() => null);
|
|
525
|
+
if (current !== raw) {
|
|
526
|
+
logger.warn('skipped repaired-log rewrite — file changed under us (another process attached?)', {
|
|
527
|
+
sessionId,
|
|
528
|
+
path: logPath,
|
|
529
|
+
});
|
|
530
|
+
} else {
|
|
531
|
+
const repaired = events.map((e) => JSON.stringify(e) + '\n').join('');
|
|
532
|
+
await writeFileAtomic(logPath, repaired);
|
|
533
|
+
}
|
|
453
534
|
} catch (err) {
|
|
454
535
|
// Restore still succeeds — the in-memory log is repaired; only the
|
|
455
536
|
// next resume would re-run this same repair.
|
|
@@ -512,6 +593,7 @@ export async function readEventPage(
|
|
|
512
593
|
opts: { before: number | null; limit: number },
|
|
513
594
|
dir = defaultSessionsDir(),
|
|
514
595
|
): Promise<EventPage> {
|
|
596
|
+
assertSafeSessionId(sessionId);
|
|
515
597
|
const logPath = path.join(dir, `${sessionId}.jsonl`);
|
|
516
598
|
let raw: string;
|
|
517
599
|
try {
|
|
@@ -552,16 +634,26 @@ export function pageEvents(
|
|
|
552
634
|
limit: number,
|
|
553
635
|
): EventPage {
|
|
554
636
|
const cap = Math.max(0, Math.floor(limit));
|
|
637
|
+
// `pageEvents` is an exported public API (runner + disk paths call it), so it
|
|
638
|
+
// must self-defend rather than trust every caller to pre-validate. A
|
|
639
|
+
// non-finite `before` (NaN/Infinity from a corrupt cursor) would otherwise
|
|
640
|
+
// make every `seq < before` comparison false, collapse the page to empty, and
|
|
641
|
+
// hand back `prevCursor: before` — a NaN that JSON-serializes to `null`,
|
|
642
|
+
// silently wedging the client's backward walk. Coerce any non-integer cursor
|
|
643
|
+
// to the newest page (`null`) so a bad cursor degrades to "start over from the
|
|
644
|
+
// top" instead of a poisoned cursor.
|
|
645
|
+
const safeBefore =
|
|
646
|
+
before === null || !Number.isInteger(before) ? null : before;
|
|
555
647
|
if (cap === 0 || events.length === 0) {
|
|
556
|
-
return { events: [], prevCursor: events.length === 0 ? null :
|
|
648
|
+
return { events: [], prevCursor: events.length === 0 ? null : safeBefore };
|
|
557
649
|
}
|
|
558
650
|
// Exclusive upper bound by `seq`: the first index whose event is NOT older
|
|
559
651
|
// than `before`. `before == null` (newest page) keeps the whole array.
|
|
560
652
|
let end = events.length;
|
|
561
|
-
if (
|
|
653
|
+
if (safeBefore !== null) {
|
|
562
654
|
end = 0;
|
|
563
655
|
for (let i = 0; i < events.length; i += 1) {
|
|
564
|
-
if (events[i]!.seq <
|
|
656
|
+
if (events[i]!.seq < safeBefore) end = i + 1;
|
|
565
657
|
else break;
|
|
566
658
|
}
|
|
567
659
|
}
|
|
@@ -581,6 +673,7 @@ export async function deleteSession(
|
|
|
581
673
|
sessionId: string,
|
|
582
674
|
dir = defaultSessionsDir(),
|
|
583
675
|
): Promise<void> {
|
|
676
|
+
assertSafeSessionId(sessionId);
|
|
584
677
|
await fs.rm(path.join(dir, `${sessionId}.jsonl`), { force: true });
|
|
585
678
|
await fs.rm(metaPath(dir, sessionId), { force: true });
|
|
586
679
|
}
|
|
@@ -590,6 +683,21 @@ function metaPath(dir: string, id: string): string {
|
|
|
590
683
|
return path.join(dir, `${id}.meta.json`);
|
|
591
684
|
}
|
|
592
685
|
|
|
686
|
+
/**
|
|
687
|
+
* First 80 chars of a prompt for the picker label, sliced on code-point (not
|
|
688
|
+
* UTF-16 code-unit) boundaries so the cut never splits a surrogate pair (emoji,
|
|
689
|
+
* astral CJK) into a lone half-character that renders as a broken glyph.
|
|
690
|
+
*
|
|
691
|
+
* `text` is typed `string`, but this runs inside the log listener chain — a
|
|
692
|
+
* throw here would latch the misleading "persistence degraded" warning. Coerce
|
|
693
|
+
* a non-string (a hand-built `EmittedEvent`, a future schema variant) instead of
|
|
694
|
+
* letting `[...text]` throw on `undefined`/`null`.
|
|
695
|
+
*/
|
|
696
|
+
function firstPromptLabel(text: string): string {
|
|
697
|
+
const s = typeof text === 'string' ? text : String(text ?? '');
|
|
698
|
+
return [...s].slice(0, 80).join('');
|
|
699
|
+
}
|
|
700
|
+
|
|
593
701
|
async function writeJsonAtomic(target: string, value: unknown): Promise<void> {
|
|
594
702
|
await writeFileAtomic(target, JSON.stringify(value, null, 2) + '\n');
|
|
595
703
|
}
|
|
@@ -99,4 +99,22 @@ describe('discoverSkills', () => {
|
|
|
99
99
|
});
|
|
100
100
|
expect(skills).toEqual([]);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
it('bounds directory recursion depth (deep tree does not crash, deepest skill skipped)', async () => {
|
|
104
|
+
const root = path.join(tmp, 'project');
|
|
105
|
+
// A shallow skill (loaded) and a very deeply-nested one (beyond the depth cap).
|
|
106
|
+
await writeSkill(root, 'shallow');
|
|
107
|
+
let deep = root;
|
|
108
|
+
for (let i = 0; i < 20; i++) deep = path.join(deep, `lvl${i}`);
|
|
109
|
+
await writeSkill(deep, 'too-deep');
|
|
110
|
+
|
|
111
|
+
const skills = await discoverSkills({
|
|
112
|
+
projectDir: root,
|
|
113
|
+
userDir: path.join(tmp, 'noop'),
|
|
114
|
+
logger: silentLogger,
|
|
115
|
+
});
|
|
116
|
+
const names = skills.map((s) => s.frontmatter.name);
|
|
117
|
+
expect(names).toContain('shallow');
|
|
118
|
+
expect(names).not.toContain('too-deep');
|
|
119
|
+
});
|
|
102
120
|
});
|
package/src/skills/loader.ts
CHANGED
|
@@ -33,10 +33,14 @@ export async function discoverSkills(opts: SkillLoadOptions = {}): Promise<Reado
|
|
|
33
33
|
return [...seenNames.values()];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Cap directory recursion so a pathologically deep (or hostile) skills tree can't blow the stack at boot. */
|
|
37
|
+
const MAX_SKILL_DIR_DEPTH = 8;
|
|
38
|
+
|
|
36
39
|
async function loadDir(
|
|
37
40
|
dir: string,
|
|
38
41
|
scope: SkillScope,
|
|
39
42
|
logger?: Logger,
|
|
43
|
+
depth = 0,
|
|
40
44
|
): Promise<ReadonlyArray<DiscoveredSkill>> {
|
|
41
45
|
let entries: import('node:fs').Dirent[];
|
|
42
46
|
try {
|
|
@@ -47,7 +51,14 @@ async function loadDir(
|
|
|
47
51
|
const out: DiscoveredSkill[] = [];
|
|
48
52
|
for (const entry of entries) {
|
|
49
53
|
if (entry.isDirectory()) {
|
|
50
|
-
|
|
54
|
+
if (depth >= MAX_SKILL_DIR_DEPTH) {
|
|
55
|
+
logger?.warn('skill: directory too deep, not recursing', {
|
|
56
|
+
path: path.join(dir, entry.name),
|
|
57
|
+
maxDepth: MAX_SKILL_DIR_DEPTH,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
out.push(...(await loadDir(path.join(dir, entry.name), scope, logger, depth + 1)));
|
|
51
62
|
continue;
|
|
52
63
|
}
|
|
53
64
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|