@junwu168/openshell 0.1.2 → 0.1.4
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/cli/openshell.js +4 -0
- package/dist/core/audit/log-store.js +1 -1
- package/dist/core/orchestrator.d.ts +2 -2
- package/dist/core/orchestrator.js +3 -3
- package/dist/core/result.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/opencode/plugin.d.ts +1 -1
- package/dist/opencode/plugin.js +8 -8
- package/package.json +6 -1
- package/.claude/settings.local.json +0 -15
- package/bun.lock +0 -368
- package/docs/superpowers/notes/2026-03-25-opencode-remote-tools-handoff.md +0 -81
- package/docs/superpowers/notes/2026-03-26-openshell-pre-release-review.md +0 -174
- package/docs/superpowers/plans/2026-03-25-opencode-remote-tools.md +0 -1656
- package/docs/superpowers/plans/2026-03-25-server-registry-cli.md +0 -54
- package/docs/superpowers/plans/2026-03-26-config-backed-credential-registry.md +0 -494
- package/docs/superpowers/plans/2026-03-26-openshell-release-prep.md +0 -639
- package/docs/superpowers/specs/2026-03-25-opencode-remote-tools-design.md +0 -378
- package/docs/superpowers/specs/2026-03-26-config-backed-credential-registry-design.md +0 -272
- package/docs/superpowers/specs/2026-03-26-openshell-release-prep-design.md +0 -197
- package/examples/opencode-local/opencode.json +0 -19
- package/scripts/openshell.ts +0 -3
- package/scripts/server-registry.ts +0 -3
- package/src/cli/openshell.ts +0 -60
- package/src/cli/server-registry.ts +0 -476
- package/src/core/audit/git-audit-repo.ts +0 -42
- package/src/core/audit/log-store.ts +0 -20
- package/src/core/audit/redact.ts +0 -4
- package/src/core/contracts.ts +0 -51
- package/src/core/orchestrator.ts +0 -1082
- package/src/core/patch.ts +0 -11
- package/src/core/paths.ts +0 -32
- package/src/core/policy.ts +0 -30
- package/src/core/registry/server-registry.ts +0 -505
- package/src/core/result.ts +0 -16
- package/src/core/ssh/ssh-runtime.ts +0 -355
- package/src/index.ts +0 -3
- package/src/opencode/plugin.ts +0 -242
- package/src/product/install.ts +0 -43
- package/src/product/opencode-config.ts +0 -118
- package/src/product/uninstall.ts +0 -47
- package/src/product/workspace-tracker.ts +0 -69
- package/tests/integration/fake-ssh-server.ts +0 -97
- package/tests/integration/install-lifecycle.test.ts +0 -85
- package/tests/integration/orchestrator.test.ts +0 -767
- package/tests/integration/ssh-runtime.test.ts +0 -122
- package/tests/unit/audit.test.ts +0 -221
- package/tests/unit/build-layout.test.ts +0 -28
- package/tests/unit/opencode-config.test.ts +0 -100
- package/tests/unit/opencode-plugin.test.ts +0 -358
- package/tests/unit/openshell-cli.test.ts +0 -60
- package/tests/unit/paths.test.ts +0 -64
- package/tests/unit/plugin-export.test.ts +0 -10
- package/tests/unit/policy.test.ts +0 -53
- package/tests/unit/release-docs.test.ts +0 -31
- package/tests/unit/result.test.ts +0 -28
- package/tests/unit/server-registry-cli.test.ts +0 -673
- package/tests/unit/server-registry.test.ts +0 -452
- package/tests/unit/workspace-tracker.test.ts +0 -57
- package/tsconfig.json +0 -14
|
@@ -1,767 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
3
|
-
import { join } from "node:path"
|
|
4
|
-
import { tmpdir } from "node:os"
|
|
5
|
-
import { createOrchestrator } from "../../src/core/orchestrator"
|
|
6
|
-
import type { ResolvedServerRecord, ServerRecord } from "../../src/core/registry/server-registry"
|
|
7
|
-
|
|
8
|
-
const createServerRecord = (id: string): ServerRecord => ({
|
|
9
|
-
id,
|
|
10
|
-
host: `${id}.example`,
|
|
11
|
-
port: 22,
|
|
12
|
-
username: "open",
|
|
13
|
-
auth: {
|
|
14
|
-
kind: "password",
|
|
15
|
-
secret: "openpass",
|
|
16
|
-
},
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
const createResolvedServerRecord = (
|
|
20
|
-
overrides: Partial<ResolvedServerRecord> & Pick<ResolvedServerRecord, "scope">,
|
|
21
|
-
): ResolvedServerRecord => ({
|
|
22
|
-
id: "prod-a",
|
|
23
|
-
host: "prod-a.example",
|
|
24
|
-
port: 22,
|
|
25
|
-
username: "open",
|
|
26
|
-
auth: {
|
|
27
|
-
kind: "password",
|
|
28
|
-
secret: "openpass",
|
|
29
|
-
},
|
|
30
|
-
workspaceRoot: "/repo",
|
|
31
|
-
...overrides,
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
const createStubSsh = (overrides: Partial<ReturnType<typeof createStubSshBase>> = {}) => ({
|
|
35
|
-
...createStubSshBase(),
|
|
36
|
-
...overrides,
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
const createStubSshBase = () => ({
|
|
40
|
-
exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
41
|
-
readFile: async () => "",
|
|
42
|
-
writeFile: async () => {},
|
|
43
|
-
listDir: async () => [] as string[] | { name: string; longname: string }[],
|
|
44
|
-
stat: async () => ({ size: 0, mode: 0o644, isFile: false, isDirectory: false }),
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const createStubAudit = (overrides: Partial<ReturnType<typeof createStubAuditBase>> = {}) => ({
|
|
48
|
-
...createStubAuditBase(),
|
|
49
|
-
...overrides,
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
const createStubAuditBase = () => ({
|
|
53
|
-
preflightLog: async () => {},
|
|
54
|
-
appendLog: async () => {},
|
|
55
|
-
preflightSnapshots: async () => {},
|
|
56
|
-
captureSnapshots: async () => {},
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
describe("tool orchestrator", () => {
|
|
60
|
-
test("auto-allows safe remote exec commands", async () => {
|
|
61
|
-
const logs: Record<string, unknown>[] = []
|
|
62
|
-
const orchestrator = createOrchestrator({
|
|
63
|
-
registry: { list: async () => [], resolve: async () => createServerRecord("prod-a") },
|
|
64
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
65
|
-
ssh: createStubSsh({
|
|
66
|
-
exec: async (_server, _command, options) => ({
|
|
67
|
-
stdout: options?.cwd ?? "",
|
|
68
|
-
stderr: "",
|
|
69
|
-
exitCode: options?.timeout ?? 0,
|
|
70
|
-
}),
|
|
71
|
-
}),
|
|
72
|
-
audit: createStubAudit({
|
|
73
|
-
appendLog: async (entry) => {
|
|
74
|
-
logs.push(entry)
|
|
75
|
-
},
|
|
76
|
-
}),
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
const result = await orchestrator.remoteExec({
|
|
80
|
-
server: "prod-a",
|
|
81
|
-
command: "cat /etc/hosts",
|
|
82
|
-
cwd: "/etc",
|
|
83
|
-
timeout: 5000,
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
expect(result).toMatchObject({ status: "ok", data: { stdout: "/etc", exitCode: 5000 } })
|
|
87
|
-
expect(logs).toEqual([
|
|
88
|
-
expect.objectContaining({
|
|
89
|
-
tool: "remote_exec",
|
|
90
|
-
server: "prod-a",
|
|
91
|
-
approvalStatus: "not-required",
|
|
92
|
-
policyDecision: "auto-allow",
|
|
93
|
-
}),
|
|
94
|
-
])
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
test("executes approval-required remote exec commands with host-managed approval metadata", async () => {
|
|
98
|
-
const logs: Record<string, unknown>[] = []
|
|
99
|
-
const orchestrator = createOrchestrator({
|
|
100
|
-
registry: { list: async () => [], resolve: async () => createServerRecord("prod-a") },
|
|
101
|
-
policy: { classifyRemoteExec: () => ({ decision: "approval-required", reason: "write" }) },
|
|
102
|
-
ssh: createStubSsh({
|
|
103
|
-
exec: async () => ({ stdout: "ok", stderr: "", exitCode: 0 }),
|
|
104
|
-
}),
|
|
105
|
-
audit: createStubAudit({
|
|
106
|
-
appendLog: async (entry) => {
|
|
107
|
-
logs.push(entry)
|
|
108
|
-
},
|
|
109
|
-
}),
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
const result = await orchestrator.remoteExec({
|
|
113
|
-
server: "prod-a",
|
|
114
|
-
command: "touch /tmp/file",
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
expect(result).toMatchObject({ status: "ok", data: { stdout: "ok", exitCode: 0 } })
|
|
118
|
-
expect(logs).toEqual([
|
|
119
|
-
expect.objectContaining({
|
|
120
|
-
tool: "remote_exec",
|
|
121
|
-
server: "prod-a",
|
|
122
|
-
approvalStatus: "host-managed-required",
|
|
123
|
-
approvalRequired: true,
|
|
124
|
-
policyDecision: "approval-required",
|
|
125
|
-
}),
|
|
126
|
-
])
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test("remote_exec reads workspace-relative privateKeyPath values at runtime", async () => {
|
|
130
|
-
const tempDir = await mkdtemp(join(tmpdir(), "open-code-auth-"))
|
|
131
|
-
const workspaceRoot = join(tempDir, "repo")
|
|
132
|
-
const keyPath = join(workspaceRoot, "keys", "id_rsa")
|
|
133
|
-
const execCalls: Array<{ connection: Record<string, unknown>; command: string }> = []
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
await mkdir(join(workspaceRoot, "keys"), { recursive: true })
|
|
137
|
-
await writeFile(keyPath, "PRIVATE KEY")
|
|
138
|
-
|
|
139
|
-
const orchestrator = createOrchestrator({
|
|
140
|
-
registry: {
|
|
141
|
-
list: async () => [],
|
|
142
|
-
resolve: async () =>
|
|
143
|
-
createResolvedServerRecord({
|
|
144
|
-
scope: "workspace",
|
|
145
|
-
workspaceRoot,
|
|
146
|
-
auth: {
|
|
147
|
-
kind: "privateKey",
|
|
148
|
-
privateKeyPath: "keys/id_rsa",
|
|
149
|
-
},
|
|
150
|
-
}),
|
|
151
|
-
},
|
|
152
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
153
|
-
ssh: createStubSsh({
|
|
154
|
-
exec: async (connection, command) => {
|
|
155
|
-
execCalls.push({ connection: connection as Record<string, unknown>, command })
|
|
156
|
-
return { stdout: "ok", stderr: "", exitCode: 0 }
|
|
157
|
-
},
|
|
158
|
-
}),
|
|
159
|
-
audit: createStubAudit(),
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
const result = await orchestrator.remoteExec({
|
|
163
|
-
server: "prod-a",
|
|
164
|
-
command: "cat /etc/hosts",
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
expect(result).toMatchObject({ status: "ok", data: { stdout: "ok", exitCode: 0 } })
|
|
168
|
-
expect(execCalls).toHaveLength(1)
|
|
169
|
-
expect(execCalls[0]?.connection).toMatchObject({
|
|
170
|
-
host: "prod-a.example",
|
|
171
|
-
username: "open",
|
|
172
|
-
privateKey: "PRIVATE KEY",
|
|
173
|
-
})
|
|
174
|
-
} finally {
|
|
175
|
-
await rm(tempDir, { recursive: true, force: true })
|
|
176
|
-
}
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
test("remote_exec forwards privateKey passphrase into ssh2 connect config", async () => {
|
|
180
|
-
const tempDir = await mkdtemp(join(tmpdir(), "open-code-auth-"))
|
|
181
|
-
const workspaceRoot = join(tempDir, "repo")
|
|
182
|
-
const keyPath = join(workspaceRoot, "keys", "id_rsa")
|
|
183
|
-
const execCalls: Array<{ connection: Record<string, unknown>; command: string }> = []
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
await mkdir(join(workspaceRoot, "keys"), { recursive: true })
|
|
187
|
-
await writeFile(keyPath, "PRIVATE KEY")
|
|
188
|
-
|
|
189
|
-
const orchestrator = createOrchestrator({
|
|
190
|
-
registry: {
|
|
191
|
-
list: async () => [],
|
|
192
|
-
resolve: async () =>
|
|
193
|
-
createResolvedServerRecord({
|
|
194
|
-
scope: "workspace",
|
|
195
|
-
workspaceRoot,
|
|
196
|
-
auth: {
|
|
197
|
-
kind: "privateKey",
|
|
198
|
-
privateKeyPath: "keys/id_rsa",
|
|
199
|
-
passphrase: "key-passphrase",
|
|
200
|
-
},
|
|
201
|
-
}),
|
|
202
|
-
},
|
|
203
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
204
|
-
ssh: createStubSsh({
|
|
205
|
-
exec: async (connection, command) => {
|
|
206
|
-
execCalls.push({ connection: connection as Record<string, unknown>, command })
|
|
207
|
-
return { stdout: "ok", stderr: "", exitCode: 0 }
|
|
208
|
-
},
|
|
209
|
-
}),
|
|
210
|
-
audit: createStubAudit(),
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
const result = await orchestrator.remoteExec({
|
|
214
|
-
server: "prod-a",
|
|
215
|
-
command: "cat /etc/hosts",
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
expect(result).toMatchObject({ status: "ok", data: { stdout: "ok", exitCode: 0 } })
|
|
219
|
-
expect(execCalls).toHaveLength(1)
|
|
220
|
-
expect(execCalls[0]?.connection).toMatchObject({
|
|
221
|
-
host: "prod-a.example",
|
|
222
|
-
username: "open",
|
|
223
|
-
privateKey: "PRIVATE KEY",
|
|
224
|
-
passphrase: "key-passphrase",
|
|
225
|
-
})
|
|
226
|
-
} finally {
|
|
227
|
-
await rm(tempDir, { recursive: true, force: true })
|
|
228
|
-
}
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
test("remote_exec forwards certificate passphrase into ssh2 connect config", async () => {
|
|
232
|
-
const tempDir = await mkdtemp(join(tmpdir(), "open-code-auth-"))
|
|
233
|
-
const workspaceRoot = join(tempDir, "repo")
|
|
234
|
-
const certificatePath = join(workspaceRoot, "certs", "client-cert.pem")
|
|
235
|
-
const privateKeyPath = join(workspaceRoot, "keys", "client-key.pem")
|
|
236
|
-
const execCalls: Array<{ connection: Record<string, unknown>; command: string }> = []
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
await mkdir(join(workspaceRoot, "certs"), { recursive: true })
|
|
240
|
-
await mkdir(join(workspaceRoot, "keys"), { recursive: true })
|
|
241
|
-
await writeFile(certificatePath, "CERTIFICATE")
|
|
242
|
-
await writeFile(privateKeyPath, "PRIVATE KEY")
|
|
243
|
-
|
|
244
|
-
const orchestrator = createOrchestrator({
|
|
245
|
-
registry: {
|
|
246
|
-
list: async () => [],
|
|
247
|
-
resolve: async () =>
|
|
248
|
-
createResolvedServerRecord({
|
|
249
|
-
scope: "workspace",
|
|
250
|
-
workspaceRoot,
|
|
251
|
-
auth: {
|
|
252
|
-
kind: "certificate",
|
|
253
|
-
certificatePath: "certs/client-cert.pem",
|
|
254
|
-
privateKeyPath: "keys/client-key.pem",
|
|
255
|
-
passphrase: "cert-passphrase",
|
|
256
|
-
},
|
|
257
|
-
}),
|
|
258
|
-
},
|
|
259
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
260
|
-
ssh: createStubSsh({
|
|
261
|
-
exec: async (connection, command) => {
|
|
262
|
-
execCalls.push({ connection: connection as Record<string, unknown>, command })
|
|
263
|
-
return { stdout: "ok", stderr: "", exitCode: 0 }
|
|
264
|
-
},
|
|
265
|
-
}),
|
|
266
|
-
audit: createStubAudit(),
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
const result = await orchestrator.remoteExec({
|
|
270
|
-
server: "prod-a",
|
|
271
|
-
command: "cat /etc/hosts",
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
expect(result).toMatchObject({ status: "ok", data: { stdout: "ok", exitCode: 0 } })
|
|
275
|
-
expect(execCalls).toHaveLength(1)
|
|
276
|
-
expect(execCalls[0]?.connection).toMatchObject({
|
|
277
|
-
host: "prod-a.example",
|
|
278
|
-
username: "open",
|
|
279
|
-
privateKey: "PRIVATE KEY",
|
|
280
|
-
passphrase: "cert-passphrase",
|
|
281
|
-
})
|
|
282
|
-
} finally {
|
|
283
|
-
await rm(tempDir, { recursive: true, force: true })
|
|
284
|
-
}
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
test("missing privateKeyPath returns KEY_PATH_NOT_FOUND before SSH execution", async () => {
|
|
288
|
-
const logs: Record<string, unknown>[] = []
|
|
289
|
-
let execCalled = false
|
|
290
|
-
const orchestrator = createOrchestrator({
|
|
291
|
-
registry: {
|
|
292
|
-
list: async () => [],
|
|
293
|
-
resolve: async () =>
|
|
294
|
-
createResolvedServerRecord({
|
|
295
|
-
scope: "workspace",
|
|
296
|
-
workspaceRoot: "/workspace",
|
|
297
|
-
auth: {
|
|
298
|
-
kind: "privateKey",
|
|
299
|
-
privateKeyPath: "keys/missing-id_rsa",
|
|
300
|
-
},
|
|
301
|
-
}),
|
|
302
|
-
},
|
|
303
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
304
|
-
ssh: createStubSsh({
|
|
305
|
-
exec: async () => {
|
|
306
|
-
execCalled = true
|
|
307
|
-
return { stdout: "ok", stderr: "", exitCode: 0 }
|
|
308
|
-
},
|
|
309
|
-
}),
|
|
310
|
-
audit: createStubAudit({
|
|
311
|
-
appendLog: async (entry) => {
|
|
312
|
-
logs.push(entry)
|
|
313
|
-
},
|
|
314
|
-
}),
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
const result = await orchestrator.remoteExec({
|
|
318
|
-
server: "prod-a",
|
|
319
|
-
command: "cat /etc/hosts",
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
expect(execCalled).toBe(false)
|
|
323
|
-
expect(result).toMatchObject({
|
|
324
|
-
status: "error",
|
|
325
|
-
code: "KEY_PATH_NOT_FOUND",
|
|
326
|
-
execution: { attempted: false, completed: false },
|
|
327
|
-
audit: { logWritten: true, snapshotStatus: "not-applicable" },
|
|
328
|
-
})
|
|
329
|
-
expect(logs).toEqual([
|
|
330
|
-
expect.objectContaining({
|
|
331
|
-
tool: "remote_exec",
|
|
332
|
-
server: "prod-a",
|
|
333
|
-
approvalStatus: "not-required",
|
|
334
|
-
code: "KEY_PATH_NOT_FOUND",
|
|
335
|
-
}),
|
|
336
|
-
])
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
test("missing certificatePath returns CERTIFICATE_PATH_NOT_FOUND before SSH execution", async () => {
|
|
340
|
-
const logs: Record<string, unknown>[] = []
|
|
341
|
-
const tempDir = await mkdtemp(join(tmpdir(), "open-code-auth-"))
|
|
342
|
-
const workspaceRoot = join(tempDir, "repo")
|
|
343
|
-
const privateKeyPath = join(workspaceRoot, "keys", "client-key.pem")
|
|
344
|
-
let execCalled = false
|
|
345
|
-
|
|
346
|
-
try {
|
|
347
|
-
await mkdir(join(workspaceRoot, "keys"), { recursive: true })
|
|
348
|
-
await writeFile(privateKeyPath, "PRIVATE KEY")
|
|
349
|
-
|
|
350
|
-
const orchestrator = createOrchestrator({
|
|
351
|
-
registry: {
|
|
352
|
-
list: async () => [],
|
|
353
|
-
resolve: async () =>
|
|
354
|
-
createResolvedServerRecord({
|
|
355
|
-
scope: "workspace",
|
|
356
|
-
workspaceRoot,
|
|
357
|
-
auth: {
|
|
358
|
-
kind: "certificate",
|
|
359
|
-
certificatePath: "certs/client-cert.pem",
|
|
360
|
-
privateKeyPath: "keys/client-key.pem",
|
|
361
|
-
},
|
|
362
|
-
}),
|
|
363
|
-
},
|
|
364
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
365
|
-
ssh: createStubSsh({
|
|
366
|
-
exec: async () => {
|
|
367
|
-
execCalled = true
|
|
368
|
-
return { stdout: "ok", stderr: "", exitCode: 0 }
|
|
369
|
-
},
|
|
370
|
-
}),
|
|
371
|
-
audit: createStubAudit({
|
|
372
|
-
appendLog: async (entry) => {
|
|
373
|
-
logs.push(entry)
|
|
374
|
-
},
|
|
375
|
-
}),
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
const result = await orchestrator.remoteExec({
|
|
379
|
-
server: "prod-a",
|
|
380
|
-
command: "cat /etc/hosts",
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
expect(execCalled).toBe(false)
|
|
384
|
-
expect(result).toMatchObject({
|
|
385
|
-
status: "error",
|
|
386
|
-
code: "CERTIFICATE_PATH_NOT_FOUND",
|
|
387
|
-
execution: { attempted: false, completed: false },
|
|
388
|
-
audit: { logWritten: true, snapshotStatus: "not-applicable" },
|
|
389
|
-
})
|
|
390
|
-
expect(logs).toEqual([
|
|
391
|
-
expect.objectContaining({
|
|
392
|
-
tool: "remote_exec",
|
|
393
|
-
server: "prod-a",
|
|
394
|
-
approvalStatus: "not-required",
|
|
395
|
-
code: "CERTIFICATE_PATH_NOT_FOUND",
|
|
396
|
-
}),
|
|
397
|
-
])
|
|
398
|
-
} finally {
|
|
399
|
-
await rm(tempDir, { recursive: true, force: true })
|
|
400
|
-
}
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
test("malformed privateKey registry records fail before SSH execution", async () => {
|
|
404
|
-
const tempDir = await mkdtemp(join(tmpdir(), "open-code-registry-"))
|
|
405
|
-
const workspaceRoot = join(tempDir, "repo")
|
|
406
|
-
const globalRegistryFile = join(tempDir, "config", "servers.json")
|
|
407
|
-
const workspaceRegistryFile = join(workspaceRoot, ".open-code", "servers.json")
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
await mkdir(join(tempDir, "config"), { recursive: true })
|
|
411
|
-
await mkdir(join(workspaceRoot, ".open-code"), { recursive: true })
|
|
412
|
-
await writeFile(
|
|
413
|
-
workspaceRegistryFile,
|
|
414
|
-
JSON.stringify(
|
|
415
|
-
[
|
|
416
|
-
{
|
|
417
|
-
id: "prod-a",
|
|
418
|
-
host: "prod-a.example",
|
|
419
|
-
port: 22,
|
|
420
|
-
username: "open",
|
|
421
|
-
auth: { kind: "privateKey" },
|
|
422
|
-
},
|
|
423
|
-
],
|
|
424
|
-
null,
|
|
425
|
-
2,
|
|
426
|
-
),
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
const { createServerRegistry } = await import("../../src/core/registry/server-registry?validation")
|
|
430
|
-
const orchestrator = createOrchestrator({
|
|
431
|
-
registry: createServerRegistry({
|
|
432
|
-
globalRegistryFile,
|
|
433
|
-
workspaceRegistryFile,
|
|
434
|
-
workspaceRoot,
|
|
435
|
-
}),
|
|
436
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
437
|
-
ssh: createStubSsh({
|
|
438
|
-
exec: async () => {
|
|
439
|
-
throw new Error("ssh should not be called for malformed registry records")
|
|
440
|
-
},
|
|
441
|
-
}),
|
|
442
|
-
audit: createStubAudit(),
|
|
443
|
-
})
|
|
444
|
-
|
|
445
|
-
const result = await orchestrator.remoteExec({
|
|
446
|
-
server: "prod-a",
|
|
447
|
-
command: "cat /etc/hosts",
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
expect(result).toMatchObject({
|
|
451
|
-
status: "error",
|
|
452
|
-
code: "REGISTRY_RECORD_INVALID",
|
|
453
|
-
execution: { attempted: false, completed: false },
|
|
454
|
-
})
|
|
455
|
-
} finally {
|
|
456
|
-
await rm(tempDir, { recursive: true, force: true })
|
|
457
|
-
}
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
test("directory auth paths are reported as AUTH_PATH_UNREADABLE", async () => {
|
|
461
|
-
const logs: Record<string, unknown>[] = []
|
|
462
|
-
const tempDir = await mkdtemp(join(tmpdir(), "open-code-auth-"))
|
|
463
|
-
const workspaceRoot = join(tempDir, "repo")
|
|
464
|
-
let execCalled = false
|
|
465
|
-
|
|
466
|
-
try {
|
|
467
|
-
await mkdir(workspaceRoot, { recursive: true })
|
|
468
|
-
|
|
469
|
-
const orchestrator = createOrchestrator({
|
|
470
|
-
registry: {
|
|
471
|
-
list: async () => [],
|
|
472
|
-
resolve: async () =>
|
|
473
|
-
createResolvedServerRecord({
|
|
474
|
-
scope: "workspace",
|
|
475
|
-
workspaceRoot,
|
|
476
|
-
auth: {
|
|
477
|
-
kind: "privateKey",
|
|
478
|
-
privateKeyPath: ".",
|
|
479
|
-
},
|
|
480
|
-
}),
|
|
481
|
-
},
|
|
482
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
483
|
-
ssh: createStubSsh({
|
|
484
|
-
exec: async () => {
|
|
485
|
-
execCalled = true
|
|
486
|
-
return { stdout: "ok", stderr: "", exitCode: 0 }
|
|
487
|
-
},
|
|
488
|
-
}),
|
|
489
|
-
audit: createStubAudit({
|
|
490
|
-
appendLog: async (entry) => {
|
|
491
|
-
logs.push(entry)
|
|
492
|
-
},
|
|
493
|
-
}),
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
const result = await orchestrator.remoteExec({
|
|
497
|
-
server: "prod-a",
|
|
498
|
-
command: "cat /etc/hosts",
|
|
499
|
-
})
|
|
500
|
-
|
|
501
|
-
expect(execCalled).toBe(false)
|
|
502
|
-
expect(result).toMatchObject({
|
|
503
|
-
status: "error",
|
|
504
|
-
code: "AUTH_PATH_UNREADABLE",
|
|
505
|
-
execution: { attempted: false, completed: false },
|
|
506
|
-
audit: { logWritten: true, snapshotStatus: "not-applicable" },
|
|
507
|
-
})
|
|
508
|
-
expect(logs).toEqual([
|
|
509
|
-
expect.objectContaining({
|
|
510
|
-
tool: "remote_exec",
|
|
511
|
-
server: "prod-a",
|
|
512
|
-
approvalStatus: "not-required",
|
|
513
|
-
code: "AUTH_PATH_UNREADABLE",
|
|
514
|
-
}),
|
|
515
|
-
])
|
|
516
|
-
} finally {
|
|
517
|
-
await rm(tempDir, { recursive: true, force: true })
|
|
518
|
-
}
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
test("global relative auth paths are rejected before SSH execution", async () => {
|
|
522
|
-
let execCalled = false
|
|
523
|
-
const orchestrator = createOrchestrator({
|
|
524
|
-
registry: {
|
|
525
|
-
list: async () => [],
|
|
526
|
-
resolve: async () =>
|
|
527
|
-
createResolvedServerRecord({
|
|
528
|
-
scope: "global",
|
|
529
|
-
workspaceRoot: undefined,
|
|
530
|
-
auth: {
|
|
531
|
-
kind: "privateKey",
|
|
532
|
-
privateKeyPath: "./keys/id_rsa",
|
|
533
|
-
},
|
|
534
|
-
}),
|
|
535
|
-
},
|
|
536
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
537
|
-
ssh: createStubSsh({
|
|
538
|
-
exec: async () => {
|
|
539
|
-
execCalled = true
|
|
540
|
-
return { stdout: "ok", stderr: "", exitCode: 0 }
|
|
541
|
-
},
|
|
542
|
-
}),
|
|
543
|
-
audit: createStubAudit(),
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
const result = await orchestrator.remoteExec({
|
|
547
|
-
server: "prod-a",
|
|
548
|
-
command: "cat /etc/hosts",
|
|
549
|
-
})
|
|
550
|
-
|
|
551
|
-
expect(execCalled).toBe(false)
|
|
552
|
-
expect(result).toMatchObject({
|
|
553
|
-
status: "error",
|
|
554
|
-
code: "AUTH_PATH_INVALID",
|
|
555
|
-
execution: { attempted: false, completed: false },
|
|
556
|
-
})
|
|
557
|
-
})
|
|
558
|
-
|
|
559
|
-
test("returns structured not-found errors for reads", async () => {
|
|
560
|
-
const logs: Record<string, unknown>[] = []
|
|
561
|
-
const orchestrator = createOrchestrator({
|
|
562
|
-
registry: { list: async () => [], resolve: async () => null },
|
|
563
|
-
ssh: createStubSsh(),
|
|
564
|
-
audit: createStubAudit({
|
|
565
|
-
appendLog: async (entry) => {
|
|
566
|
-
logs.push(entry)
|
|
567
|
-
},
|
|
568
|
-
}),
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
const result = await orchestrator.remoteReadFile({
|
|
572
|
-
server: "missing",
|
|
573
|
-
path: "/etc/hosts",
|
|
574
|
-
})
|
|
575
|
-
|
|
576
|
-
expect(result).toMatchObject({
|
|
577
|
-
status: "error",
|
|
578
|
-
tool: "remote_read_file",
|
|
579
|
-
server: "missing",
|
|
580
|
-
code: "SERVER_NOT_FOUND",
|
|
581
|
-
execution: { attempted: false, completed: false },
|
|
582
|
-
audit: { logWritten: true, snapshotStatus: "not-applicable" },
|
|
583
|
-
})
|
|
584
|
-
expect(logs).toEqual([
|
|
585
|
-
expect.objectContaining({
|
|
586
|
-
tool: "remote_read_file",
|
|
587
|
-
server: "missing",
|
|
588
|
-
code: "SERVER_NOT_FOUND",
|
|
589
|
-
}),
|
|
590
|
-
])
|
|
591
|
-
})
|
|
592
|
-
|
|
593
|
-
test("slices remote file reads with offset and length", async () => {
|
|
594
|
-
const orchestrator = createOrchestrator({
|
|
595
|
-
registry: { list: async () => [], resolve: async () => createServerRecord("prod-a") },
|
|
596
|
-
ssh: createStubSsh({
|
|
597
|
-
readFile: async () => "abcdef",
|
|
598
|
-
}),
|
|
599
|
-
audit: createStubAudit(),
|
|
600
|
-
})
|
|
601
|
-
|
|
602
|
-
const result = await orchestrator.remoteReadFile({
|
|
603
|
-
server: "prod-a",
|
|
604
|
-
path: "/tmp/example.txt",
|
|
605
|
-
offset: 2,
|
|
606
|
-
length: 2,
|
|
607
|
-
})
|
|
608
|
-
|
|
609
|
-
expect(result).toMatchObject({
|
|
610
|
-
status: "ok",
|
|
611
|
-
data: { content: "cd" },
|
|
612
|
-
})
|
|
613
|
-
})
|
|
614
|
-
|
|
615
|
-
test("returns partial failure when audit snapshot finalization fails after a successful write", async () => {
|
|
616
|
-
const orchestrator = createOrchestrator({
|
|
617
|
-
registry: { list: async () => [], resolve: async () => createServerRecord("prod-a") },
|
|
618
|
-
ssh: createStubSsh({
|
|
619
|
-
readFile: async () => "port=80\n",
|
|
620
|
-
writeFile: async () => {},
|
|
621
|
-
}),
|
|
622
|
-
audit: createStubAudit({
|
|
623
|
-
captureSnapshots: async () => {
|
|
624
|
-
throw new Error("git commit failed")
|
|
625
|
-
},
|
|
626
|
-
}),
|
|
627
|
-
})
|
|
628
|
-
|
|
629
|
-
const result = await orchestrator.remoteWriteFile({
|
|
630
|
-
server: "prod-a",
|
|
631
|
-
path: "/tmp/app.conf",
|
|
632
|
-
content: "port=81\n",
|
|
633
|
-
mode: 0o640,
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
expect(result.status).toBe("partial_failure")
|
|
637
|
-
expect(result.execution).toMatchObject({ attempted: true, completed: true })
|
|
638
|
-
expect(result.audit).toMatchObject({ logWritten: true, snapshotStatus: "partial-failure" })
|
|
639
|
-
})
|
|
640
|
-
|
|
641
|
-
test("keeps execution and audit scoped to the addressed server", async () => {
|
|
642
|
-
const logs: Record<string, unknown>[] = []
|
|
643
|
-
const orchestrator = createOrchestrator({
|
|
644
|
-
registry: {
|
|
645
|
-
list: async () => [],
|
|
646
|
-
resolve: async (id: string) => createServerRecord(id),
|
|
647
|
-
},
|
|
648
|
-
policy: { classifyRemoteExec: () => ({ decision: "auto-allow", reason: "safe inspection command" }) },
|
|
649
|
-
ssh: createStubSsh({
|
|
650
|
-
exec: async (server) => ({ stdout: server.host, stderr: "", exitCode: 0 }),
|
|
651
|
-
}),
|
|
652
|
-
audit: createStubAudit({
|
|
653
|
-
appendLog: async (entry) => {
|
|
654
|
-
logs.push(entry)
|
|
655
|
-
},
|
|
656
|
-
}),
|
|
657
|
-
})
|
|
658
|
-
|
|
659
|
-
const first = await orchestrator.remoteExec({ server: "prod-a", command: "pwd" })
|
|
660
|
-
const second = await orchestrator.remoteExec({ server: "prod-b", command: "pwd" })
|
|
661
|
-
|
|
662
|
-
expect(first.data).toMatchObject({ stdout: "prod-a.example" })
|
|
663
|
-
expect(second.data).toMatchObject({ stdout: "prod-b.example" })
|
|
664
|
-
expect(logs.map((entry) => entry.server)).toEqual(["prod-a", "prod-b"])
|
|
665
|
-
})
|
|
666
|
-
|
|
667
|
-
test("keeps file writes and snapshots partitioned across two registered servers", async () => {
|
|
668
|
-
const snapshots: Record<string, unknown>[] = []
|
|
669
|
-
const files = new Map([
|
|
670
|
-
["prod-a.example:/tmp/app.conf", "port=80\n"],
|
|
671
|
-
["prod-b.example:/tmp/app.conf", "port=90\n"],
|
|
672
|
-
])
|
|
673
|
-
|
|
674
|
-
const orchestrator = createOrchestrator({
|
|
675
|
-
registry: {
|
|
676
|
-
list: async () => [],
|
|
677
|
-
resolve: async (id: string) => createServerRecord(id),
|
|
678
|
-
},
|
|
679
|
-
ssh: createStubSsh({
|
|
680
|
-
readFile: async (server, path) => files.get(`${server.host}:${path}`) ?? "",
|
|
681
|
-
writeFile: async (server, path, content) => {
|
|
682
|
-
files.set(`${server.host}:${path}`, content)
|
|
683
|
-
},
|
|
684
|
-
}),
|
|
685
|
-
audit: createStubAudit({
|
|
686
|
-
captureSnapshots: async (entry) => {
|
|
687
|
-
snapshots.push(entry)
|
|
688
|
-
},
|
|
689
|
-
}),
|
|
690
|
-
})
|
|
691
|
-
|
|
692
|
-
await orchestrator.remoteWriteFile({ server: "prod-a", path: "/tmp/app.conf", content: "port=81\n" })
|
|
693
|
-
await orchestrator.remoteWriteFile({ server: "prod-b", path: "/tmp/app.conf", content: "port=91\n" })
|
|
694
|
-
|
|
695
|
-
expect(snapshots).toEqual([
|
|
696
|
-
expect.objectContaining({ server: "prod-a", path: "/tmp/app.conf", before: "port=80\n", after: "port=81\n" }),
|
|
697
|
-
expect.objectContaining({ server: "prod-b", path: "/tmp/app.conf", before: "port=90\n", after: "port=91\n" }),
|
|
698
|
-
])
|
|
699
|
-
})
|
|
700
|
-
|
|
701
|
-
test("lists servers without exposing auth material", async () => {
|
|
702
|
-
const orchestrator = createOrchestrator({
|
|
703
|
-
registry: {
|
|
704
|
-
list: async () => [
|
|
705
|
-
createResolvedServerRecord({
|
|
706
|
-
scope: "workspace",
|
|
707
|
-
}),
|
|
708
|
-
],
|
|
709
|
-
resolve: async () => createServerRecord("prod-a"),
|
|
710
|
-
},
|
|
711
|
-
ssh: createStubSsh(),
|
|
712
|
-
audit: createStubAudit(),
|
|
713
|
-
})
|
|
714
|
-
|
|
715
|
-
const result = await orchestrator.listServers()
|
|
716
|
-
|
|
717
|
-
expect(result.status).toBe("ok")
|
|
718
|
-
expect(result.data).toEqual([
|
|
719
|
-
expect.objectContaining({
|
|
720
|
-
id: "prod-a",
|
|
721
|
-
host: "prod-a.example",
|
|
722
|
-
}),
|
|
723
|
-
])
|
|
724
|
-
expect((result.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("auth")
|
|
725
|
-
expect((result.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("workspaceRoot")
|
|
726
|
-
})
|
|
727
|
-
|
|
728
|
-
test("builds remote find commands without reusing remote_exec policy gating", async () => {
|
|
729
|
-
const logs: Record<string, unknown>[] = []
|
|
730
|
-
let executedCommand = ""
|
|
731
|
-
const orchestrator = createOrchestrator({
|
|
732
|
-
registry: {
|
|
733
|
-
list: async () => [],
|
|
734
|
-
resolve: async () => createServerRecord("prod-a"),
|
|
735
|
-
},
|
|
736
|
-
policy: { classifyRemoteExec: () => ({ decision: "reject", reason: "should not be used here" }) },
|
|
737
|
-
ssh: createStubSsh({
|
|
738
|
-
exec: async (_server, command) => {
|
|
739
|
-
executedCommand = command
|
|
740
|
-
return { stdout: "match", stderr: "", exitCode: 0 }
|
|
741
|
-
},
|
|
742
|
-
}),
|
|
743
|
-
audit: createStubAudit({
|
|
744
|
-
appendLog: async (entry) => {
|
|
745
|
-
logs.push(entry)
|
|
746
|
-
},
|
|
747
|
-
}),
|
|
748
|
-
})
|
|
749
|
-
|
|
750
|
-
const result = await orchestrator.remoteFind({
|
|
751
|
-
server: "prod-a",
|
|
752
|
-
path: "/var/log",
|
|
753
|
-
pattern: "ERROR",
|
|
754
|
-
limit: 5,
|
|
755
|
-
})
|
|
756
|
-
|
|
757
|
-
expect(result).toMatchObject({ status: "ok", data: { stdout: "match", exitCode: 0 } })
|
|
758
|
-
expect(executedCommand).toContain("grep -R -n")
|
|
759
|
-
expect(logs).toEqual([
|
|
760
|
-
expect.objectContaining({
|
|
761
|
-
tool: "remote_find",
|
|
762
|
-
server: "prod-a",
|
|
763
|
-
approvalStatus: "not-required",
|
|
764
|
-
}),
|
|
765
|
-
])
|
|
766
|
-
})
|
|
767
|
-
})
|