@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.
Files changed (105) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +118 -95
  3. package/package.json +2 -3
  4. package/src/commands/studio.ts +13 -0
  5. package/src/lib/__tests__/view-assets.test.ts +114 -0
  6. package/src/lib/instance.ts +0 -11
  7. package/src/lib/login-flow.ts +4 -41
  8. package/src/lib/view/assets.ts +64 -0
  9. package/src/lib/view/server.ts +4 -37
  10. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  11. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  12. package/studio/client/dist/index.html +2 -2
  13. package/studio/server/agent/ask.test.ts +74 -0
  14. package/studio/server/agent/ask.ts +20 -22
  15. package/studio/server/agent/bridge/client.test.ts +49 -0
  16. package/studio/server/agent/bridge/client.ts +37 -0
  17. package/studio/server/agent/bridge/grant.test.ts +62 -0
  18. package/studio/server/agent/bridge/grant.ts +84 -0
  19. package/studio/server/agent/bridge/routes.test.ts +151 -0
  20. package/studio/server/agent/bridge/routes.ts +148 -0
  21. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  22. package/studio/server/agent/conversation.test.ts +84 -0
  23. package/studio/server/agent/conversation.ts +110 -0
  24. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  25. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  26. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  27. package/studio/server/agent/harness/claude/ask.ts +114 -0
  28. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  29. package/studio/server/agent/harness/claude/command.ts +63 -0
  30. package/studio/server/agent/harness/claude/events.ts +215 -0
  31. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  32. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  33. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  34. package/studio/server/agent/harness/claude/skills.ts +43 -0
  35. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  36. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  37. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  38. package/studio/server/agent/harness/codex/ask.ts +181 -0
  39. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  40. package/studio/server/agent/harness/codex/command.ts +43 -0
  41. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  42. package/studio/server/agent/harness/codex/events.ts +132 -0
  43. package/studio/server/agent/harness/codex/exec.ts +113 -0
  44. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  45. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  46. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  47. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  48. package/studio/server/agent/harness/codex/models.ts +211 -0
  49. package/studio/server/agent/harness/codex/skills.ts +33 -0
  50. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  51. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  52. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  53. package/studio/server/agent/harness/gateway/token.ts +166 -0
  54. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  55. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  56. package/studio/server/agent/harness/process.test.ts +46 -0
  57. package/studio/server/agent/harness/process.ts +104 -0
  58. package/studio/server/agent/harness/registry.ts +37 -0
  59. package/studio/server/agent/harness/selection.test.ts +75 -0
  60. package/studio/server/agent/harness/selection.ts +77 -0
  61. package/studio/server/agent/harness/skills.test.ts +95 -0
  62. package/studio/server/agent/harness/skills.ts +100 -0
  63. package/studio/server/agent/layout.test.ts +95 -0
  64. package/studio/server/agent/notify.ts +12 -0
  65. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  66. package/studio/server/agent/prompts/ask.ts +34 -0
  67. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  68. package/studio/server/agent/prompts/turn.ts +57 -0
  69. package/studio/server/agent/routes.test.ts +176 -0
  70. package/studio/server/agent/routes.ts +214 -0
  71. package/studio/server/agent/run/completion.ts +193 -0
  72. package/studio/server/agent/run/coordinator.test.ts +337 -0
  73. package/studio/server/agent/run/coordinator.ts +104 -0
  74. package/studio/server/agent/run/live-state.ts +73 -0
  75. package/studio/server/agent/run/preparation.ts +162 -0
  76. package/studio/server/agent/run/transcript.test.ts +47 -0
  77. package/studio/server/agent/run/transcript.ts +30 -0
  78. package/studio/server/agent/run/usage.test.ts +46 -0
  79. package/studio/server/{state → agent/run}/usage.ts +4 -4
  80. package/studio/server/agent/stream.ts +37 -0
  81. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  82. package/studio/server/api-agent-loadout.test.ts +69 -0
  83. package/studio/server/api.ts +3 -155
  84. package/studio/server/index.ts +6 -5
  85. package/studio/server/state/comments.test.ts +29 -0
  86. package/studio/server/state/comments.ts +3 -3
  87. package/studio/server/state/settings.test.ts +78 -0
  88. package/studio/server/state/settings.ts +28 -1
  89. package/studio/shared/agent-effort.test.ts +12 -0
  90. package/studio/shared/agent-effort.ts +15 -0
  91. package/studio/shared/agent-models.test.ts +9 -0
  92. package/studio/shared/agent-models.ts +14 -0
  93. package/studio/shared/settings-values.test.ts +18 -0
  94. package/studio/shared/settings-values.ts +20 -0
  95. package/studio/shared/types.ts +58 -7
  96. package/src/connect-core.test.ts +0 -42
  97. package/src/connect-core.ts +0 -53
  98. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  99. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  100. package/studio/server/agent/bridge.ts +0 -188
  101. package/studio/server/agent/claude.ts +0 -678
  102. package/studio/server/agent/mock.ts +0 -186
  103. package/studio/server/agent/registry.ts +0 -29
  104. package/studio/server/agent/runner.ts +0 -487
  105. 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
- * harness-gateway.ts — point the agent harness at a custom Anthropic-compatible
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 '../../shared/types'
23
+ } from '../../../../shared/types'
24
24
 
25
- import { acquireGatewayToken } from './harness-token'
26
- import { readJson, removeState, writeJson } from './store'
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 || !cfg.baseUrl) return { ok: true, env: {} }
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
- audience = new URL(cfg.baseUrl).origin
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
  }