@astrale-os/cli 0.7.0-alpha.0 → 0.8.0-alpha.0
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/README.md +3 -2
- package/dist/astrale.js +118 -95
- package/package.json +2 -3
- package/src/commands/studio.ts +13 -0
- package/src/lib/__tests__/view-assets.test.ts +114 -0
- package/src/lib/instance.ts +0 -11
- package/src/lib/login-flow.ts +4 -41
- package/src/lib/view/assets.ts +64 -0
- package/src/lib/view/server.ts +4 -37
- package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
- package/studio/client/dist/assets/index-jRdExahh.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.test.ts +74 -0
- package/studio/server/agent/ask.ts +20 -22
- package/studio/server/agent/bridge/client.test.ts +49 -0
- package/studio/server/agent/bridge/client.ts +37 -0
- package/studio/server/agent/bridge/grant.test.ts +62 -0
- package/studio/server/agent/bridge/grant.ts +84 -0
- package/studio/server/agent/bridge/routes.test.ts +151 -0
- package/studio/server/agent/bridge/routes.ts +148 -0
- package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
- package/studio/server/agent/conversation.test.ts +84 -0
- package/studio/server/agent/conversation.ts +110 -0
- package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
- package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
- package/studio/server/agent/harness/claude/adapter.ts +71 -0
- package/studio/server/agent/harness/claude/ask.ts +114 -0
- package/studio/server/agent/harness/claude/capabilities.ts +27 -0
- package/studio/server/agent/harness/claude/command.ts +63 -0
- package/studio/server/agent/harness/claude/events.ts +215 -0
- package/studio/server/agent/harness/claude/loadout.ts +132 -0
- package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
- package/studio/server/agent/harness/claude/mcp.ts +41 -0
- package/studio/server/agent/harness/claude/skills.ts +43 -0
- package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
- package/studio/server/agent/harness/codex/adapter.ts +116 -0
- package/studio/server/agent/harness/codex/ask.test.ts +176 -0
- package/studio/server/agent/harness/codex/ask.ts +181 -0
- package/studio/server/agent/harness/codex/command.test.ts +69 -0
- package/studio/server/agent/harness/codex/command.ts +43 -0
- package/studio/server/agent/harness/codex/events.test.ts +94 -0
- package/studio/server/agent/harness/codex/events.ts +132 -0
- package/studio/server/agent/harness/codex/exec.ts +113 -0
- package/studio/server/agent/harness/codex/loadout.ts +63 -0
- package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
- package/studio/server/agent/harness/codex/mcp.ts +27 -0
- package/studio/server/agent/harness/codex/models.test.ts +167 -0
- package/studio/server/agent/harness/codex/models.ts +211 -0
- package/studio/server/agent/harness/codex/skills.ts +33 -0
- package/studio/server/agent/harness/gateway/config.test.ts +98 -0
- package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
- package/studio/server/agent/harness/gateway/token.test.ts +133 -0
- package/studio/server/agent/harness/gateway/token.ts +166 -0
- package/studio/server/agent/harness/mock/adapter.ts +190 -0
- package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
- package/studio/server/agent/harness/process.test.ts +46 -0
- package/studio/server/agent/harness/process.ts +104 -0
- package/studio/server/agent/harness/registry.ts +37 -0
- package/studio/server/agent/harness/selection.test.ts +75 -0
- package/studio/server/agent/harness/selection.ts +77 -0
- package/studio/server/agent/harness/skills.test.ts +95 -0
- package/studio/server/agent/harness/skills.ts +100 -0
- package/studio/server/agent/layout.test.ts +95 -0
- package/studio/server/agent/notify.ts +12 -0
- package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
- package/studio/server/agent/prompts/ask.ts +34 -0
- package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
- package/studio/server/agent/prompts/turn.ts +57 -0
- package/studio/server/agent/routes.test.ts +176 -0
- package/studio/server/agent/routes.ts +214 -0
- package/studio/server/agent/run/completion.ts +193 -0
- package/studio/server/agent/run/coordinator.test.ts +337 -0
- package/studio/server/agent/run/coordinator.ts +104 -0
- package/studio/server/agent/run/live-state.ts +73 -0
- package/studio/server/agent/run/preparation.ts +162 -0
- package/studio/server/agent/run/transcript.test.ts +47 -0
- package/studio/server/agent/run/transcript.ts +30 -0
- package/studio/server/agent/run/usage.test.ts +46 -0
- package/studio/server/{state → agent/run}/usage.ts +4 -4
- package/studio/server/agent/stream.ts +37 -0
- package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
- package/studio/server/api-agent-loadout.test.ts +69 -0
- package/studio/server/api.ts +3 -155
- package/studio/server/index.ts +6 -5
- package/studio/server/state/comments.test.ts +29 -0
- package/studio/server/state/comments.ts +3 -3
- package/studio/server/state/settings.test.ts +78 -0
- package/studio/server/state/settings.ts +28 -1
- package/studio/shared/agent-effort.test.ts +12 -0
- package/studio/shared/agent-effort.ts +15 -0
- package/studio/shared/agent-models.test.ts +9 -0
- package/studio/shared/agent-models.ts +14 -0
- package/studio/shared/settings-values.test.ts +18 -0
- package/studio/shared/settings-values.ts +20 -0
- package/studio/shared/types.ts +58 -7
- package/src/connect-core.test.ts +0 -42
- package/src/connect-core.ts +0 -53
- package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
- package/studio/client/dist/assets/index-huaFafBC.css +0 -1
- package/studio/server/agent/bridge.ts +0 -188
- package/studio/server/agent/claude.ts +0 -678
- package/studio/server/agent/mock.ts +0 -186
- package/studio/server/agent/registry.ts +0 -29
- package/studio/server/agent/runner.ts +0 -487
- package/studio/server/state/harness-token.ts +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { codexMcpConfigArgs } from './mcp'
|
|
4
|
+
|
|
5
|
+
test('serializes a structured MCP grant into Codex overrides', () => {
|
|
6
|
+
expect(
|
|
7
|
+
codexMcpConfigArgs([
|
|
8
|
+
{
|
|
9
|
+
name: 'domain-studio',
|
|
10
|
+
command: '/opt/bun',
|
|
11
|
+
args: ['/studio/bridge/stdio.ts', '--config', '/domain/bridge.json'],
|
|
12
|
+
env: { MODE: 'test value' },
|
|
13
|
+
required: true,
|
|
14
|
+
approvalMode: 'approve',
|
|
15
|
+
enabledTools: ['reply_to_thread', 'resolve_thread'],
|
|
16
|
+
},
|
|
17
|
+
]),
|
|
18
|
+
).toEqual([
|
|
19
|
+
'-c',
|
|
20
|
+
'mcp_servers.domain-studio.command="/opt/bun"',
|
|
21
|
+
'-c',
|
|
22
|
+
'mcp_servers.domain-studio.args=["/studio/bridge/stdio.ts","--config","/domain/bridge.json"]',
|
|
23
|
+
'-c',
|
|
24
|
+
'mcp_servers.domain-studio.env={MODE="test value"}',
|
|
25
|
+
'-c',
|
|
26
|
+
'mcp_servers.domain-studio.required=true',
|
|
27
|
+
'-c',
|
|
28
|
+
'mcp_servers.domain-studio.default_tools_approval_mode="approve"',
|
|
29
|
+
'-c',
|
|
30
|
+
'mcp_servers.domain-studio.enabled_tools=["reply_to_thread","resolve_thread"]',
|
|
31
|
+
])
|
|
32
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { HarnessMcpServer } from '../adapter'
|
|
2
|
+
|
|
3
|
+
export function tomlString(value: string): string {
|
|
4
|
+
return JSON.stringify(value)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function tomlStringMap(values: Record<string, string>): string {
|
|
8
|
+
return `{${Object.entries(values)
|
|
9
|
+
.map(([key, value]) => `${key}=${tomlString(value)}`)
|
|
10
|
+
.join(',')}}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Serialize Studio MCP grants into Codex CLI config overrides. */
|
|
14
|
+
export function codexMcpConfigArgs(servers: HarnessMcpServer[] | undefined): string[] {
|
|
15
|
+
const args: string[] = []
|
|
16
|
+
for (const server of servers ?? []) {
|
|
17
|
+
const prefix = `mcp_servers.${server.name}`
|
|
18
|
+
const add = (key: string, value: string) => args.push('-c', `${prefix}.${key}=${value}`)
|
|
19
|
+
add('command', tomlString(server.command))
|
|
20
|
+
if (server.args?.length) add('args', JSON.stringify(server.args))
|
|
21
|
+
if (server.env && Object.keys(server.env).length) add('env', tomlStringMap(server.env))
|
|
22
|
+
if (server.required !== undefined) add('required', String(server.required))
|
|
23
|
+
if (server.approvalMode) add('default_tools_approval_mode', tomlString(server.approvalMode))
|
|
24
|
+
if (server.enabledTools?.length) add('enabled_tools', JSON.stringify(server.enabledTools))
|
|
25
|
+
}
|
|
26
|
+
return args
|
|
27
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { probeCodexModels } from './models'
|
|
7
|
+
|
|
8
|
+
const roots: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function fakeCodex(root: string): string {
|
|
15
|
+
const file = join(root, 'fake-codex')
|
|
16
|
+
writeFileSync(
|
|
17
|
+
file,
|
|
18
|
+
`#!/usr/bin/env bun
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
let buffer = ''
|
|
21
|
+
const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n')
|
|
22
|
+
process.stdin.setEncoding('utf8')
|
|
23
|
+
process.stdin.on('data', (chunk) => {
|
|
24
|
+
buffer += chunk
|
|
25
|
+
let newline
|
|
26
|
+
while ((newline = buffer.indexOf('\\n')) >= 0) {
|
|
27
|
+
const line = buffer.slice(0, newline).trim()
|
|
28
|
+
buffer = buffer.slice(newline + 1)
|
|
29
|
+
if (!line) continue
|
|
30
|
+
const message = JSON.parse(line)
|
|
31
|
+
const mode = process.env.FAKE_MODEL_MODE
|
|
32
|
+
if (process.env.FAKE_MODEL_LOG) fs.appendFileSync(process.env.FAKE_MODEL_LOG, line + '\\n')
|
|
33
|
+
if (message.method === 'initialize') {
|
|
34
|
+
if (mode === 'early-close') {
|
|
35
|
+
console.error('probe crashed before initialization')
|
|
36
|
+
process.exit(7)
|
|
37
|
+
}
|
|
38
|
+
if (mode !== 'timeout') send({ id: message.id, result: { userAgent: 'fake' } })
|
|
39
|
+
}
|
|
40
|
+
if (message.method === 'config/read') {
|
|
41
|
+
if (mode === 'rpc-error') send({ id: message.id, error: { message: 'config denied' } })
|
|
42
|
+
else if (mode === 'malformed-config') send({ id: message.id, result: { origins: {} } })
|
|
43
|
+
else send({ id: message.id, result: { config: { model: 'gpt-configured' }, origins: {} } })
|
|
44
|
+
}
|
|
45
|
+
if (message.method === 'model/list') {
|
|
46
|
+
send({
|
|
47
|
+
id: message.id,
|
|
48
|
+
result: {
|
|
49
|
+
data: mode === 'malformed-models' ? {} : [
|
|
50
|
+
{ id: 'default', model: 'gpt-default', displayName: 'Default GPT', description: 'default model', isDefault: true },
|
|
51
|
+
{ id: 'fast', model: 'gpt-fast', displayName: 'Fast GPT', description: 'fast model', isDefault: false },
|
|
52
|
+
],
|
|
53
|
+
nextCursor: null,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
`,
|
|
60
|
+
)
|
|
61
|
+
chmodSync(file, 0o755)
|
|
62
|
+
return file
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test('resolves Codex config and live catalog through the app-server protocol', async () => {
|
|
66
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-codex-models-'))
|
|
67
|
+
roots.push(root)
|
|
68
|
+
const log = join(root, 'requests.jsonl')
|
|
69
|
+
|
|
70
|
+
const result = await probeCodexModels(fakeCodex(root), root, undefined, {
|
|
71
|
+
FAKE_MODEL_LOG: log,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
ok: true,
|
|
76
|
+
nativeModel: 'gpt-configured',
|
|
77
|
+
model: 'gpt-configured',
|
|
78
|
+
modelSource: 'config',
|
|
79
|
+
models: [
|
|
80
|
+
{
|
|
81
|
+
id: 'gpt-default',
|
|
82
|
+
label: 'Default GPT',
|
|
83
|
+
description: 'default model',
|
|
84
|
+
isDefault: true,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'gpt-fast',
|
|
88
|
+
label: 'Fast GPT',
|
|
89
|
+
description: 'fast model',
|
|
90
|
+
isDefault: false,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
detail: 'Codex resolves gpt-configured from its effective config.',
|
|
94
|
+
})
|
|
95
|
+
const requests = readFileSync(log, 'utf8')
|
|
96
|
+
.trim()
|
|
97
|
+
.split('\n')
|
|
98
|
+
.map((line) => JSON.parse(line))
|
|
99
|
+
expect(requests.map((request) => request.method)).toEqual([
|
|
100
|
+
'initialize',
|
|
101
|
+
'initialized',
|
|
102
|
+
'config/read',
|
|
103
|
+
'model/list',
|
|
104
|
+
])
|
|
105
|
+
expect(requests[2].params).toEqual({ cwd: root, includeLayers: false })
|
|
106
|
+
expect(requests[3].params).toEqual({ includeHidden: false, limit: 100 })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('keeps the native Codex model visible while a Studio override wins', async () => {
|
|
110
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-codex-model-override-'))
|
|
111
|
+
roots.push(root)
|
|
112
|
+
|
|
113
|
+
const result = await probeCodexModels(fakeCodex(root), root, 'gpt-fast')
|
|
114
|
+
|
|
115
|
+
expect(result).toMatchObject({
|
|
116
|
+
ok: true,
|
|
117
|
+
nativeModel: 'gpt-configured',
|
|
118
|
+
model: 'gpt-fast',
|
|
119
|
+
modelSource: 'studio',
|
|
120
|
+
})
|
|
121
|
+
expect(result.detail).toBe('Codex resolves gpt-fast from this Studio domain.')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test.each([
|
|
125
|
+
['malformed-config', 'Codex config probe returned an invalid response'],
|
|
126
|
+
['malformed-models', 'Codex model catalog returned an invalid response'],
|
|
127
|
+
['rpc-error', 'config denied'],
|
|
128
|
+
] as const)('fails closed on %s app-server protocol responses', async (mode, detail) => {
|
|
129
|
+
const root = mkdtempSync(join(tmpdir(), `studio-codex-model-${mode}-`))
|
|
130
|
+
roots.push(root)
|
|
131
|
+
|
|
132
|
+
const result = await probeCodexModels(fakeCodex(root), root, undefined, {
|
|
133
|
+
FAKE_MODEL_MODE: mode,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(result.ok).toBe(false)
|
|
137
|
+
expect(result.detail).toBe(detail)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('reports spawn, early-close, and timeout failures without hanging', async () => {
|
|
141
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-codex-model-failures-'))
|
|
142
|
+
roots.push(root)
|
|
143
|
+
const bin = fakeCodex(root)
|
|
144
|
+
|
|
145
|
+
const missing = await probeCodexModels(join(root, 'missing-codex'), root)
|
|
146
|
+
expect(missing).toMatchObject({
|
|
147
|
+
ok: false,
|
|
148
|
+
models: [],
|
|
149
|
+
})
|
|
150
|
+
expect(missing.detail).toContain('failed to spawn')
|
|
151
|
+
|
|
152
|
+
const early = await probeCodexModels(bin, root, undefined, {
|
|
153
|
+
FAKE_MODEL_MODE: 'early-close',
|
|
154
|
+
})
|
|
155
|
+
expect(early.ok).toBe(false)
|
|
156
|
+
expect(early.detail).toContain('probe crashed before initialization')
|
|
157
|
+
|
|
158
|
+
const timedOut = await probeCodexModels(bin, root, undefined, { FAKE_MODEL_MODE: 'timeout' }, 50)
|
|
159
|
+
expect(timedOut).toEqual({
|
|
160
|
+
ok: false,
|
|
161
|
+
nativeModel: undefined,
|
|
162
|
+
model: undefined,
|
|
163
|
+
modelSource: undefined,
|
|
164
|
+
models: [],
|
|
165
|
+
detail: 'Codex model probe timed out',
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
import type { HarnessLoadout, HarnessModelOption } from '../../../../shared/types'
|
|
4
|
+
|
|
5
|
+
import { childEnvironment, terminateProcessTree } from '../process'
|
|
6
|
+
|
|
7
|
+
const PROBE_TIMEOUT_MS = 15_000
|
|
8
|
+
|
|
9
|
+
export interface CodexModelProbe {
|
|
10
|
+
ok: boolean
|
|
11
|
+
nativeModel?: string
|
|
12
|
+
model?: string
|
|
13
|
+
modelSource?: HarnessLoadout['modelSource']
|
|
14
|
+
models: HarnessModelOption[]
|
|
15
|
+
detail?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read the effective Codex config and the authenticated account's model catalog.
|
|
19
|
+
*
|
|
20
|
+
* `config/read` resolves the same project/profile/user/system layers a real turn
|
|
21
|
+
* sees for this cwd. `model/list` supplies the live picker options and built-in
|
|
22
|
+
* default. An explicit Studio override remains highest precedence, matching
|
|
23
|
+
* Codex's documented `--model` behavior.
|
|
24
|
+
*/
|
|
25
|
+
export function probeCodexModels(
|
|
26
|
+
bin: string,
|
|
27
|
+
root: string,
|
|
28
|
+
override?: string,
|
|
29
|
+
env?: Record<string, string>,
|
|
30
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
31
|
+
): Promise<CodexModelProbe> {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let child: ReturnType<typeof spawn>
|
|
34
|
+
try {
|
|
35
|
+
child = spawn(bin, ['app-server', '--stdio'], {
|
|
36
|
+
cwd: root,
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
env: childEnvironment(env),
|
|
39
|
+
detached: process.platform !== 'win32',
|
|
40
|
+
})
|
|
41
|
+
} catch (error) {
|
|
42
|
+
resolve({
|
|
43
|
+
ok: false,
|
|
44
|
+
models: [],
|
|
45
|
+
detail: `failed to spawn ${bin} app-server: ${String(error)}`,
|
|
46
|
+
})
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let settled = false
|
|
51
|
+
let configDone = false
|
|
52
|
+
let modelsDone = false
|
|
53
|
+
let configModel: string | undefined
|
|
54
|
+
let catalog: HarnessModelOption[] = []
|
|
55
|
+
let probeError = ''
|
|
56
|
+
let stderr = ''
|
|
57
|
+
let buffer = ''
|
|
58
|
+
|
|
59
|
+
const send = (message: unknown) => child.stdin?.write(`${JSON.stringify(message)}\n`)
|
|
60
|
+
const finish = () => {
|
|
61
|
+
if (settled) return
|
|
62
|
+
settled = true
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
try {
|
|
65
|
+
terminateProcessTree(child)
|
|
66
|
+
} catch {
|
|
67
|
+
/* already gone */
|
|
68
|
+
}
|
|
69
|
+
const selected = override?.trim() || undefined
|
|
70
|
+
const catalogDefault = catalog.find((model) => model.isDefault)?.id
|
|
71
|
+
const nativeModel = configModel ?? catalogDefault
|
|
72
|
+
const model = selected ?? nativeModel
|
|
73
|
+
resolve({
|
|
74
|
+
ok: configDone && modelsDone && !probeError,
|
|
75
|
+
nativeModel,
|
|
76
|
+
model,
|
|
77
|
+
modelSource: selected
|
|
78
|
+
? 'studio'
|
|
79
|
+
: configModel
|
|
80
|
+
? 'config'
|
|
81
|
+
: catalogDefault
|
|
82
|
+
? 'default'
|
|
83
|
+
: undefined,
|
|
84
|
+
models: catalog,
|
|
85
|
+
detail:
|
|
86
|
+
probeError ||
|
|
87
|
+
(model
|
|
88
|
+
? `Codex resolves ${model} from ${
|
|
89
|
+
selected
|
|
90
|
+
? 'this Studio domain'
|
|
91
|
+
: configModel
|
|
92
|
+
? 'its effective config'
|
|
93
|
+
: 'its catalog default'
|
|
94
|
+
}.`
|
|
95
|
+
: undefined),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
const maybeFinish = () => {
|
|
99
|
+
if (configDone && modelsDone) finish()
|
|
100
|
+
}
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
probeError = 'Codex model probe timed out'
|
|
103
|
+
configDone = true
|
|
104
|
+
modelsDone = true
|
|
105
|
+
finish()
|
|
106
|
+
}, timeoutMs)
|
|
107
|
+
|
|
108
|
+
child.stdout?.setEncoding('utf8')
|
|
109
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
110
|
+
buffer += chunk
|
|
111
|
+
let newline: number
|
|
112
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
113
|
+
const line = buffer.slice(0, newline).trim()
|
|
114
|
+
buffer = buffer.slice(newline + 1)
|
|
115
|
+
if (!line) continue
|
|
116
|
+
let message: any
|
|
117
|
+
try {
|
|
118
|
+
message = JSON.parse(line)
|
|
119
|
+
} catch {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (message.id === 1) {
|
|
123
|
+
if (message.error) {
|
|
124
|
+
probeError = message.error.message ?? 'Codex app-server init failed'
|
|
125
|
+
configDone = true
|
|
126
|
+
modelsDone = true
|
|
127
|
+
return finish()
|
|
128
|
+
}
|
|
129
|
+
send({ method: 'initialized', params: {} })
|
|
130
|
+
send({ method: 'config/read', id: 2, params: { cwd: root, includeLayers: false } })
|
|
131
|
+
send({ method: 'model/list', id: 3, params: { includeHidden: false, limit: 100 } })
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (message.id === 2) {
|
|
135
|
+
configDone = true
|
|
136
|
+
if (message.error) {
|
|
137
|
+
probeError ||= message.error.message ?? 'Codex config probe failed'
|
|
138
|
+
} else {
|
|
139
|
+
const config = message.result?.config
|
|
140
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
141
|
+
probeError ||= 'Codex config probe returned an invalid response'
|
|
142
|
+
} else {
|
|
143
|
+
const raw = config.model
|
|
144
|
+
configModel = typeof raw === 'string' && raw.trim() ? raw.trim() : undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
maybeFinish()
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (message.id === 3) {
|
|
151
|
+
modelsDone = true
|
|
152
|
+
if (message.error) {
|
|
153
|
+
probeError ||= message.error.message ?? 'Codex model catalog probe failed'
|
|
154
|
+
} else {
|
|
155
|
+
if (!Array.isArray(message.result?.data)) {
|
|
156
|
+
probeError ||= 'Codex model catalog returned an invalid response'
|
|
157
|
+
maybeFinish()
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
catalog = message.result.data
|
|
161
|
+
.map((entry: any): HarnessModelOption | null => {
|
|
162
|
+
const id = String(entry?.model ?? entry?.id ?? '').trim()
|
|
163
|
+
if (!id) return null
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
label: String(entry?.displayName ?? id),
|
|
167
|
+
description:
|
|
168
|
+
typeof entry?.description === 'string' ? entry.description : undefined,
|
|
169
|
+
isDefault: entry?.isDefault === true,
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
.filter((entry: HarnessModelOption | null): entry is HarnessModelOption => !!entry)
|
|
173
|
+
}
|
|
174
|
+
maybeFinish()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
child.stderr?.setEncoding('utf8')
|
|
179
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
180
|
+
stderr += chunk
|
|
181
|
+
})
|
|
182
|
+
child.on('error', (error) => {
|
|
183
|
+
probeError = `failed to spawn ${bin} app-server: ${error.message}`
|
|
184
|
+
configDone = true
|
|
185
|
+
modelsDone = true
|
|
186
|
+
finish()
|
|
187
|
+
})
|
|
188
|
+
child.on('close', (code) => {
|
|
189
|
+
if (settled) return
|
|
190
|
+
probeError =
|
|
191
|
+
probeError ||
|
|
192
|
+
`Codex app-server exited ${code ?? -1}${stderr ? `: ${stderr.trim().slice(-400)}` : ''}`
|
|
193
|
+
configDone = true
|
|
194
|
+
modelsDone = true
|
|
195
|
+
finish()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
send({
|
|
199
|
+
method: 'initialize',
|
|
200
|
+
id: 1,
|
|
201
|
+
params: {
|
|
202
|
+
clientInfo: {
|
|
203
|
+
name: 'astrale_domain_studio_probe',
|
|
204
|
+
title: 'Astrale Domain Studio',
|
|
205
|
+
version: '0.1.0',
|
|
206
|
+
},
|
|
207
|
+
capabilities: null,
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { LoadoutSkill } from '../../../../shared/types'
|
|
5
|
+
|
|
6
|
+
import { scanAncestors, scanSkillDir } from '../skills'
|
|
7
|
+
|
|
8
|
+
export interface CodexPlugin {
|
|
9
|
+
name: string
|
|
10
|
+
path: string
|
|
11
|
+
enabled: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Discover project, user, and plugin skills visible to Codex. */
|
|
15
|
+
export function scanCodexSkills(root: string, plugins: CodexPlugin[]): LoadoutSkill[] {
|
|
16
|
+
const out: LoadoutSkill[] = []
|
|
17
|
+
const seen = new Set<string>()
|
|
18
|
+
const home = homedir()
|
|
19
|
+
scanAncestors(root, ['.agents/skills', '.codex/skills'], out, seen)
|
|
20
|
+
scanSkillDir(join(home, '.agents', 'skills'), 'user', undefined, '', true, out, seen)
|
|
21
|
+
scanSkillDir(join(home, '.codex', 'skills'), 'user', undefined, '', true, out, seen)
|
|
22
|
+
for (const plugin of plugins)
|
|
23
|
+
scanSkillDir(
|
|
24
|
+
join(plugin.path, 'skills'),
|
|
25
|
+
'plugin',
|
|
26
|
+
plugin.name,
|
|
27
|
+
`${plugin.name}:`,
|
|
28
|
+
plugin.enabled,
|
|
29
|
+
out,
|
|
30
|
+
seen,
|
|
31
|
+
)
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync, statSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { statePath, writeJson } from '../../../state/store'
|
|
7
|
+
import {
|
|
8
|
+
clearHarnessGateway,
|
|
9
|
+
getHarnessGatewayState,
|
|
10
|
+
resolveHarnessEnv,
|
|
11
|
+
setHarnessGateway,
|
|
12
|
+
} from './config'
|
|
13
|
+
|
|
14
|
+
const roots: string[] = []
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('owns normalized per-domain gateway configuration and child environment', async () => {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-harness-gateway-'))
|
|
22
|
+
roots.push(root)
|
|
23
|
+
|
|
24
|
+
const state = setHarnessGateway(root, {
|
|
25
|
+
scope: 'domain',
|
|
26
|
+
config: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
baseUrl: ' https://gateway.example/v1/models/demo ',
|
|
29
|
+
model: ' demo-model ',
|
|
30
|
+
auth: { mode: 'token', token: ' secret ' },
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
expect(state).toMatchObject({
|
|
34
|
+
source: 'domain',
|
|
35
|
+
effective: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
baseUrl: 'https://gateway.example/v1/models/demo',
|
|
38
|
+
model: 'demo-model',
|
|
39
|
+
auth: { mode: 'token', token: 'secret' },
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
expect(await resolveHarnessEnv(root)).toEqual({
|
|
43
|
+
ok: true,
|
|
44
|
+
env: {
|
|
45
|
+
ANTHROPIC_BASE_URL: 'https://gateway.example/v1/models/demo',
|
|
46
|
+
ANTHROPIC_AUTH_TOKEN: 'secret',
|
|
47
|
+
ANTHROPIC_MODEL: 'demo-model',
|
|
48
|
+
ANTHROPIC_SMALL_FAST_MODEL: 'demo-model',
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
expect(statSync(statePath(root, 'harness-gateway.json')).mode & 0o777).toBe(0o600)
|
|
52
|
+
|
|
53
|
+
clearHarnessGateway(root, 'domain')
|
|
54
|
+
expect(getHarnessGatewayState(root).local).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rejects enabled gateway configurations that cannot authenticate safely', () => {
|
|
58
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-harness-gateway-invalid-'))
|
|
59
|
+
roots.push(root)
|
|
60
|
+
|
|
61
|
+
expect(() =>
|
|
62
|
+
setHarnessGateway(root, {
|
|
63
|
+
scope: 'domain',
|
|
64
|
+
config: { enabled: true, baseUrl: '', auth: { mode: 'mint' } },
|
|
65
|
+
}),
|
|
66
|
+
).toThrow('gateway base URL is required')
|
|
67
|
+
expect(() =>
|
|
68
|
+
setHarnessGateway(root, {
|
|
69
|
+
scope: 'domain',
|
|
70
|
+
config: { enabled: true, baseUrl: 'file:///tmp/gateway', auth: { mode: 'mint' } },
|
|
71
|
+
}),
|
|
72
|
+
).toThrow('must use http:// or https://')
|
|
73
|
+
expect(() =>
|
|
74
|
+
setHarnessGateway(root, {
|
|
75
|
+
scope: 'domain',
|
|
76
|
+
config: {
|
|
77
|
+
enabled: true,
|
|
78
|
+
baseUrl: 'https://gateway.example',
|
|
79
|
+
auth: { mode: 'token', token: ' ' },
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
).toThrow('static token is required')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('fails closed for invalid enabled configuration already present on disk', async () => {
|
|
86
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-harness-gateway-legacy-invalid-'))
|
|
87
|
+
roots.push(root)
|
|
88
|
+
writeJson(root, 'harness-gateway.json', {
|
|
89
|
+
enabled: true,
|
|
90
|
+
baseUrl: '',
|
|
91
|
+
auth: { mode: 'mint' },
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(await resolveHarnessEnv(root)).toEqual({
|
|
95
|
+
ok: false,
|
|
96
|
+
error: 'gateway base URL is required while enabled',
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* gateway/config.ts — point the selected harness at an Anthropic-compatible
|
|
3
3
|
* model gateway (e.g. an Astrale `ai-gateway` model node) instead of Claude
|
|
4
4
|
* Code's built-in auth. Two scopes, layered:
|
|
5
5
|
* - per-domain : `<domain>/.domain-studio/harness-gateway.json` — AUTHORITATIVE
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* subprocess env ONLY — never the studio's own process env, the user's shell, or
|
|
13
13
|
* a `claude` they run themselves outside the studio.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
16
|
import { homedir } from 'node:os'
|
|
17
17
|
import { dirname, join } from 'node:path'
|
|
18
18
|
|
|
@@ -20,10 +20,10 @@ import type {
|
|
|
20
20
|
HarnessGatewayAuth,
|
|
21
21
|
HarnessGatewayConfig,
|
|
22
22
|
HarnessGatewayState,
|
|
23
|
-
} from '
|
|
23
|
+
} from '../../../../shared/types'
|
|
24
24
|
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
25
|
+
import { readJson, removeState, statePath, writeJson } from '../../../state/store'
|
|
26
|
+
import { acquireGatewayToken } from './token'
|
|
27
27
|
|
|
28
28
|
/** Per-domain file (lives under the domain's `.domain-studio/`, gitignored). */
|
|
29
29
|
const LOCAL_FILE = 'harness-gateway.json'
|
|
@@ -55,6 +55,21 @@ function normalize(input: any): HarnessGatewayConfig {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function validateEnabledConfig(config: HarnessGatewayConfig): void {
|
|
59
|
+
if (!config.enabled) return
|
|
60
|
+
if (!config.baseUrl) throw new Error('gateway base URL is required while enabled')
|
|
61
|
+
let url: URL
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(config.baseUrl)
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(`invalid gateway base URL: ${config.baseUrl}`)
|
|
66
|
+
}
|
|
67
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
68
|
+
throw new Error('gateway base URL must use http:// or https://')
|
|
69
|
+
if (config.auth.mode === 'token' && !config.auth.token)
|
|
70
|
+
throw new Error('gateway static token is required in token mode')
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
function readLocal(root: string): HarnessGatewayConfig | null {
|
|
59
74
|
const raw = readJson<HarnessGatewayConfig | null>(root, LOCAL_FILE, null)
|
|
60
75
|
return raw ? normalize(raw) : null
|
|
@@ -77,7 +92,8 @@ function writeGlobal(cfg: HarnessGatewayConfig | null): void {
|
|
|
77
92
|
return
|
|
78
93
|
}
|
|
79
94
|
mkdirSync(dirname(GLOBAL_FILE), { recursive: true })
|
|
80
|
-
writeFileSync(GLOBAL_FILE, JSON.stringify(cfg, null, 2))
|
|
95
|
+
writeFileSync(GLOBAL_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 })
|
|
96
|
+
chmodSync(GLOBAL_FILE, 0o600)
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
/** Resolve the layered state. A PRESENT local override wins outright — even when
|
|
@@ -115,11 +131,13 @@ export function setHarnessGateway(
|
|
|
115
131
|
input: SetHarnessGatewayInput,
|
|
116
132
|
): HarnessGatewayState {
|
|
117
133
|
const cfg = normalize(input.config)
|
|
134
|
+
validateEnabledConfig(cfg)
|
|
118
135
|
if (input.scope === 'global') {
|
|
119
136
|
writeGlobal(cfg)
|
|
120
137
|
removeState(root, LOCAL_FILE)
|
|
121
138
|
} else {
|
|
122
139
|
writeJson(root, LOCAL_FILE, cfg)
|
|
140
|
+
chmodSync(statePath(root, LOCAL_FILE), 0o600)
|
|
123
141
|
}
|
|
124
142
|
return getHarnessGatewayState(root)
|
|
125
143
|
}
|
|
@@ -165,10 +183,14 @@ export type HarnessEnvResult =
|
|
|
165
183
|
*/
|
|
166
184
|
export async function resolveHarnessEnv(root: string): Promise<HarnessEnvResult> {
|
|
167
185
|
const cfg = resolveHarnessGateway(root)
|
|
168
|
-
if (!cfg || !cfg.enabled
|
|
186
|
+
if (!cfg || !cfg.enabled) return { ok: true, env: {} }
|
|
187
|
+
if (!cfg.baseUrl) return { ok: false, error: 'gateway base URL is required while enabled' }
|
|
169
188
|
let audience: string
|
|
170
189
|
try {
|
|
171
|
-
|
|
190
|
+
const url = new URL(cfg.baseUrl)
|
|
191
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
192
|
+
return { ok: false, error: 'gateway base URL must use http:// or https://' }
|
|
193
|
+
audience = url.origin
|
|
172
194
|
} catch {
|
|
173
195
|
return { ok: false, error: `invalid gateway base URL: ${cfg.baseUrl}` }
|
|
174
196
|
}
|