@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,133 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { HarnessTokenBroker, HarnessTokenError } from './token'
|
|
4
|
+
|
|
5
|
+
function jwt(audience: string, expiresAtMs = Date.now() + 3_600_000): string {
|
|
6
|
+
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url')
|
|
7
|
+
const payload = Buffer.from(
|
|
8
|
+
JSON.stringify({ aud: audience, exp: Math.floor(expiresAtMs / 1000) }),
|
|
9
|
+
).toString('base64url')
|
|
10
|
+
return `${header}.${payload}.signature`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('resolves static and audience-bound host-relayed gateway tokens', async () => {
|
|
14
|
+
const broker = new HarnessTokenBroker()
|
|
15
|
+
expect(
|
|
16
|
+
await broker.acquireGatewayToken(
|
|
17
|
+
{
|
|
18
|
+
enabled: true,
|
|
19
|
+
baseUrl: 'https://gateway.example',
|
|
20
|
+
auth: { mode: 'token', token: ' x ' },
|
|
21
|
+
},
|
|
22
|
+
'https://gateway.example',
|
|
23
|
+
),
|
|
24
|
+
).toBe('x')
|
|
25
|
+
|
|
26
|
+
const audience = 'https://host.example'
|
|
27
|
+
const token = jwt(audience)
|
|
28
|
+
expect(broker.setHostToken(audience, token)).toBe(true)
|
|
29
|
+
expect(broker.setHostToken(audience, jwt('https://other.example'))).toBe(false)
|
|
30
|
+
expect(
|
|
31
|
+
await broker.acquireGatewayToken(
|
|
32
|
+
{ enabled: true, baseUrl: audience, auth: { mode: 'host' } },
|
|
33
|
+
audience,
|
|
34
|
+
),
|
|
35
|
+
).toBe(token)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('reports a missing host relay as the concrete typed failure', async () => {
|
|
39
|
+
expect.assertions(2)
|
|
40
|
+
const broker = new HarnessTokenBroker()
|
|
41
|
+
try {
|
|
42
|
+
await broker.acquireGatewayToken(
|
|
43
|
+
{
|
|
44
|
+
enabled: true,
|
|
45
|
+
baseUrl: 'https://missing.example',
|
|
46
|
+
auth: { mode: 'host' },
|
|
47
|
+
},
|
|
48
|
+
'https://missing.example',
|
|
49
|
+
)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
expect(error).toBeInstanceOf(HarnessTokenError)
|
|
52
|
+
expect((error as HarnessTokenError).kind).toBe('host-token-needed')
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('mints with the exact audience and instance, caches, and coalesces concurrent callers', async () => {
|
|
57
|
+
const calls: { args: string[]; timeoutMs?: number }[] = []
|
|
58
|
+
const audience = 'https://gateway.example'
|
|
59
|
+
const token = jwt(audience)
|
|
60
|
+
const broker = new HarnessTokenBroker({
|
|
61
|
+
capture: async (_bin, args, _cwd, options) => {
|
|
62
|
+
calls.push({ args, timeoutMs: options?.timeoutMs })
|
|
63
|
+
await Promise.resolve()
|
|
64
|
+
return { code: 0, stdout: `${token}\n`, stderr: '' }
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
const config = {
|
|
68
|
+
enabled: true,
|
|
69
|
+
baseUrl: audience,
|
|
70
|
+
auth: { mode: 'mint' as const, instance: 'prod' },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
await Promise.all([
|
|
75
|
+
broker.acquireGatewayToken(config, audience),
|
|
76
|
+
broker.acquireGatewayToken(config, audience),
|
|
77
|
+
]),
|
|
78
|
+
).toEqual([token, token])
|
|
79
|
+
expect(await broker.acquireGatewayToken(config, audience)).toBe(token)
|
|
80
|
+
expect(calls).toEqual([
|
|
81
|
+
{
|
|
82
|
+
args: ['token', '--audience', audience, '--ttl', '3600', '--raw', '-i', 'prod'],
|
|
83
|
+
timeoutMs: 12_000,
|
|
84
|
+
},
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('isolates mint caches by audience and instance', async () => {
|
|
89
|
+
let calls = 0
|
|
90
|
+
const broker = new HarnessTokenBroker({
|
|
91
|
+
capture: async (_bin, args) => {
|
|
92
|
+
calls++
|
|
93
|
+
const audience = args[args.indexOf('--audience') + 1]
|
|
94
|
+
return { code: 0, stdout: jwt(audience), stderr: '' }
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
for (const [audience, instance] of [
|
|
99
|
+
['https://one.example', 'a'],
|
|
100
|
+
['https://one.example', 'b'],
|
|
101
|
+
['https://two.example', 'a'],
|
|
102
|
+
] as const)
|
|
103
|
+
await broker.acquireGatewayToken(
|
|
104
|
+
{ enabled: true, baseUrl: audience, auth: { mode: 'mint', instance } },
|
|
105
|
+
audience,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(calls).toBe(3)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('turns failed, blank, or wrong-audience mint output into a typed failure', async () => {
|
|
112
|
+
expect.assertions(6)
|
|
113
|
+
for (const result of [
|
|
114
|
+
{ code: 7, stdout: '', stderr: 'not signed in' },
|
|
115
|
+
{ code: 0, stdout: ' ', stderr: '' },
|
|
116
|
+
{ code: 0, stdout: jwt('https://other.example'), stderr: '' },
|
|
117
|
+
]) {
|
|
118
|
+
const broker = new HarnessTokenBroker({ capture: async () => result })
|
|
119
|
+
try {
|
|
120
|
+
await broker.acquireGatewayToken(
|
|
121
|
+
{
|
|
122
|
+
enabled: true,
|
|
123
|
+
baseUrl: 'https://gateway.example',
|
|
124
|
+
auth: { mode: 'mint' },
|
|
125
|
+
},
|
|
126
|
+
'https://gateway.example',
|
|
127
|
+
)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
expect(error).toBeInstanceOf(HarnessTokenError)
|
|
130
|
+
expect((error as HarnessTokenError).kind).toBe('mint-failed')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acquire bearer tokens for a harness model gateway.
|
|
3
|
+
*
|
|
4
|
+
* - mint: shell out to `astrale token`, cache the short-lived delegation, and
|
|
5
|
+
* coalesce concurrent requests for the same audience/instance
|
|
6
|
+
* - token: use the manually configured bearer
|
|
7
|
+
* - host: consume a token relayed by the embedding Astrale application
|
|
8
|
+
*
|
|
9
|
+
* Tokens only flow into the spawned harness child environment.
|
|
10
|
+
*/
|
|
11
|
+
import type { HarnessGatewayConfig } from '../../../../shared/types'
|
|
12
|
+
|
|
13
|
+
import { captureCommand, type CapturedCommand, type CaptureOptions } from '../process'
|
|
14
|
+
|
|
15
|
+
const MINT_TTL_SECONDS = 3600
|
|
16
|
+
const REFRESH_SKEW_MS = 5 * 60_000
|
|
17
|
+
|
|
18
|
+
interface CachedToken {
|
|
19
|
+
token: string
|
|
20
|
+
expiresAtMs: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type CaptureHarnessCommand = (
|
|
24
|
+
bin: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
cwd: string,
|
|
27
|
+
options?: CaptureOptions,
|
|
28
|
+
) => Promise<CapturedCommand>
|
|
29
|
+
|
|
30
|
+
export interface HarnessTokenBrokerOptions {
|
|
31
|
+
capture?: CaptureHarnessCommand
|
|
32
|
+
now?: () => number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class HarnessTokenError extends Error {
|
|
36
|
+
constructor(
|
|
37
|
+
message: string,
|
|
38
|
+
readonly kind: 'mint-failed' | 'host-token-needed' | 'config',
|
|
39
|
+
) {
|
|
40
|
+
super(message)
|
|
41
|
+
this.name = 'HarnessTokenError'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function jwtClaims(jwt: string): { audience?: string | string[]; expiresAtMs?: number } | null {
|
|
46
|
+
try {
|
|
47
|
+
const payload = Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')
|
|
48
|
+
const claims = JSON.parse(payload)
|
|
49
|
+
return {
|
|
50
|
+
audience:
|
|
51
|
+
typeof claims?.aud === 'string' ||
|
|
52
|
+
(Array.isArray(claims?.aud) &&
|
|
53
|
+
claims.aud.every((item: unknown) => typeof item === 'string'))
|
|
54
|
+
? claims.aud
|
|
55
|
+
: undefined,
|
|
56
|
+
expiresAtMs: typeof claims?.exp === 'number' ? claims.exp * 1000 : undefined,
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function audienceMatches(actual: string | string[] | undefined, expected: string): boolean {
|
|
64
|
+
return actual === expected || (Array.isArray(actual) && actual.includes(expected))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class HarnessTokenBroker {
|
|
68
|
+
private readonly mintCache = new Map<string, CachedToken>()
|
|
69
|
+
private readonly hostTokens = new Map<string, CachedToken>()
|
|
70
|
+
private readonly inFlightMints = new Map<string, Promise<string>>()
|
|
71
|
+
private readonly capture: CaptureHarnessCommand
|
|
72
|
+
private readonly now: () => number
|
|
73
|
+
|
|
74
|
+
constructor(options: HarnessTokenBrokerOptions = {}) {
|
|
75
|
+
this.capture = options.capture ?? captureCommand
|
|
76
|
+
this.now = options.now ?? Date.now
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Relay a host-owned delegation token into the embedded Studio process. */
|
|
80
|
+
setHostToken(audience: string, token: string): boolean {
|
|
81
|
+
if (!audience || !token || token.split('.').length !== 3) return false
|
|
82
|
+
const claims = jwtClaims(token)
|
|
83
|
+
if (!claims || !audienceMatches(claims.audience, audience)) return false
|
|
84
|
+
const expiresAtMs = claims.expiresAtMs ?? this.now() + MINT_TTL_SECONDS * 1000
|
|
85
|
+
if (expiresAtMs <= this.now()) return false
|
|
86
|
+
this.hostTokens.set(audience, { token, expiresAtMs })
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Acquire the configured gateway bearer for one child-process invocation. */
|
|
91
|
+
async acquireGatewayToken(config: HarnessGatewayConfig, audience: string): Promise<string> {
|
|
92
|
+
switch (config.auth.mode) {
|
|
93
|
+
case 'token': {
|
|
94
|
+
const token = config.auth.token.trim()
|
|
95
|
+
if (!token) throw new HarnessTokenError('no token set for this gateway', 'config')
|
|
96
|
+
return token
|
|
97
|
+
}
|
|
98
|
+
case 'host':
|
|
99
|
+
return this.readHostToken(audience)
|
|
100
|
+
default:
|
|
101
|
+
return this.mintToken(audience, config.auth.instance)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private readHostToken(audience: string): string {
|
|
106
|
+
const cached = this.hostTokens.get(audience)
|
|
107
|
+
if (!cached || cached.expiresAtMs <= this.now())
|
|
108
|
+
throw new HarnessTokenError(
|
|
109
|
+
'no valid host-supplied token — the embedding Astrale app must provide one',
|
|
110
|
+
'host-token-needed',
|
|
111
|
+
)
|
|
112
|
+
return cached.token
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private mintToken(audience: string, instance?: string): Promise<string> {
|
|
116
|
+
const key = `${audience}\u0000${instance ?? ''}`
|
|
117
|
+
const cached = this.mintCache.get(key)
|
|
118
|
+
if (cached && cached.expiresAtMs - this.now() > REFRESH_SKEW_MS)
|
|
119
|
+
return Promise.resolve(cached.token)
|
|
120
|
+
|
|
121
|
+
const pending = this.inFlightMints.get(key)
|
|
122
|
+
if (pending) return pending
|
|
123
|
+
|
|
124
|
+
let mint!: Promise<string>
|
|
125
|
+
mint = this.mintAndCache(key, audience, instance).finally(() => {
|
|
126
|
+
if (this.inFlightMints.get(key) === mint) this.inFlightMints.delete(key)
|
|
127
|
+
})
|
|
128
|
+
this.inFlightMints.set(key, mint)
|
|
129
|
+
return mint
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async mintAndCache(key: string, audience: string, instance?: string): Promise<string> {
|
|
133
|
+
const args = ['token', '--audience', audience, '--ttl', String(MINT_TTL_SECONDS), '--raw']
|
|
134
|
+
if (instance) args.push('-i', instance)
|
|
135
|
+
const result = await this.capture('astrale', args, process.cwd(), { timeoutMs: 12_000 })
|
|
136
|
+
const token = result.code === 0 ? result.stdout.trim() : ''
|
|
137
|
+
const claims = token && token.split('.').length === 3 ? jwtClaims(token) : null
|
|
138
|
+
if (
|
|
139
|
+
!claims ||
|
|
140
|
+
!audienceMatches(claims.audience, audience) ||
|
|
141
|
+
(claims.expiresAtMs !== undefined && claims.expiresAtMs <= this.now())
|
|
142
|
+
)
|
|
143
|
+
throw new HarnessTokenError(
|
|
144
|
+
'could not mint a delegation token — is the instance reachable and are you signed in? (try `astrale login` / `astrale use <instance>`)',
|
|
145
|
+
'mint-failed',
|
|
146
|
+
)
|
|
147
|
+
this.mintCache.set(key, {
|
|
148
|
+
token,
|
|
149
|
+
expiresAtMs: claims.expiresAtMs ?? this.now() + MINT_TTL_SECONDS * 1000,
|
|
150
|
+
})
|
|
151
|
+
return token
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const defaultBroker = new HarnessTokenBroker()
|
|
156
|
+
|
|
157
|
+
export function setHostToken(audience: string, token: string): boolean {
|
|
158
|
+
return defaultBroker.setHostToken(audience, token)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function acquireGatewayToken(
|
|
162
|
+
config: HarnessGatewayConfig,
|
|
163
|
+
audience: string,
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
return defaultBroker.acquireGatewayToken(config, audience)
|
|
166
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { Comment } from '../../../../shared/types'
|
|
2
|
+
import type { AgentHarness, AgentTurnInput, AgentTurnResult, AskInput, AskResult } from '../adapter'
|
|
3
|
+
|
|
4
|
+
import { readComments } from '../../../state/comments'
|
|
5
|
+
import { applyMockDomainEdit } from './domain-edit'
|
|
6
|
+
|
|
7
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
8
|
+
if (signal.aborted) return Promise.resolve()
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const timer = setTimeout(resolve, ms)
|
|
11
|
+
signal.addEventListener(
|
|
12
|
+
'abort',
|
|
13
|
+
() => {
|
|
14
|
+
clearTimeout(timer)
|
|
15
|
+
resolve()
|
|
16
|
+
},
|
|
17
|
+
{ once: true },
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MockHarness implements AgentHarness {
|
|
23
|
+
id = 'mock'
|
|
24
|
+
label = 'Mock agent (free)'
|
|
25
|
+
capabilities = {
|
|
26
|
+
effortLevels: ['low', 'medium', 'high'],
|
|
27
|
+
accessLevels: ['workspace', 'full'],
|
|
28
|
+
ask: true,
|
|
29
|
+
loadout: false,
|
|
30
|
+
gateway: 'none',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
async isAvailable(): Promise<boolean> {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async run(input: AgentTurnInput): Promise<AgentTurnResult> {
|
|
38
|
+
const expectedModel = process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL
|
|
39
|
+
if (expectedModel && input.model !== expectedModel)
|
|
40
|
+
throw new Error(
|
|
41
|
+
`mock expected model ${expectedModel}, received ${input.model ?? '(default)'}`,
|
|
42
|
+
)
|
|
43
|
+
const mode = process.env.DOMAIN_STUDIO_MOCK_MODE || 'normal'
|
|
44
|
+
const extraDelay = Number(process.env.DOMAIN_STUDIO_MOCK_DELAY_MS || 0)
|
|
45
|
+
if ((mode === 'resumefail' || mode === 'resumefailafterevent') && input.sessionId) {
|
|
46
|
+
input.onEvent({ kind: 'status', text: 'resuming…' })
|
|
47
|
+
if (mode === 'resumefailafterevent')
|
|
48
|
+
input.onEvent({
|
|
49
|
+
kind: 'tool',
|
|
50
|
+
text: 'Edit',
|
|
51
|
+
tool: 'Edit',
|
|
52
|
+
target: 'schema/test.ts',
|
|
53
|
+
})
|
|
54
|
+
return {
|
|
55
|
+
sessionId: input.sessionId,
|
|
56
|
+
finalText: '',
|
|
57
|
+
isError: true,
|
|
58
|
+
errorMessage: 'mock: no conversation found with session id',
|
|
59
|
+
resumeRejected: true,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const store = readComments(input.root)
|
|
63
|
+
const open = store.comments.filter(
|
|
64
|
+
(comment) => comment.status === 'open' && comment.thread.at(-1)?.role !== 'author',
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
input.onEvent({ kind: 'status', text: 'session started' })
|
|
68
|
+
await sleep(250, input.signal)
|
|
69
|
+
if (extraDelay > 0) await sleep(extraDelay, input.signal)
|
|
70
|
+
if (mode === 'error') throw new Error('mock harness failure (test)')
|
|
71
|
+
input.onEvent({
|
|
72
|
+
kind: 'thinking',
|
|
73
|
+
text: `Reviewing ${open.length} open thread(s) and the current schema.`,
|
|
74
|
+
})
|
|
75
|
+
await sleep(300, input.signal)
|
|
76
|
+
input.onEvent({
|
|
77
|
+
kind: 'tool',
|
|
78
|
+
text: 'Read',
|
|
79
|
+
tool: 'Read',
|
|
80
|
+
target: '.domain-studio/comments.json',
|
|
81
|
+
})
|
|
82
|
+
await sleep(250, input.signal)
|
|
83
|
+
|
|
84
|
+
const seed = open[0]?.thread.at(-1)?.text ?? 'note'
|
|
85
|
+
const edit = input.signal.aborted ? null : applyMockDomainEdit(input.root, seed)
|
|
86
|
+
if (edit) {
|
|
87
|
+
input.onEvent({ kind: 'tool', text: 'Edit', tool: 'Edit', target: edit.file })
|
|
88
|
+
await sleep(300, input.signal)
|
|
89
|
+
}
|
|
90
|
+
input.onEvent({
|
|
91
|
+
kind: 'message',
|
|
92
|
+
text: edit
|
|
93
|
+
? `Added a \`${edit.prop}\` property to \`${edit.file}\` and answered the open threads.`
|
|
94
|
+
: 'Answered the open threads.',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const replyText = edit
|
|
98
|
+
? `Done — implemented this by adding \`${edit.prop}\` to \`${edit.file}\`. (mock agent)`
|
|
99
|
+
: 'Acknowledged. (mock agent)'
|
|
100
|
+
|
|
101
|
+
if ((mode === 'liveandblock' || mode === 'liveandblockdifferent') && open[0]) {
|
|
102
|
+
const bridge = input.mcpServers?.find((server) => server.name === 'domain-studio')
|
|
103
|
+
if (!bridge?.invoke) throw new Error('mock bridge grant is not invokable')
|
|
104
|
+
await bridge.invoke('reply_to_thread', {
|
|
105
|
+
commentId: open[0].id,
|
|
106
|
+
text: replyText,
|
|
107
|
+
resolve: true,
|
|
108
|
+
closeNote: 'mock live reply',
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const replied: Comment[] = open.map((comment) => ({
|
|
113
|
+
...comment,
|
|
114
|
+
status: 'closed',
|
|
115
|
+
thread: [
|
|
116
|
+
...comment.thread,
|
|
117
|
+
{
|
|
118
|
+
id: crypto.randomUUID(),
|
|
119
|
+
role: 'author' as const,
|
|
120
|
+
type: 'text' as const,
|
|
121
|
+
text: replyText,
|
|
122
|
+
},
|
|
123
|
+
...(mode === 'liveandblockdifferent'
|
|
124
|
+
? [
|
|
125
|
+
{
|
|
126
|
+
id: crypto.randomUUID(),
|
|
127
|
+
role: 'author' as const,
|
|
128
|
+
type: 'text' as const,
|
|
129
|
+
text: 'Additional final detail. (mock agent)',
|
|
130
|
+
},
|
|
131
|
+
]
|
|
132
|
+
: []),
|
|
133
|
+
],
|
|
134
|
+
}))
|
|
135
|
+
const machine = {
|
|
136
|
+
schemaVersion: store.schemaVersion,
|
|
137
|
+
comments: replied.map((comment) => ({
|
|
138
|
+
id: comment.id,
|
|
139
|
+
anchors: comment.anchors,
|
|
140
|
+
status: mode === 'openreply' ? 'open' : comment.status,
|
|
141
|
+
thread: comment.thread,
|
|
142
|
+
})),
|
|
143
|
+
}
|
|
144
|
+
const finalText =
|
|
145
|
+
mode === 'noblock'
|
|
146
|
+
? 'I reviewed the open threads and made the edit. (no machine-state block — resilience test)'
|
|
147
|
+
: mode === 'badblock'
|
|
148
|
+
? 'I made the edit.\n\n```json\n{ this is : not valid json, ]\n```\n'
|
|
149
|
+
: `I reviewed the open threads and made the edit.\n\n\`\`\`json\n${JSON.stringify(machine, null, 2)}\n\`\`\`\n`
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
sessionId: input.sessionId ?? 'mock-session',
|
|
153
|
+
finalText,
|
|
154
|
+
costUsd: 0,
|
|
155
|
+
numTurns: 1,
|
|
156
|
+
isError: false,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async ask(input: AskInput): Promise<AskResult> {
|
|
161
|
+
const expectedModel = process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL
|
|
162
|
+
if (expectedModel && input.model !== expectedModel)
|
|
163
|
+
return {
|
|
164
|
+
text: '',
|
|
165
|
+
isError: true,
|
|
166
|
+
errorMessage: `mock expected model ${expectedModel}, received ${input.model ?? '(default)'}`,
|
|
167
|
+
}
|
|
168
|
+
const expectedSession = process.env.DOMAIN_STUDIO_MOCK_EXPECT_SESSION
|
|
169
|
+
if (expectedSession && input.sessionId !== expectedSession)
|
|
170
|
+
return {
|
|
171
|
+
text: '',
|
|
172
|
+
isError: true,
|
|
173
|
+
errorMessage: `mock expected session ${expectedSession}, received ${input.sessionId ?? '(fresh)'}`,
|
|
174
|
+
}
|
|
175
|
+
const forked = input.sessionId ? `(forked from ${input.sessionId.slice(0, 8)}…) ` : '(fresh) '
|
|
176
|
+
const parts = [
|
|
177
|
+
forked,
|
|
178
|
+
'This is a mock answer to your side question. ',
|
|
179
|
+
'In a real run, the selected harness would answer from the inherited conversation context.',
|
|
180
|
+
]
|
|
181
|
+
let text = ''
|
|
182
|
+
for (const part of parts) {
|
|
183
|
+
if (input.signal.aborted) break
|
|
184
|
+
text += part
|
|
185
|
+
input.onDelta(part)
|
|
186
|
+
await sleep(180, input.signal)
|
|
187
|
+
}
|
|
188
|
+
return { text, isError: false }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
function identifier(text: string, fallback: string): string {
|
|
5
|
+
const words = text
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9 ]+/g, ' ')
|
|
8
|
+
.trim()
|
|
9
|
+
.split(/\s+/)
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.slice(0, 3)
|
|
12
|
+
if (words.length === 0) return fallback
|
|
13
|
+
return words
|
|
14
|
+
.map((word, index) => (index === 0 ? word : word[0].toUpperCase() + word.slice(1)))
|
|
15
|
+
.join('')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Apply the deterministic schema edit used by the local mock harness. */
|
|
19
|
+
export function applyMockDomainEdit(
|
|
20
|
+
root: string,
|
|
21
|
+
instruction: string,
|
|
22
|
+
): { file: string; prop: string } | null {
|
|
23
|
+
const schemaDir = join(root, 'schema')
|
|
24
|
+
if (!existsSync(schemaDir)) return null
|
|
25
|
+
const files = readdirSync(schemaDir).filter((file) => file.endsWith('.ts') && file !== 'index.ts')
|
|
26
|
+
const propName = identifier(instruction, 'agentNote')
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const absolute = join(schemaDir, file)
|
|
29
|
+
const source = readFileSync(absolute, 'utf8')
|
|
30
|
+
const props = source.indexOf('props: {')
|
|
31
|
+
if (props < 0) continue
|
|
32
|
+
let prop = propName
|
|
33
|
+
let suffix = 2
|
|
34
|
+
while (new RegExp(`\\b${prop}\\b\\s*:`).test(source)) prop = `${propName}${suffix++}`
|
|
35
|
+
const insertAt = props + 'props: {'.length
|
|
36
|
+
const line = `\n /** Added by the agent in response to a studio comment. */\n ${prop}: z.string().optional(),`
|
|
37
|
+
writeFileSync(absolute, source.slice(0, insertAt) + line + source.slice(insertAt))
|
|
38
|
+
return { file: `schema/${file}`, prop }
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { captureCommand } from './process'
|
|
7
|
+
|
|
8
|
+
const roots: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function hangingCommand(root: string): string {
|
|
15
|
+
const file = join(root, 'hang')
|
|
16
|
+
writeFileSync(file, '#!/usr/bin/env bun\nsetInterval(() => {}, 1000)\n')
|
|
17
|
+
chmodSync(file, 0o755)
|
|
18
|
+
return file
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('bounds hanging probes with a timeout', async () => {
|
|
22
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-harness-process-timeout-'))
|
|
23
|
+
roots.push(root)
|
|
24
|
+
const result = await captureCommand(hangingCommand(root), [], root, { timeoutMs: 30 })
|
|
25
|
+
expect(result).toMatchObject({
|
|
26
|
+
code: -1,
|
|
27
|
+
timedOut: true,
|
|
28
|
+
stderr: 'command timed out after 30ms',
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('lets setup cancellation abort an in-flight probe', async () => {
|
|
33
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-harness-process-abort-'))
|
|
34
|
+
roots.push(root)
|
|
35
|
+
const controller = new AbortController()
|
|
36
|
+
const pending = captureCommand(hangingCommand(root), [], root, {
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
timeoutMs: 5_000,
|
|
39
|
+
})
|
|
40
|
+
controller.abort()
|
|
41
|
+
expect(await pending).toMatchObject({
|
|
42
|
+
code: -1,
|
|
43
|
+
aborted: true,
|
|
44
|
+
stderr: 'canceled',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export interface CapturedCommand {
|
|
4
|
+
code: number
|
|
5
|
+
stdout: string
|
|
6
|
+
stderr: string
|
|
7
|
+
aborted?: boolean
|
|
8
|
+
timedOut?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CaptureOptions {
|
|
12
|
+
env?: Record<string, string>
|
|
13
|
+
signal?: AbortSignal
|
|
14
|
+
timeoutMs?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Merge overrides into the spawned harness only. */
|
|
18
|
+
export function childEnvironment(extra?: Record<string, string>): NodeJS.ProcessEnv | undefined {
|
|
19
|
+
return extra && Object.keys(extra).length ? { ...process.env, ...extra } : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Capture a short-lived harness command such as version or loadout discovery. */
|
|
23
|
+
export function captureCommand(
|
|
24
|
+
bin: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
cwd: string,
|
|
27
|
+
options: CaptureOptions = {},
|
|
28
|
+
): Promise<CapturedCommand> {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let stdout = ''
|
|
31
|
+
let stderr = ''
|
|
32
|
+
let settled = false
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
34
|
+
let onAbort = () => {}
|
|
35
|
+
const finish = (result: CapturedCommand) => {
|
|
36
|
+
if (settled) return
|
|
37
|
+
settled = true
|
|
38
|
+
if (timer) clearTimeout(timer)
|
|
39
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
40
|
+
resolve(result)
|
|
41
|
+
}
|
|
42
|
+
if (options.signal?.aborted) {
|
|
43
|
+
finish({ code: -1, stdout, stderr: 'canceled', aborted: true })
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
let child: ReturnType<typeof spawn>
|
|
47
|
+
try {
|
|
48
|
+
child = spawn(bin, args, {
|
|
49
|
+
cwd,
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
env: childEnvironment(options.env),
|
|
52
|
+
detached: process.platform !== 'win32',
|
|
53
|
+
})
|
|
54
|
+
} catch (error) {
|
|
55
|
+
finish({ code: -1, stdout, stderr: String(error) })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
onAbort = () => {
|
|
59
|
+
terminateProcessTree(child, 'SIGKILL')
|
|
60
|
+
finish({ code: -1, stdout, stderr: 'canceled', aborted: true })
|
|
61
|
+
}
|
|
62
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
63
|
+
const timeoutMs = options.timeoutMs ?? 15_000
|
|
64
|
+
if (timeoutMs > 0) {
|
|
65
|
+
timer = setTimeout(() => {
|
|
66
|
+
terminateProcessTree(child, 'SIGKILL')
|
|
67
|
+
finish({
|
|
68
|
+
code: -1,
|
|
69
|
+
stdout,
|
|
70
|
+
stderr: `command timed out after ${timeoutMs}ms`,
|
|
71
|
+
timedOut: true,
|
|
72
|
+
})
|
|
73
|
+
}, timeoutMs)
|
|
74
|
+
timer.unref?.()
|
|
75
|
+
}
|
|
76
|
+
child.stdout?.setEncoding('utf8')
|
|
77
|
+
child.stderr?.setEncoding('utf8')
|
|
78
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
79
|
+
stdout += chunk
|
|
80
|
+
})
|
|
81
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
82
|
+
stderr += chunk
|
|
83
|
+
})
|
|
84
|
+
child.on('error', (error) => finish({ code: -1, stdout, stderr: error.message }))
|
|
85
|
+
child.on('close', (code) => finish({ code: code ?? -1, stdout, stderr }))
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Terminate the whole harness process group so child commands cannot leak. */
|
|
90
|
+
export function terminateProcessTree(
|
|
91
|
+
child: ReturnType<typeof spawn>,
|
|
92
|
+
signal: NodeJS.Signals = 'SIGTERM',
|
|
93
|
+
): void {
|
|
94
|
+
try {
|
|
95
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal)
|
|
96
|
+
else child.kill(signal)
|
|
97
|
+
} catch {
|
|
98
|
+
try {
|
|
99
|
+
child.kill(signal)
|
|
100
|
+
} catch {
|
|
101
|
+
/* already gone */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|