forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexternalAdapters.test.ts
More file actions
217 lines (197 loc) · 8.52 KB
/
Copy pathexternalAdapters.test.ts
File metadata and controls
217 lines (197 loc) · 8.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import { spawn, execFile } from 'child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OpenClawRuntimeAdapter } from './openclaw'
import { HermesRuntimeAdapter } from './hermes'
import { CodexRuntimeAdapter } from './codex'
import { createMockProcess, respond, scriptJsonRpc, stubPlatform } from './acp.testkit'
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process')
return {
...actual,
spawn: vi.fn(),
execFile: vi.fn()
}
})
const ADAPTER_ENV_VARS = [
'OMI_OPENCLAW_ADAPTER_COMMAND',
'OMI_HERMES_ADAPTER_COMMAND',
'OMI_CODEX_ADAPTER_COMMAND'
] as const
describe('external adapter subprocesses (OpenClaw / Hermes / Codex)', () => {
beforeEach(() => {
vi.mocked(spawn).mockReset()
vi.mocked(execFile).mockReset()
// stop() must terminate on whichever platform the suite runs on:
// - win32 path calls execFile('taskkill', …, cb) — fail the callback so the
// proc.kill() fallback (which emits 'exit' on the mock) runs;
// - POSIX path calls process.kill(-pid) — throw so the same fallback runs
// (and so the test can never signal a REAL process group by accident).
vi.mocked(execFile).mockImplementation(((...args: unknown[]) => {
const callback = args.find((arg) => typeof arg === 'function') as
| ((error: Error | null) => void)
| undefined
callback?.(new Error('taskkill unavailable in tests'))
return undefined as never
}) as never)
vi.spyOn(process, 'kill').mockImplementation(() => {
throw new Error('ESRCH')
})
for (const key of ADAPTER_ENV_VARS) delete process.env[key]
})
afterEach(() => {
vi.restoreAllMocks()
for (const key of ADAPTER_ENV_VARS) delete process.env[key]
delete process.env.FAKE_SECRET_TOKEN
delete process.env.OMI_AUTH_TOKEN
delete process.env.HERMES_HOME
delete process.env.OPENAI_API_KEY
delete process.env.HTTPS_PROXY
})
it('refuses to start without a configured command, activates from the env var', async () => {
const adapter = new OpenClawRuntimeAdapter()
await expect(adapter.start()).rejects.toThrow(
'openclaw adapter requires OMI_OPENCLAW_ADAPTER_COMMAND'
)
const proc = createMockProcess()
vi.mocked(spawn).mockReturnValue(proc as never)
process.env.OMI_OPENCLAW_ADAPTER_COMMAND = 'openclaw acp'
await adapter.start()
expect(spawn).toHaveBeenCalledWith(
'openclaw acp',
expect.objectContaining({
shell: true,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
env: expect.objectContaining({ OMI_ADAPTER_ID: 'openclaw' })
})
)
await adapter.stop()
})
it('never leaks host secrets into the external env, but forwards adapter-specific vars', async () => {
process.env.FAKE_SECRET_TOKEN = 'super-secret'
process.env.OMI_AUTH_TOKEN = 'firebase-token'
process.env.HERMES_HOME = 'C:/hermes-home'
process.env.OPENAI_API_KEY = 'sk-test-openai-key-123456'
process.env.HTTPS_PROXY = 'http://alice:s3cr3t@proxy:3128'
const hermesProc = createMockProcess()
vi.mocked(spawn).mockReturnValue(hermesProc as never)
const hermes = new HermesRuntimeAdapter({ command: 'hermes acp' })
await hermes.start()
const hermesEnv = (vi.mocked(spawn).mock.calls[0][1] as unknown as { env: NodeJS.ProcessEnv })
.env
expect(hermesEnv.FAKE_SECRET_TOKEN).toBeUndefined()
expect(hermesEnv.OMI_AUTH_TOKEN).toBeUndefined()
// Hermes-specific passthrough
expect(hermesEnv.HERMES_HOME).toBe('C:/hermes-home')
// Not Hermes's to receive
expect(hermesEnv.OPENAI_API_KEY).toBeUndefined()
// Proxy credentials are stripped before forwarding
expect(hermesEnv.HTTPS_PROXY).toBe('http://proxy:3128/')
await hermes.stop()
// Non-URL proxy values (no scheme) must still lose their userinfo.
vi.mocked(spawn).mockReset()
process.env.HTTPS_PROXY = 'alice:s3cr3t@proxy:3128'
const hermes2Proc = createMockProcess()
vi.mocked(spawn).mockReturnValue(hermes2Proc as never)
const hermes2 = new HermesRuntimeAdapter({ command: 'hermes acp' })
await hermes2.start()
const hermes2Env = (vi.mocked(spawn).mock.calls[0][1] as unknown as { env: NodeJS.ProcessEnv })
.env
expect(hermes2Env.HTTPS_PROXY).toBe('proxy:3128')
await hermes2.stop()
vi.mocked(spawn).mockReset()
const codexProc = createMockProcess()
vi.mocked(spawn).mockReturnValue(codexProc as never)
const codex = new CodexRuntimeAdapter({ command: 'npx @agentclientprotocol/codex-acp' })
await codex.start()
const codexEnv = (vi.mocked(spawn).mock.calls[0][1] as unknown as { env: NodeJS.ProcessEnv })
.env
// Codex-specific passthrough: the bridge needs the OpenAI/Codex key to auth.
expect(codexEnv.OPENAI_API_KEY).toBe('sk-test-openai-key-123456')
expect(codexEnv.HERMES_HOME).toBeUndefined()
expect(codexEnv.FAKE_SECRET_TOKEN).toBeUndefined()
await codex.stop()
})
it('kills the whole process tree with taskkill on Windows', async () => {
const restorePlatform = stubPlatform('win32')
try {
const proc = createMockProcess()
vi.mocked(spawn).mockReturnValue(proc as never)
vi.mocked(execFile).mockImplementation(((...args: unknown[]) => {
const callback = args.find((arg) => typeof arg === 'function') as
| ((error: Error | null) => void)
| undefined
// taskkill terminates the tree → the child exits.
proc.emit('exit', 0)
callback?.(null)
return proc as never
}) as never)
const adapter = new OpenClawRuntimeAdapter({ command: 'openclaw acp' })
await adapter.start()
await adapter.stop()
// windowsHide so the console-subsystem taskkill.exe never flashes a window.
expect(execFile).toHaveBeenCalledWith(
'taskkill',
['/pid', String(proc.pid), '/t', '/f'],
expect.objectContaining({ windowsHide: true }),
expect.any(Function)
)
} finally {
restorePlatform()
}
})
it('kills the detached process group on POSIX platforms', async () => {
const restorePlatform = stubPlatform('linux')
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
const proc = createMockProcess()
vi.mocked(spawn).mockReturnValue(proc as never)
const adapter = new HermesRuntimeAdapter({ command: 'hermes acp' })
await adapter.start()
// Group kill signal delivery is external to the mock — emit exit manually.
setImmediate(() => proc.emit('exit', 0))
await adapter.stop()
expect(killSpy).toHaveBeenCalledWith(-proc.pid, 'SIGTERM')
expect(vi.mocked(spawn).mock.calls[0][1]).toMatchObject({ detached: true })
} finally {
restorePlatform()
}
})
it('applies OpenClaw session semantics: empty MCP servers and no set_model', async () => {
const proc = createMockProcess()
vi.mocked(spawn).mockReturnValue(proc as never)
const adapter = new OpenClawRuntimeAdapter({ command: 'openclaw acp' })
expect(adapter.adapterId).toBe('openclaw')
expect(adapter.capabilities.supportsModelSwitching).toBe(false)
expect(adapter.capabilities.supportsNativeResume).toBe(true)
// OpenClaw rejects per-session MCP servers: even when the caller passes
// some, session/new must go out with an empty list — and no
// session/set_model despite a model being requested.
const methods: string[] = []
scriptJsonRpc(proc, (message) => {
if (message.method && message.id !== undefined) {
methods.push(message.method)
if (message.method === 'initialize') respond(proc, message.id, { protocolVersion: 1 })
if (message.method === 'session/new') {
expect(message.params?.mcpServers).toEqual([])
respond(proc, message.id, { sessionId: 'openclaw-native-1' })
}
}
})
const binding = await adapter.openBinding({
sessionId: 'omi-session',
cwd: 'C:/work',
model: 'some-model',
mcpServers: [{ name: 'omi', url: 'http://localhost' }]
})
expect(binding.model).toBeUndefined() // set_model unsupported → not applied
expect(methods).toEqual(['initialize', 'session/new'])
await adapter.stop()
})
it('treats Codex sessions as process-local until verified', () => {
const adapter = new CodexRuntimeAdapter({ command: 'codex-acp' })
expect(adapter.adapterId).toBe('codex')
expect(adapter.capabilities.supportsNativeResume).toBe(false)
expect(adapter.capabilities.requiresPinnedWorker).toBe(true)
})
})