lyra-core 0.1.2 → 0.1.11
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/package.json +1 -1
- package/src/brain/compaction.test.ts +0 -156
- package/src/brain/frozen-prefix.test.ts +0 -235
- package/src/brain/history-persist.test.ts +0 -129
- package/src/brain/openai-brain.test.ts +0 -61
- package/src/brain/sanitize.test.ts +0 -27
- package/src/claude-plugins/discovery.test.ts +0 -71
- package/src/commands/registry.test.ts +0 -186
- package/src/events/queue.test.ts +0 -43
- package/src/index.test.ts +0 -6
- package/src/locks.test.ts +0 -259
- package/src/mcp/discovery.test.ts +0 -97
- package/src/mcp/manager.test.ts +0 -97
- package/src/memory/edit.test.ts +0 -41
- package/src/memory/encryption.test.ts +0 -68
- package/src/memory/index-sync.test.ts +0 -81
- package/src/memory/pack-blob.test.ts +0 -90
- package/src/memory/pack-gather.test.ts +0 -110
- package/src/memory/parser.test.ts +0 -29
- package/src/memory/path-drift.test.ts +0 -71
- package/src/memory/read-tool-fallback.test.ts +0 -54
- package/src/memory/save-tool.test.ts +0 -193
- package/src/memory/scan.test.ts +0 -24
- package/src/migration/option3-crypto.test.ts +0 -106
- package/src/pairing.test.ts +0 -212
- package/src/permission/permission.test.ts +0 -299
- package/src/plugins/plugins.test.ts +0 -196
- package/src/public/card.test.ts +0 -70
- package/src/runtime/runtime.test.ts +0 -55
- package/src/sandbox/sandbox.test.ts +0 -386
- package/src/skills/scanner.test.ts +0 -137
- package/src/skills/triggers.test.ts +0 -77
- package/src/storage/encryption.test.ts +0 -30
- package/src/storage/sqlite.test.ts +0 -29
- package/src/tools/escalation.test.ts +0 -348
- package/src/tools/registry.test.ts +0 -70
- package/src/tools/zod-helpers.test.ts +0 -63
- package/src/wallet/drain.test.ts +0 -41
- package/src/wallet/keystore.test.ts +0 -17
|
@@ -1,386 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { makeSandboxBackend } from './factory'
|
|
3
|
-
import { buildBwrapArgs } from './linux'
|
|
4
|
-
import { LocalBackend } from './local'
|
|
5
|
-
import { MacOSSandboxExecBackend } from './macos'
|
|
6
|
-
import { buildSeatbeltProfile } from './seatbelt-profile'
|
|
7
|
-
|
|
8
|
-
describe('LocalBackend (passthrough)', () => {
|
|
9
|
-
test('returns spawn request unchanged', async () => {
|
|
10
|
-
const b = new LocalBackend()
|
|
11
|
-
const req = {
|
|
12
|
-
command: '/bin/sh',
|
|
13
|
-
args: ['-c', 'echo ok'],
|
|
14
|
-
options: { cwd: '/tmp' },
|
|
15
|
-
}
|
|
16
|
-
const out = await b.wrapSpawn(req)
|
|
17
|
-
expect(out.command).toBe('/bin/sh')
|
|
18
|
-
expect(out.args).toEqual(['-c', 'echo ok'])
|
|
19
|
-
expect(out.options).toEqual({ cwd: '/tmp' })
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('mode + label expose correct identifiers', () => {
|
|
23
|
-
const b = new LocalBackend()
|
|
24
|
-
expect(b.mode).toBe('none')
|
|
25
|
-
expect(b.label).toBe('none')
|
|
26
|
-
})
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
describe('buildSeatbeltProfile', () => {
|
|
30
|
-
test('emits a deny-default profile that explicitly allows agentDir + workspaceRoot', () => {
|
|
31
|
-
const profile = buildSeatbeltProfile({
|
|
32
|
-
agentDir: '/Users/test/.lyra/agents/abc',
|
|
33
|
-
workspaceRoot: '/Users/test/Documents/proj',
|
|
34
|
-
homedir: '/Users/test',
|
|
35
|
-
})
|
|
36
|
-
expect(profile).toContain('(deny default)')
|
|
37
|
-
expect(profile).toContain('(allow file-write* (subpath "/Users/test/.lyra/agents/abc"))')
|
|
38
|
-
expect(profile).toContain('(allow file-write* (subpath "/Users/test/Documents/proj"))')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('explicitly denies credential paths for both reads and writes', () => {
|
|
42
|
-
const profile = buildSeatbeltProfile({
|
|
43
|
-
agentDir: '/Users/test/.lyra/agents/abc',
|
|
44
|
-
workspaceRoot: '/Users/test/work',
|
|
45
|
-
homedir: '/Users/test',
|
|
46
|
-
})
|
|
47
|
-
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.ssh"))')
|
|
48
|
-
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.aws"))')
|
|
49
|
-
expect(profile).toContain('(deny file-write* (subpath "/Users/test/Library/Keychains"))')
|
|
50
|
-
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.config/gcloud"))')
|
|
51
|
-
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.lyra"))')
|
|
52
|
-
// Reads of credential dirs blocked too — `cat ~/.ssh/id_rsa` should fail
|
|
53
|
-
expect(profile).toContain('(deny file-read* (subpath "/Users/test/.ssh"))')
|
|
54
|
-
expect(profile).toContain('(deny file-read* (subpath "/Users/test/.aws"))')
|
|
55
|
-
expect(profile).toContain('(deny file-read* (subpath "/Users/test/Library/Keychains"))')
|
|
56
|
-
expect(profile).toContain('(deny file-read* (subpath "/Users/test/.config/gcloud"))')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('allows process-fork + process-exec + network', () => {
|
|
60
|
-
const profile = buildSeatbeltProfile({
|
|
61
|
-
agentDir: '/a',
|
|
62
|
-
workspaceRoot: '/b',
|
|
63
|
-
homedir: '/h',
|
|
64
|
-
})
|
|
65
|
-
expect(profile).toContain('(allow process-fork)')
|
|
66
|
-
expect(profile).toContain('(allow process-exec)')
|
|
67
|
-
expect(profile).toContain('(allow network*)')
|
|
68
|
-
expect(profile).toContain('(allow file-read*)')
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
test('allows /tmp/lyra-* + /var/folders for temp dirs', () => {
|
|
72
|
-
const profile = buildSeatbeltProfile({
|
|
73
|
-
agentDir: '/a',
|
|
74
|
-
workspaceRoot: '/b',
|
|
75
|
-
homedir: '/h',
|
|
76
|
-
})
|
|
77
|
-
expect(profile).toContain('(allow file-write* (regex #"^/tmp/lyra-"))')
|
|
78
|
-
expect(profile).toContain('(allow file-write* (regex #"^/private/tmp/lyra-"))')
|
|
79
|
-
expect(profile).toContain('(allow file-write* (subpath "/var/folders"))')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('respects extraWriteAllow + extraWriteDeny', () => {
|
|
83
|
-
const profile = buildSeatbeltProfile({
|
|
84
|
-
agentDir: '/a',
|
|
85
|
-
workspaceRoot: '/b',
|
|
86
|
-
homedir: '/h',
|
|
87
|
-
extraWriteAllow: ['/tmp/lyra-test-sandbox-XYZ'],
|
|
88
|
-
extraWriteDeny: ['/Users/h/Documents/sensitive'],
|
|
89
|
-
})
|
|
90
|
-
expect(profile).toContain('(allow file-write* (subpath "/tmp/lyra-test-sandbox-XYZ"))')
|
|
91
|
-
expect(profile).toContain('(deny file-write* (subpath "/Users/h/Documents/sensitive"))')
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
test('escapes embedded double quotes + backslashes in paths', () => {
|
|
95
|
-
const profile = buildSeatbeltProfile({
|
|
96
|
-
agentDir: '/path/with"quote',
|
|
97
|
-
workspaceRoot: '/path\\with\\backslash',
|
|
98
|
-
homedir: '/h',
|
|
99
|
-
})
|
|
100
|
-
expect(profile).toContain('/path/with\\"quote')
|
|
101
|
-
expect(profile).toContain('/path\\\\with\\\\backslash')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
// Property-style fuzz of the escape function: every input must produce a
|
|
105
|
-
// syntactically valid (subpath "X") string — no unescaped backslashes/quotes/
|
|
106
|
-
// newlines that would prematurely close the literal or break SBPL parsing.
|
|
107
|
-
// The actual exploit risk is near-zero (operators don't trick themselves) but
|
|
108
|
-
// the cost of a clean test is one regex check per scenario.
|
|
109
|
-
test('escapes pathological inputs cleanly: nested quotes, sbpl operators, unicode', () => {
|
|
110
|
-
const cases: Array<[string, string]> = [
|
|
111
|
-
// Bare close-paren that could escape an SBPL form (must remain inside the literal)
|
|
112
|
-
['/tmp/test)', '/tmp/test)'],
|
|
113
|
-
// Embedded "(allow file-write*" attempt to inject a new rule
|
|
114
|
-
[
|
|
115
|
-
'/tmp/" (allow file-write* (subpath "/etc"))',
|
|
116
|
-
'/tmp/\\" (allow file-write* (subpath \\"/etc\\"))',
|
|
117
|
-
],
|
|
118
|
-
// Nested quotes
|
|
119
|
-
['/path"with"many"quotes', '/path\\"with\\"many\\"quotes'],
|
|
120
|
-
// Backslash-quote pair
|
|
121
|
-
['/x\\"y', '/x\\\\\\"y'],
|
|
122
|
-
// Carriage return / form feed
|
|
123
|
-
['/x\ny', '/x y'], // newline becomes space
|
|
124
|
-
// Unicode emoji (valid path component on macOS)
|
|
125
|
-
['/Users/alice/\u{1F600}-projects', '/Users/alice/\u{1F600}-projects'],
|
|
126
|
-
// SBPL pattern delimiters embedded
|
|
127
|
-
['/path/#(literal)', '/path/#(literal)'],
|
|
128
|
-
]
|
|
129
|
-
for (const [input, expectedFragment] of cases) {
|
|
130
|
-
const profile = buildSeatbeltProfile({
|
|
131
|
-
agentDir: input,
|
|
132
|
-
workspaceRoot: '/w',
|
|
133
|
-
homedir: '/h',
|
|
134
|
-
})
|
|
135
|
-
expect(profile).toContain(`(subpath "${expectedFragment}")`)
|
|
136
|
-
// After escape + concat, no unbalanced double quote inside (subpath "..."):
|
|
137
|
-
// every non-escaped " inside the literal would prematurely close it.
|
|
138
|
-
const subpathPattern = /\(subpath\s+"((?:\\.|[^"\\])*)"\)/g
|
|
139
|
-
// Strip well-formed (subpath "...") instances; remaining text shouldn't
|
|
140
|
-
// contain a stray (subpath ".. " missing its terminator.
|
|
141
|
-
const stripped = profile.replace(subpathPattern, '<<subpath>>')
|
|
142
|
-
expect(stripped).not.toContain('(subpath "')
|
|
143
|
-
}
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
test('re-allows agentDir AFTER the broad ~/.lyra deny so lyra state stays writable', () => {
|
|
147
|
-
const profile = buildSeatbeltProfile({
|
|
148
|
-
agentDir: '/Users/test/.lyra/agents/abc',
|
|
149
|
-
workspaceRoot: '/w',
|
|
150
|
-
homedir: '/Users/test',
|
|
151
|
-
})
|
|
152
|
-
const agentAllowIdx = profile.lastIndexOf(
|
|
153
|
-
'(allow file-write* (subpath "/Users/test/.lyra/agents/abc"))',
|
|
154
|
-
)
|
|
155
|
-
const lyraDenyIdx = profile.indexOf('(deny file-write* (subpath "/Users/test/.lyra"))')
|
|
156
|
-
expect(agentAllowIdx).toBeGreaterThan(-1)
|
|
157
|
-
expect(lyraDenyIdx).toBeGreaterThan(-1)
|
|
158
|
-
expect(agentAllowIdx).toBeGreaterThan(lyraDenyIdx)
|
|
159
|
-
})
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
describe('MacOSSandboxExecBackend', () => {
|
|
163
|
-
// sandbox-exec is macOS-only; skip these on Linux CI but assert constructor
|
|
164
|
-
// there too.
|
|
165
|
-
if (process.platform !== 'darwin') {
|
|
166
|
-
test('skipped on non-darwin', () => {
|
|
167
|
-
expect(true).toBe(true)
|
|
168
|
-
})
|
|
169
|
-
return
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
test('constructs with valid opts on darwin', () => {
|
|
173
|
-
const b = new MacOSSandboxExecBackend({
|
|
174
|
-
agentDir: '/tmp/lyra-test-agent',
|
|
175
|
-
workspaceRoot: '/tmp',
|
|
176
|
-
homedir: process.env.HOME ?? '/tmp',
|
|
177
|
-
})
|
|
178
|
-
expect(b.mode).toBe('os')
|
|
179
|
-
expect(b.label).toBe('os:darwin')
|
|
180
|
-
const profile = b.getProfile()
|
|
181
|
-
expect(profile).toContain('(deny default)')
|
|
182
|
-
expect(profile).toContain('(allow file-write* (subpath "/tmp/lyra-test-agent"))')
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
test('wrapSpawn prepends sandbox-exec + profile to argv', async () => {
|
|
186
|
-
const b = new MacOSSandboxExecBackend({
|
|
187
|
-
agentDir: '/tmp/lyra-test-agent',
|
|
188
|
-
workspaceRoot: '/tmp',
|
|
189
|
-
homedir: process.env.HOME ?? '/tmp',
|
|
190
|
-
})
|
|
191
|
-
const out = await b.wrapSpawn({
|
|
192
|
-
command: '/bin/sh',
|
|
193
|
-
args: ['-c', 'echo hi'],
|
|
194
|
-
options: { cwd: '/tmp' },
|
|
195
|
-
})
|
|
196
|
-
expect(out.command).toBe('/usr/bin/sandbox-exec')
|
|
197
|
-
expect(out.args[0]).toBe('-p')
|
|
198
|
-
expect(typeof out.args[1]).toBe('string')
|
|
199
|
-
expect(out.args[1]?.includes('(deny default)')).toBe(true)
|
|
200
|
-
expect(out.args.slice(2)).toEqual(['/bin/sh', '-c', 'echo hi'])
|
|
201
|
-
expect(out.options).toEqual({ cwd: '/tmp' })
|
|
202
|
-
})
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
describe('makeSandboxBackend factory', () => {
|
|
206
|
-
test('mode=none returns LocalBackend on any platform', () => {
|
|
207
|
-
const b = makeSandboxBackend({
|
|
208
|
-
mode: 'none',
|
|
209
|
-
agentDir: '/a',
|
|
210
|
-
workspaceRoot: '/w',
|
|
211
|
-
homedir: '/h',
|
|
212
|
-
platform: 'darwin',
|
|
213
|
-
})
|
|
214
|
-
expect(b.mode).toBe('none')
|
|
215
|
-
const b2 = makeSandboxBackend({
|
|
216
|
-
mode: 'none',
|
|
217
|
-
agentDir: '/a',
|
|
218
|
-
workspaceRoot: '/w',
|
|
219
|
-
homedir: '/h',
|
|
220
|
-
platform: 'linux',
|
|
221
|
-
})
|
|
222
|
-
expect(b2.mode).toBe('none')
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
test('mode=docker constructs DockerBackend if a runtime exists, else falls back', () => {
|
|
226
|
-
let warned = ''
|
|
227
|
-
const b = makeSandboxBackend({
|
|
228
|
-
mode: 'docker',
|
|
229
|
-
agentDir: '/a',
|
|
230
|
-
workspaceRoot: '/w',
|
|
231
|
-
homedir: '/h',
|
|
232
|
-
platform: 'darwin',
|
|
233
|
-
warn: m => {
|
|
234
|
-
warned = m
|
|
235
|
-
},
|
|
236
|
-
})
|
|
237
|
-
// On a developer machine with docker/podman installed, mode === 'docker'.
|
|
238
|
-
// On a stripped CI box with neither, factory falls back to LocalBackend
|
|
239
|
-
// and emits a warning. Either is acceptable for this test.
|
|
240
|
-
if (b.mode === 'docker') {
|
|
241
|
-
expect(b.label.startsWith('docker:') || b.label.startsWith('podman:')).toBe(true)
|
|
242
|
-
} else {
|
|
243
|
-
expect(b.mode).toBe('none')
|
|
244
|
-
expect(warned).toContain('docker')
|
|
245
|
-
}
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
test('mode=docker accepts resource caps + network/runtime overrides without crashing', () => {
|
|
249
|
-
// Wide-input smoke test: factory should accept all DockerBackend opts and
|
|
250
|
-
// construct (or fall back) without throwing, regardless of runtime presence.
|
|
251
|
-
let warned = ''
|
|
252
|
-
const b = makeSandboxBackend({
|
|
253
|
-
mode: 'docker',
|
|
254
|
-
agentDir: '/a',
|
|
255
|
-
workspaceRoot: '/w',
|
|
256
|
-
homedir: '/h',
|
|
257
|
-
platform: 'darwin',
|
|
258
|
-
dockerCpu: 1.5,
|
|
259
|
-
dockerMemoryMb: 4096,
|
|
260
|
-
dockerDiskMb: 51200,
|
|
261
|
-
dockerNoNetwork: true,
|
|
262
|
-
dockerMountWorkspace: false,
|
|
263
|
-
warn: m => {
|
|
264
|
-
warned = m
|
|
265
|
-
},
|
|
266
|
-
})
|
|
267
|
-
if (b.mode === 'docker') {
|
|
268
|
-
expect(b.label.startsWith('docker:') || b.label.startsWith('podman:')).toBe(true)
|
|
269
|
-
} else {
|
|
270
|
-
expect(b.mode).toBe('none')
|
|
271
|
-
expect(warned).toContain('docker')
|
|
272
|
-
}
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
test('mode=os on linux constructs bubblewrap backend if bwrap exists, else falls back', () => {
|
|
276
|
-
let warned = ''
|
|
277
|
-
const b = makeSandboxBackend({
|
|
278
|
-
mode: 'os',
|
|
279
|
-
agentDir: '/tmp/lyra-agent',
|
|
280
|
-
workspaceRoot: '/tmp',
|
|
281
|
-
homedir: '/root',
|
|
282
|
-
platform: 'linux',
|
|
283
|
-
warn: m => {
|
|
284
|
-
warned = m
|
|
285
|
-
},
|
|
286
|
-
})
|
|
287
|
-
// On a Linux machine with bubblewrap installed (apt install bubblewrap),
|
|
288
|
-
// mode === 'os' + label 'os:linux'. On macOS or stripped Linux without
|
|
289
|
-
// bwrap, factory falls back to LocalBackend with a warning. Either is
|
|
290
|
-
// acceptable; this test pins the contract, not the platform.
|
|
291
|
-
if (b.mode === 'os') {
|
|
292
|
-
expect(b.label).toBe('os:linux')
|
|
293
|
-
} else {
|
|
294
|
-
expect(b.mode).toBe('none')
|
|
295
|
-
expect(warned).toContain('bubblewrap')
|
|
296
|
-
}
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
test('mode=os on unknown platform falls back to LocalBackend with warning', () => {
|
|
300
|
-
let warned = ''
|
|
301
|
-
const b = makeSandboxBackend({
|
|
302
|
-
mode: 'os',
|
|
303
|
-
agentDir: '/a',
|
|
304
|
-
workspaceRoot: '/w',
|
|
305
|
-
homedir: '/h',
|
|
306
|
-
platform: 'aix',
|
|
307
|
-
warn: m => {
|
|
308
|
-
warned = m
|
|
309
|
-
},
|
|
310
|
-
})
|
|
311
|
-
expect(b.mode).toBe('none')
|
|
312
|
-
expect(warned).toContain('aix')
|
|
313
|
-
})
|
|
314
|
-
|
|
315
|
-
if (process.platform === 'darwin') {
|
|
316
|
-
test('mode=os on darwin returns MacOSSandboxExecBackend', () => {
|
|
317
|
-
const b = makeSandboxBackend({
|
|
318
|
-
mode: 'os',
|
|
319
|
-
agentDir: '/tmp/lyra-test-agent',
|
|
320
|
-
workspaceRoot: '/tmp',
|
|
321
|
-
homedir: process.env.HOME ?? '/tmp',
|
|
322
|
-
})
|
|
323
|
-
expect(b.mode).toBe('os')
|
|
324
|
-
expect(b.label).toBe('os:darwin')
|
|
325
|
-
})
|
|
326
|
-
}
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
describe('buildBwrapArgs (Linux profile)', () => {
|
|
330
|
-
test('binds agentDir + workspaceRoot writable', () => {
|
|
331
|
-
const args = buildBwrapArgs({
|
|
332
|
-
agentDir: '/home/u/.lyra/agents/abc',
|
|
333
|
-
workspaceRoot: '/home/u/proj',
|
|
334
|
-
homedir: '/home/u',
|
|
335
|
-
})
|
|
336
|
-
expect(args).toContain('--bind')
|
|
337
|
-
expect(args.join(' ')).toContain('/home/u/.lyra/agents/abc /home/u/.lyra/agents/abc')
|
|
338
|
-
expect(args.join(' ')).toContain('/home/u/proj /home/u/proj')
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
test('blocks credential dirs via tmpfs overlay', () => {
|
|
342
|
-
const args = buildBwrapArgs({
|
|
343
|
-
agentDir: '/a',
|
|
344
|
-
workspaceRoot: '/w',
|
|
345
|
-
homedir: '/home/u',
|
|
346
|
-
})
|
|
347
|
-
const argString = args.join(' ')
|
|
348
|
-
expect(argString).toContain('--tmpfs /home/u/.ssh')
|
|
349
|
-
expect(argString).toContain('--tmpfs /home/u/.aws')
|
|
350
|
-
expect(argString).toContain('--tmpfs /home/u/.config/gcloud')
|
|
351
|
-
expect(argString).toContain('--tmpfs /home/u/.gnupg')
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
test('reads of root are allowed via --ro-bind', () => {
|
|
355
|
-
const args = buildBwrapArgs({ agentDir: '/a', workspaceRoot: '/w', homedir: '/h' })
|
|
356
|
-
expect(args.slice(0, 3)).toEqual(['--ro-bind', '/', '/'])
|
|
357
|
-
})
|
|
358
|
-
|
|
359
|
-
test('shares network but unshares everything else', () => {
|
|
360
|
-
const args = buildBwrapArgs({ agentDir: '/a', workspaceRoot: '/w', homedir: '/h' })
|
|
361
|
-
expect(args).toContain('--unshare-all')
|
|
362
|
-
expect(args).toContain('--share-net')
|
|
363
|
-
expect(args).toContain('--die-with-parent')
|
|
364
|
-
expect(args).toContain('--new-session')
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
test('respects extraWriteAllow for sandbox dirs', () => {
|
|
368
|
-
const args = buildBwrapArgs({
|
|
369
|
-
agentDir: '/a',
|
|
370
|
-
workspaceRoot: '/w',
|
|
371
|
-
homedir: '/h',
|
|
372
|
-
extraWriteAllow: ['/tmp/lyra-test-sandbox-XYZ'],
|
|
373
|
-
})
|
|
374
|
-
expect(args.join(' ')).toContain('--bind /tmp/lyra-test-sandbox-XYZ /tmp/lyra-test-sandbox-XYZ')
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
test('respects extraWriteDeny via tmpfs', () => {
|
|
378
|
-
const args = buildBwrapArgs({
|
|
379
|
-
agentDir: '/a',
|
|
380
|
-
workspaceRoot: '/w',
|
|
381
|
-
homedir: '/h',
|
|
382
|
-
extraWriteDeny: ['/home/u/secrets'],
|
|
383
|
-
})
|
|
384
|
-
expect(args.join(' ')).toContain('--tmpfs /home/u/secrets')
|
|
385
|
-
})
|
|
386
|
-
})
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { parseFrontmatter, scanSkills } from './scanner'
|
|
6
|
-
|
|
7
|
-
let scratch: string
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
scratch = await mkdtemp(join(tmpdir(), 'lyra-skills-scanner-'))
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
await rm(scratch, { recursive: true, force: true })
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
async function plant(absDir: string, fm: string, body = '# body\n'): Promise<void> {
|
|
18
|
-
await mkdir(absDir, { recursive: true })
|
|
19
|
-
await writeFile(join(absDir, 'SKILL.md'), `${fm}\n\n${body}`)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('parseFrontmatter', () => {
|
|
23
|
-
it('parses top-level fields', () => {
|
|
24
|
-
const fm = parseFrontmatter('---\nname: foo\ndescription: hi\nversion: 1.0.0\n---\n\nbody')
|
|
25
|
-
expect(fm.name).toBe('foo')
|
|
26
|
-
expect(fm.description).toBe('hi')
|
|
27
|
-
expect(fm.version).toBe('1.0.0')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('parses metadata.filePattern + metadata.bashPattern', () => {
|
|
31
|
-
const raw =
|
|
32
|
-
'---\nname: dogfood\ndescription: tests\nmetadata:\n filePattern: "*.test.ts,*.spec.ts"\n bashPattern: "playwright|jest"\n---\n\nbody'
|
|
33
|
-
const fm = parseFrontmatter(raw)
|
|
34
|
-
expect(fm.filePattern).toBe('*.test.ts,*.spec.ts')
|
|
35
|
-
expect(fm.bashPattern).toBe('playwright|jest')
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('parses argument-hint as argumentHint', () => {
|
|
39
|
-
const fm = parseFrontmatter('---\nname: c\nargument-hint: <message>\n---\n\nbody')
|
|
40
|
-
expect(fm.argumentHint).toBe('<message>')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('returns empty object for non-frontmatter content', () => {
|
|
44
|
-
expect(parseFrontmatter('# just a heading\nblah')).toEqual({})
|
|
45
|
-
})
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
describe('scanSkills', () => {
|
|
49
|
-
it('finds lyra + claude-code skills + claude plugin cache layouts', async () => {
|
|
50
|
-
const lyraSkillsRoot = join(scratch, '.lyra', 'skills')
|
|
51
|
-
const claudeSkillsRoot = join(scratch, '.claude', 'skills')
|
|
52
|
-
const claudePluginsCacheRoot = join(scratch, '.claude', 'plugins', 'cache')
|
|
53
|
-
|
|
54
|
-
await plant(join(lyraSkillsRoot, 'dogfood'), '---\nname: dogfood\ndescription: lyra skill\n---')
|
|
55
|
-
await plant(
|
|
56
|
-
join(claudeSkillsRoot, 'commit'),
|
|
57
|
-
'---\nname: commit\ndescription: claude skill\n---',
|
|
58
|
-
)
|
|
59
|
-
await plant(
|
|
60
|
-
join(claudePluginsCacheRoot, 'awesome', 'pdf', '1.0.0', 'skills', 'extract'),
|
|
61
|
-
'---\nname: extract\ndescription: pdf skill\n---',
|
|
62
|
-
)
|
|
63
|
-
// Plugin with direct SKILL.md (no skills/ subdir)
|
|
64
|
-
await plant(
|
|
65
|
-
join(claudePluginsCacheRoot, 'awesome', 'docx', '1.0.0'),
|
|
66
|
-
'---\nname: docx\ndescription: docx skill\n---',
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
const skills = await scanSkills({
|
|
70
|
-
lyraSkillsRoot,
|
|
71
|
-
lyraPluginsRoot: join(scratch, '.lyra', 'plugins'),
|
|
72
|
-
claudeSkillsRoot,
|
|
73
|
-
claudePluginsCacheRoot,
|
|
74
|
-
importsClaudeCode: true,
|
|
75
|
-
})
|
|
76
|
-
const ids = skills.map(s => s.id).sort()
|
|
77
|
-
expect(ids).toContain('lyra:dogfood')
|
|
78
|
-
expect(ids).toContain('claude-code:commit')
|
|
79
|
-
expect(ids).toContain('claude-plugin:awesome:pdf:extract')
|
|
80
|
-
expect(ids).toContain('claude-plugin:awesome:docx')
|
|
81
|
-
const pdf = skills.find(s => s.id === 'claude-plugin:awesome:pdf:extract')
|
|
82
|
-
expect(pdf?.pluginCoord?.marketplace).toBe('awesome')
|
|
83
|
-
expect(pdf?.pluginCoord?.plugin).toBe('pdf')
|
|
84
|
-
expect(pdf?.pluginCoord?.version).toBe('1.0.0')
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('skips claude paths when imports.claudeCode is false', async () => {
|
|
88
|
-
const claudeSkillsRoot = join(scratch, '.claude', 'skills')
|
|
89
|
-
await plant(join(claudeSkillsRoot, 'foo'), '---\nname: foo\ndescription: x\n---')
|
|
90
|
-
const skills = await scanSkills({
|
|
91
|
-
lyraSkillsRoot: join(scratch, 'doesnotexist'),
|
|
92
|
-
lyraPluginsRoot: join(scratch, 'doesnotexist'),
|
|
93
|
-
claudeSkillsRoot,
|
|
94
|
-
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
95
|
-
importsClaudeCode: false,
|
|
96
|
-
})
|
|
97
|
-
expect(skills).toEqual([])
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('discovers lyra-plugin skills', async () => {
|
|
101
|
-
const lyraPluginsRoot = join(scratch, '.lyra', 'plugins')
|
|
102
|
-
await plant(
|
|
103
|
-
join(lyraPluginsRoot, 'system', 'skills', 'sweep'),
|
|
104
|
-
'---\nname: sweep\ndescription: plugin-sourced skill\n---',
|
|
105
|
-
)
|
|
106
|
-
const skills = await scanSkills({
|
|
107
|
-
lyraSkillsRoot: join(scratch, 'doesnotexist'),
|
|
108
|
-
lyraPluginsRoot,
|
|
109
|
-
claudeSkillsRoot: join(scratch, 'doesnotexist'),
|
|
110
|
-
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
111
|
-
importsClaudeCode: false,
|
|
112
|
-
})
|
|
113
|
-
expect(skills.map(s => s.id)).toEqual(['lyra-plugin:system:sweep'])
|
|
114
|
-
})
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
describe('skills without YAML frontmatter', () => {
|
|
118
|
-
it('still surfaces skills whose SKILL.md has no frontmatter (fallback to dir name + first body line)', async () => {
|
|
119
|
-
const lyraSkillsRoot = join(scratch, '.lyra', 'skills')
|
|
120
|
-
await mkdir(join(lyraSkillsRoot, 'no-fm'), { recursive: true })
|
|
121
|
-
await writeFile(
|
|
122
|
-
join(lyraSkillsRoot, 'no-fm', 'SKILL.md'),
|
|
123
|
-
'# no-fm skill\n\nA skill without yaml frontmatter that should still be discoverable.\n',
|
|
124
|
-
)
|
|
125
|
-
const skills = await scanSkills({
|
|
126
|
-
lyraSkillsRoot,
|
|
127
|
-
lyraPluginsRoot: join(scratch, 'doesnotexist'),
|
|
128
|
-
claudeSkillsRoot: join(scratch, 'doesnotexist'),
|
|
129
|
-
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
130
|
-
importsClaudeCode: false,
|
|
131
|
-
})
|
|
132
|
-
const found = skills.find(s => s.id === 'lyra:no-fm')
|
|
133
|
-
expect(found).toBeDefined()
|
|
134
|
-
expect(found!.name).toBe('no-fm')
|
|
135
|
-
expect(found!.description).toContain('A skill without yaml frontmatter')
|
|
136
|
-
})
|
|
137
|
-
})
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { matchBashPattern, matchFilePattern, matchTriggers } from './triggers'
|
|
3
|
-
import type { SkillRef } from './types'
|
|
4
|
-
|
|
5
|
-
describe('matchFilePattern', () => {
|
|
6
|
-
it('matches single glob against basename', () => {
|
|
7
|
-
expect(matchFilePattern('*.test.ts', '/tmp/foo.test.ts')).toBe(true)
|
|
8
|
-
expect(matchFilePattern('*.test.ts', '/tmp/foo.ts')).toBe(false)
|
|
9
|
-
})
|
|
10
|
-
it('matches comma-separated globs', () => {
|
|
11
|
-
expect(matchFilePattern('*.test.ts,*.spec.ts', '/tmp/foo.spec.ts')).toBe(true)
|
|
12
|
-
expect(matchFilePattern('*.test.ts,*.spec.ts', '/tmp/foo.md')).toBe(false)
|
|
13
|
-
})
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
describe('matchBashPattern', () => {
|
|
17
|
-
it('matches regex anywhere in command', () => {
|
|
18
|
-
expect(matchBashPattern('playwright|jest', 'bun run jest tests/')).toBe(true)
|
|
19
|
-
expect(matchBashPattern('playwright|jest', 'go test ./...')).toBe(false)
|
|
20
|
-
})
|
|
21
|
-
it('returns false on invalid regex', () => {
|
|
22
|
-
expect(matchBashPattern('[invalid', 'foo')).toBe(false)
|
|
23
|
-
})
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
function ref(filePattern?: string, bashPattern?: string): SkillRef {
|
|
27
|
-
return {
|
|
28
|
-
id: 'lyra:t',
|
|
29
|
-
name: 't',
|
|
30
|
-
description: '',
|
|
31
|
-
path: '/tmp/SKILL.md',
|
|
32
|
-
source: 'lyra',
|
|
33
|
-
frontmatter: {
|
|
34
|
-
name: 't',
|
|
35
|
-
description: '',
|
|
36
|
-
...(filePattern ? { filePattern } : {}),
|
|
37
|
-
...(bashPattern ? { bashPattern } : {}),
|
|
38
|
-
},
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
describe('matchTriggers', () => {
|
|
43
|
-
it('matches fs.write paths against filePattern', () => {
|
|
44
|
-
const skills = [ref('*.test.ts')]
|
|
45
|
-
const out = matchTriggers(
|
|
46
|
-
{ name: 'fs.write', args: { path: '/tmp/foo.test.ts', text: 'x' } },
|
|
47
|
-
skills,
|
|
48
|
-
)
|
|
49
|
-
expect(out).toHaveLength(1)
|
|
50
|
-
expect(out[0]!.reason).toBe('filePattern')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('matches shell.run commands against bashPattern', () => {
|
|
54
|
-
const skills = [ref(undefined, 'jest')]
|
|
55
|
-
const out = matchTriggers(
|
|
56
|
-
{ name: 'shell.run', args: { command: 'bun run jest tests/' } },
|
|
57
|
-
skills,
|
|
58
|
-
)
|
|
59
|
-
expect(out).toHaveLength(1)
|
|
60
|
-
expect(out[0]!.reason).toBe('bashPattern')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('returns empty when nothing matches', () => {
|
|
64
|
-
const skills = [ref('*.spec.ts'), ref(undefined, 'rspec')]
|
|
65
|
-
const out = matchTriggers(
|
|
66
|
-
{ name: 'fs.write', args: { path: '/tmp/foo.md', text: 'x' } },
|
|
67
|
-
skills,
|
|
68
|
-
)
|
|
69
|
-
expect(out).toEqual([])
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('ignores non-matching tool names', () => {
|
|
73
|
-
const skills = [ref('*.md')]
|
|
74
|
-
const out = matchTriggers({ name: 'memory.save', args: { path: '/tmp/foo.md' } }, skills)
|
|
75
|
-
expect(out).toEqual([])
|
|
76
|
-
})
|
|
77
|
-
})
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { decrypt, encrypt, packEnvelope, unpackEnvelope } from './encryption'
|
|
3
|
-
|
|
4
|
-
describe('encryption', () => {
|
|
5
|
-
test('round-trips a short message', () => {
|
|
6
|
-
const plaintext = new TextEncoder().encode('hello lyra')
|
|
7
|
-
const env = encrypt(plaintext, 'testpass1234')
|
|
8
|
-
const out = decrypt(env, 'testpass1234')
|
|
9
|
-
expect(new TextDecoder().decode(out)).toBe('hello lyra')
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
test('wrong passphrase fails', () => {
|
|
13
|
-
const env = encrypt(new TextEncoder().encode('secret'), 'right-password')
|
|
14
|
-
expect(() => decrypt(env, 'wrong-password')).toThrow()
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
test('packs + unpacks round-trip', () => {
|
|
18
|
-
const env = encrypt(new TextEncoder().encode('pack me'), 'pw')
|
|
19
|
-
const packed = packEnvelope(env)
|
|
20
|
-
const unpacked = unpackEnvelope(packed)
|
|
21
|
-
expect(Array.from(unpacked.salt)).toEqual(Array.from(env.salt))
|
|
22
|
-
expect(Array.from(unpacked.iv)).toEqual(Array.from(env.iv))
|
|
23
|
-
expect(Array.from(unpacked.tag)).toEqual(Array.from(env.tag))
|
|
24
|
-
expect(Array.from(unpacked.ciphertext)).toEqual(Array.from(env.ciphertext))
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
test('unpack throws on truncated input', () => {
|
|
28
|
-
expect(() => unpackEnvelope(new Uint8Array(20))).toThrow('envelope shorter than header')
|
|
29
|
-
})
|
|
30
|
-
})
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { SqliteStorage } from './sqlite'
|
|
3
|
-
|
|
4
|
-
const enc = (s: string) => new TextEncoder().encode(s)
|
|
5
|
-
const dec = (b: Uint8Array | null) => (b ? new TextDecoder().decode(b) : null)
|
|
6
|
-
|
|
7
|
-
describe('SqliteStorage', () => {
|
|
8
|
-
it('round-trips a content-addressed blob', async () => {
|
|
9
|
-
const s = new SqliteStorage(':memory:')
|
|
10
|
-
const cid = await s.putBlob(enc('hello sui'))
|
|
11
|
-
expect(cid).toMatch(/^0x[0-9a-f]{64}$/)
|
|
12
|
-
// Same bytes -> same CID (content addressed, idempotent).
|
|
13
|
-
expect(await s.putBlob(enc('hello sui'))).toBe(cid)
|
|
14
|
-
expect(dec(await s.getBlob(cid))).toBe('hello sui')
|
|
15
|
-
expect(await s.getBlob('0xdeadbeef')).toBeNull()
|
|
16
|
-
s.close()
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('stores + overwrites KV and appends to the log', async () => {
|
|
20
|
-
const s = new SqliteStorage(':memory:')
|
|
21
|
-
await s.putKV('stream1', 'k', enc('v1'))
|
|
22
|
-
await s.putKV('stream1', 'k', enc('v2'))
|
|
23
|
-
expect(dec(await s.getKV('stream1', 'k'))).toBe('v2')
|
|
24
|
-
expect(await s.getKV('stream1', 'missing')).toBeNull()
|
|
25
|
-
const cid = await s.appendLog('stream1', enc('entry'))
|
|
26
|
-
expect(cid).toMatch(/^0x[0-9a-f]{64}$/)
|
|
27
|
-
s.close()
|
|
28
|
-
})
|
|
29
|
-
})
|