@astrale-os/cli 0.7.0-alpha.0 → 0.8.1-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 +7 -3
- package/dist/astrale.js +128 -98
- package/package.json +11 -5
- 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/elk-api-DVw5WDTH.js +1 -0
- package/studio/client/dist/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/studio/client/dist/assets/index-BQnJ5sgd.css +1 -0
- package/studio/client/dist/assets/index-CTbkDp3z.js +81 -0
- package/studio/client/dist/assets/index-ChOr3yP0.css +1 -0
- package/studio/client/dist/assets/index-Dl709Ra1.js +8 -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/studio/tsconfig.json +23 -0
- package/viewer/tsconfig.json +13 -0
- 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
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Domain Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CTbkDp3z.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BQnJ5sgd.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { registerDomain, unregisterDomain } from '../domain'
|
|
7
|
+
import { updateSettings } from '../state/settings'
|
|
8
|
+
import { runAsk } from './ask'
|
|
9
|
+
import { saveConversation } from './conversation'
|
|
10
|
+
|
|
11
|
+
const roots: string[] = []
|
|
12
|
+
const domainIds: string[] = []
|
|
13
|
+
const envBefore = {
|
|
14
|
+
harness: process.env.DOMAIN_STUDIO_HARNESS,
|
|
15
|
+
model: process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL,
|
|
16
|
+
session: process.env.DOMAIN_STUDIO_MOCK_EXPECT_SESSION,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function restore(name: keyof typeof envBefore, variable: string): void {
|
|
20
|
+
const value = envBefore[name]
|
|
21
|
+
if (value === undefined) delete process.env[variable]
|
|
22
|
+
else process.env[variable] = value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
restore('harness', 'DOMAIN_STUDIO_HARNESS')
|
|
27
|
+
restore('model', 'DOMAIN_STUDIO_MOCK_EXPECT_MODEL')
|
|
28
|
+
restore('session', 'DOMAIN_STUDIO_MOCK_EXPECT_SESSION')
|
|
29
|
+
while (domainIds.length) unregisterDomain(domainIds.pop()!)
|
|
30
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('Ask uses the active harness model and matching per-harness conversation', async () => {
|
|
34
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-ask-model-'))
|
|
35
|
+
roots.push(root)
|
|
36
|
+
mkdirSync(join(root, 'schema'))
|
|
37
|
+
writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
38
|
+
writeFileSync(join(root, 'domain.ts'), 'export default {}\n')
|
|
39
|
+
writeFileSync(join(root, 'schema/index.ts'), 'export const Test = {}\n')
|
|
40
|
+
const handle = registerDomain(root)!
|
|
41
|
+
domainIds.push(handle.id)
|
|
42
|
+
|
|
43
|
+
process.env.DOMAIN_STUDIO_HARNESS = 'mock'
|
|
44
|
+
process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL = 'mock-selected-model'
|
|
45
|
+
process.env.DOMAIN_STUDIO_MOCK_EXPECT_SESSION = 'mock-parent-session'
|
|
46
|
+
updateSettings(root, {
|
|
47
|
+
agentModels: {
|
|
48
|
+
mock: 'mock-selected-model',
|
|
49
|
+
claude: 'claude-other-model',
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
saveConversation(root, 'mock', {
|
|
53
|
+
sessionId: 'mock-parent-session',
|
|
54
|
+
turns: 2,
|
|
55
|
+
updatedAt: 'now',
|
|
56
|
+
})
|
|
57
|
+
saveConversation(root, 'claude', {
|
|
58
|
+
sessionId: 'claude-other-session',
|
|
59
|
+
turns: 4,
|
|
60
|
+
updatedAt: 'now',
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const deltas: string[] = []
|
|
64
|
+
const result = await runAsk(
|
|
65
|
+
handle,
|
|
66
|
+
{ question: 'Which context is active?' },
|
|
67
|
+
new AbortController().signal,
|
|
68
|
+
(delta) => deltas.push(delta),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(result.isError).toBe(false)
|
|
72
|
+
expect(result.text).toStartWith('(forked from mock-par…)')
|
|
73
|
+
expect(deltas.join('')).toBe(result.text)
|
|
74
|
+
})
|
|
@@ -10,15 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { AnchorRef } from '../../shared/types'
|
|
12
12
|
import type { DomainHandle } from '../domain'
|
|
13
|
-
import type { AskResult } from './
|
|
13
|
+
import type { AskResult } from './harness/adapter'
|
|
14
14
|
|
|
15
15
|
import { getBundle } from '../cache'
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { buildAskPrompt, buildAskSystemPrompt } from './
|
|
19
|
-
import {
|
|
20
|
-
import { forkableSession } from './runner'
|
|
21
|
-
import { studioSessionId } from './session-id'
|
|
16
|
+
import { readConversation } from './conversation'
|
|
17
|
+
import { resolveHarnessConfiguration } from './harness/selection'
|
|
18
|
+
import { buildAskPrompt, buildAskSystemPrompt } from './prompts/ask'
|
|
19
|
+
import { studioSessionId } from './telemetry'
|
|
22
20
|
|
|
23
21
|
export interface AskRequest {
|
|
24
22
|
anchor?: AnchorRef
|
|
@@ -32,15 +30,22 @@ export async function runAsk(
|
|
|
32
30
|
signal: AbortSignal,
|
|
33
31
|
onDelta: (text: string) => void,
|
|
34
32
|
): Promise<AskResult> {
|
|
35
|
-
const
|
|
33
|
+
const question = String(body?.question ?? '').trim()
|
|
34
|
+
if (!question) return { text: '', isError: true, errorMessage: 'question is required' }
|
|
35
|
+
const resolved = await resolveHarnessConfiguration(handle.root)
|
|
36
|
+
if (!resolved.ok)
|
|
37
|
+
return {
|
|
38
|
+
text: '',
|
|
39
|
+
isError: true,
|
|
40
|
+
errorMessage: `model gateway auth failed — ${resolved.error}`,
|
|
41
|
+
}
|
|
42
|
+
const { harness, model, effort, env } = resolved.configuration
|
|
36
43
|
if (!harness.ask)
|
|
37
44
|
return {
|
|
38
45
|
text: '',
|
|
39
46
|
isError: true,
|
|
40
47
|
errorMessage: `${harness.label} does not support side questions`,
|
|
41
48
|
}
|
|
42
|
-
const question = String(body?.question ?? '').trim()
|
|
43
|
-
if (!question) return { text: '', isError: true, errorMessage: 'question is required' }
|
|
44
49
|
|
|
45
50
|
const ref = body.anchor?.ref ?? '(no target)'
|
|
46
51
|
const bundle = await getBundle(handle.id)
|
|
@@ -51,22 +56,15 @@ export async function runAsk(
|
|
|
51
56
|
ir: bundle?.ir ?? null,
|
|
52
57
|
overlay: bundle?.overlay,
|
|
53
58
|
})
|
|
54
|
-
const settings = readSettings(handle.root)
|
|
55
|
-
const envResult = await resolveHarnessEnv(handle.root)
|
|
56
|
-
if (!envResult.ok)
|
|
57
|
-
return {
|
|
58
|
-
text: '',
|
|
59
|
-
isError: true,
|
|
60
|
-
errorMessage: `model gateway auth failed — ${envResult.error}`,
|
|
61
|
-
}
|
|
62
|
-
|
|
63
59
|
return harness.ask({
|
|
64
60
|
root: handle.root,
|
|
65
61
|
prompt,
|
|
66
62
|
appendSystemPrompt: buildAskSystemPrompt(),
|
|
67
|
-
sessionId:
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
sessionId: readConversation(handle.root, harness.id).sessionId,
|
|
64
|
+
model,
|
|
65
|
+
effort,
|
|
66
|
+
access: resolved.configuration.settings.agentAccess,
|
|
67
|
+
env: { ...env, ASTRALE_SESSION: studioSessionId(handle.id) },
|
|
70
68
|
signal,
|
|
71
69
|
onDelta,
|
|
72
70
|
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { forwardBridgeTool } from './client'
|
|
4
|
+
|
|
5
|
+
test('marks HTTP and transport failures as MCP tool errors', async () => {
|
|
6
|
+
const denied = await forwardBridgeTool(
|
|
7
|
+
'http://studio.test',
|
|
8
|
+
'token',
|
|
9
|
+
'reply',
|
|
10
|
+
{},
|
|
11
|
+
async () => new Response('denied', { status: 401 }),
|
|
12
|
+
)
|
|
13
|
+
expect(denied).toEqual({
|
|
14
|
+
content: [{ type: 'text', text: 'bridge error 401: denied' }],
|
|
15
|
+
isError: true,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const unavailable = await forwardBridgeTool(
|
|
19
|
+
'http://studio.test',
|
|
20
|
+
'token',
|
|
21
|
+
'reply',
|
|
22
|
+
{},
|
|
23
|
+
async () => {
|
|
24
|
+
throw new Error('offline')
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
expect(unavailable).toEqual({
|
|
28
|
+
content: [{ type: 'text', text: 'bridge call failed: offline' }],
|
|
29
|
+
isError: true,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('keeps the scoped bearer authoritative and returns successful content', async () => {
|
|
34
|
+
let sent: any
|
|
35
|
+
const response = await forwardBridgeTool(
|
|
36
|
+
'http://studio.test',
|
|
37
|
+
'scoped',
|
|
38
|
+
'threads',
|
|
39
|
+
{ token: 'forged' },
|
|
40
|
+
async (_url, init) => {
|
|
41
|
+
sent = JSON.parse(String(init?.body))
|
|
42
|
+
return new Response('{"threads":[]}')
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
expect(sent).toEqual({ token: 'scoped' })
|
|
46
|
+
expect(response).toEqual({
|
|
47
|
+
content: [{ type: 'text', text: '{"threads":[]}' }],
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface BridgeToolResult {
|
|
2
|
+
content: [{ type: 'text'; text: string }]
|
|
3
|
+
isError?: true
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type FetchBridge = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
|
7
|
+
|
|
8
|
+
function result(text: string, isError = false): BridgeToolResult {
|
|
9
|
+
return {
|
|
10
|
+
content: [{ type: 'text', text }],
|
|
11
|
+
...(isError ? { isError: true as const } : {}),
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Forward one stdio MCP tool call to the authenticated Studio bridge. */
|
|
16
|
+
export async function forwardBridgeTool(
|
|
17
|
+
base: string,
|
|
18
|
+
token: string,
|
|
19
|
+
route: string,
|
|
20
|
+
args: Record<string, unknown>,
|
|
21
|
+
fetchBridge: FetchBridge = fetch,
|
|
22
|
+
): Promise<BridgeToolResult> {
|
|
23
|
+
if (!base || !token) return result('bridge not configured', true)
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetchBridge(`${base}/${route}`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ ...args, token }),
|
|
29
|
+
})
|
|
30
|
+
const text = await response.text()
|
|
31
|
+
return response.ok
|
|
32
|
+
? result(text || 'ok')
|
|
33
|
+
: result(`bridge error ${response.status}: ${text}`, true)
|
|
34
|
+
} catch (error: any) {
|
|
35
|
+
return result(`bridge call failed: ${error?.message ?? error}`, true)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { DomainHandle } from '../../domain'
|
|
7
|
+
|
|
8
|
+
import { startBridge } from './grant'
|
|
9
|
+
|
|
10
|
+
const roots: string[] = []
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function domain(root: string, id: string): DomainHandle {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
root,
|
|
20
|
+
configFile: join(root, 'astrale.config.ts'),
|
|
21
|
+
domainFile: join(root, 'domain.ts'),
|
|
22
|
+
schemaDirName: 'schema',
|
|
23
|
+
schemaDir: join(root, 'schema'),
|
|
24
|
+
schemaIndex: join(root, 'schema/index.ts'),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('keeps its bearer out of the harness command line', () => {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-bridge-'))
|
|
30
|
+
roots.push(root)
|
|
31
|
+
|
|
32
|
+
const bridge = startBridge(domain(root, 'test-domain'), () => {})
|
|
33
|
+
const server = bridge.mcpServers[0]!
|
|
34
|
+
const configPath = server.args?.at(-1)
|
|
35
|
+
expect(server.name).toBe('domain-studio')
|
|
36
|
+
expect(server.approvalMode).toBe('approve')
|
|
37
|
+
expect(configPath).toBeTruthy()
|
|
38
|
+
|
|
39
|
+
const config = JSON.parse(readFileSync(configPath!, 'utf8')) as {
|
|
40
|
+
base: string
|
|
41
|
+
token: string
|
|
42
|
+
}
|
|
43
|
+
expect(server.args?.join(' ')).not.toContain(config.token)
|
|
44
|
+
expect(config.base).toContain('/api/domain/test-domain/agent/bridge')
|
|
45
|
+
expect(existsSync(configPath!)).toBe(true)
|
|
46
|
+
expect(statSync(configPath!).mode & 0o777).toBe(0o600)
|
|
47
|
+
|
|
48
|
+
bridge.dispose()
|
|
49
|
+
expect(existsSync(configPath!)).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('does not let an in-process tool replace its scoped bearer', async () => {
|
|
53
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-bridge-invoke-'))
|
|
54
|
+
roots.push(root)
|
|
55
|
+
const bridge = startBridge(domain(root, 'test-domain'), () => {})
|
|
56
|
+
const server = bridge.mcpServers[0]!
|
|
57
|
+
|
|
58
|
+
await expect(server.invoke?.('list_open_threads', { token: 'forged' })).resolves.toEqual({
|
|
59
|
+
threads: [],
|
|
60
|
+
})
|
|
61
|
+
bridge.dispose()
|
|
62
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { chmodSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { StudioEvent } from '../../../shared/types'
|
|
6
|
+
import type { DomainHandle } from '../../domain'
|
|
7
|
+
import type { HarnessMcpServer } from '../harness/adapter'
|
|
8
|
+
|
|
9
|
+
import { removeState, statePath, writeJson } from '../../state/store'
|
|
10
|
+
import { openBridgeSession } from './routes'
|
|
11
|
+
|
|
12
|
+
export interface Bridge {
|
|
13
|
+
enabled: boolean
|
|
14
|
+
mcpServers: HarnessMcpServer[]
|
|
15
|
+
onReply(callback: (commentId: string, text: string) => void): void
|
|
16
|
+
onProgress(callback: (text: string) => void): void
|
|
17
|
+
dispose(): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TOOL_ROUTES: Record<string, string> = {
|
|
21
|
+
list_open_threads: 'threads',
|
|
22
|
+
reply_to_thread: 'reply',
|
|
23
|
+
resolve_thread: 'resolve',
|
|
24
|
+
post_progress: 'progress',
|
|
25
|
+
raise_question: 'raise_question',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let studioPort = Number(process.env.PORT) || 4319
|
|
29
|
+
|
|
30
|
+
export function setBridgePort(port: number): void {
|
|
31
|
+
studioPort = port
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MCP_SERVER = join(import.meta.dir, 'stdio.ts')
|
|
35
|
+
const BIN = process.env.DOMAIN_STUDIO_BRIDGE_BUN || process.execPath
|
|
36
|
+
|
|
37
|
+
/** Mint the run-scoped MCP grant and its secret-bearing configuration file. */
|
|
38
|
+
export function startBridge(handle: DomainHandle, notify: (event: StudioEvent) => void): Bridge {
|
|
39
|
+
const token = randomUUID()
|
|
40
|
+
const fileId = randomUUID()
|
|
41
|
+
const session = openBridgeSession(handle, token, notify)
|
|
42
|
+
const base = `http://127.0.0.1:${studioPort}/api/domain/${encodeURIComponent(handle.id)}/agent/bridge`
|
|
43
|
+
const bridgeRel = `.cache/agent/bridge-${fileId}.json`
|
|
44
|
+
writeJson(handle.root, bridgeRel, { base, token })
|
|
45
|
+
const bridgeConfigPath = statePath(handle.root, bridgeRel)
|
|
46
|
+
chmodSync(bridgeConfigPath, 0o600)
|
|
47
|
+
|
|
48
|
+
const server: HarnessMcpServer = {
|
|
49
|
+
name: 'domain-studio',
|
|
50
|
+
command: BIN,
|
|
51
|
+
args: [MCP_SERVER, '--config', bridgeConfigPath],
|
|
52
|
+
required: true,
|
|
53
|
+
approvalMode: 'approve',
|
|
54
|
+
enabledTools: Object.keys(TOOL_ROUTES),
|
|
55
|
+
invoke: async (tool, args) => {
|
|
56
|
+
const route = TOOL_ROUTES[tool]
|
|
57
|
+
if (!route) throw new Error(`unknown bridge tool: ${tool}`)
|
|
58
|
+
const response = await session.invoke(route, args)
|
|
59
|
+
const result = await response.json()
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new Error(
|
|
62
|
+
typeof result?.error === 'string'
|
|
63
|
+
? result.error
|
|
64
|
+
: `bridge call failed: ${response.status}`,
|
|
65
|
+
)
|
|
66
|
+
return result
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
enabled: true,
|
|
72
|
+
mcpServers: [server],
|
|
73
|
+
onReply: session.onReply,
|
|
74
|
+
onProgress: session.onProgress,
|
|
75
|
+
dispose: () => {
|
|
76
|
+
session.dispose()
|
|
77
|
+
try {
|
|
78
|
+
removeState(handle.root, bridgeRel)
|
|
79
|
+
} catch {
|
|
80
|
+
/* best-effort */
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { DomainHandle } from '../../domain'
|
|
7
|
+
|
|
8
|
+
import { readComments, upsertComment } from '../../state/comments'
|
|
9
|
+
import { startBridge } from './grant'
|
|
10
|
+
import { handleBridge } from './routes'
|
|
11
|
+
|
|
12
|
+
const roots: string[] = []
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function domain(root: string, id: string): DomainHandle {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
root,
|
|
22
|
+
configFile: join(root, 'astrale.config.ts'),
|
|
23
|
+
domainFile: join(root, 'domain.ts'),
|
|
24
|
+
schemaDirName: 'schema',
|
|
25
|
+
schemaDir: join(root, 'schema'),
|
|
26
|
+
schemaIndex: join(root, 'schema/index.ts'),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('authorizes one domain and applies live thread mutations', async () => {
|
|
31
|
+
const rootA = mkdtempSync(join(tmpdir(), 'studio-bridge-a-'))
|
|
32
|
+
const rootB = mkdtempSync(join(tmpdir(), 'studio-bridge-b-'))
|
|
33
|
+
roots.push(rootA, rootB)
|
|
34
|
+
const domainA = domain(rootA, 'domain-a')
|
|
35
|
+
const domainB = domain(rootB, 'domain-b')
|
|
36
|
+
const awaiting = upsertComment(rootA, {
|
|
37
|
+
anchors: ['Test'],
|
|
38
|
+
anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
|
|
39
|
+
text: 'please answer',
|
|
40
|
+
})
|
|
41
|
+
upsertComment(rootA, {
|
|
42
|
+
anchors: ['Agent question'],
|
|
43
|
+
anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
|
|
44
|
+
text: 'already answered by author',
|
|
45
|
+
firstRole: 'author',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const notifications: string[] = []
|
|
49
|
+
const bridge = startBridge(domainA, (event) =>
|
|
50
|
+
notifications.push(`${event.type}:${'domainId' in event ? event.domainId : ''}`),
|
|
51
|
+
)
|
|
52
|
+
const configPath = bridge.mcpServers[0]!.args!.at(-1)!
|
|
53
|
+
const { token } = JSON.parse(readFileSync(configPath, 'utf8')) as { token: string }
|
|
54
|
+
const replies: string[] = []
|
|
55
|
+
const progress: string[] = []
|
|
56
|
+
bridge.onReply((id, text) => replies.push(`${id}:${text}`))
|
|
57
|
+
bridge.onProgress((text) => progress.push(text))
|
|
58
|
+
const request = new Request('http://studio.test')
|
|
59
|
+
|
|
60
|
+
const invalid = await handleBridge(domainA, 'reply', request, {
|
|
61
|
+
token: 'wrong',
|
|
62
|
+
commentId: awaiting.id,
|
|
63
|
+
text: 'forged',
|
|
64
|
+
})
|
|
65
|
+
const crossDomain = await handleBridge(domainB, 'reply', request, {
|
|
66
|
+
token,
|
|
67
|
+
commentId: awaiting.id,
|
|
68
|
+
text: 'forged',
|
|
69
|
+
})
|
|
70
|
+
expect([invalid.status, crossDomain.status]).toEqual([401, 401])
|
|
71
|
+
expect(readComments(rootA).comments[0]!.thread).toHaveLength(1)
|
|
72
|
+
|
|
73
|
+
const listed = await handleBridge(domainA, 'threads', request, { token })
|
|
74
|
+
expect(await listed.json()).toEqual({
|
|
75
|
+
threads: [
|
|
76
|
+
{
|
|
77
|
+
id: awaiting.id,
|
|
78
|
+
kind: 'comment',
|
|
79
|
+
anchor: 'class.Test',
|
|
80
|
+
file: null,
|
|
81
|
+
latest: 'please answer',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const replied = await handleBridge(domainA, 'reply', request, {
|
|
87
|
+
token,
|
|
88
|
+
commentId: awaiting.id,
|
|
89
|
+
text: 'done',
|
|
90
|
+
resolve: true,
|
|
91
|
+
closeNote: 'complete',
|
|
92
|
+
})
|
|
93
|
+
await handleBridge(domainA, 'progress', request, { token, text: 'working' })
|
|
94
|
+
const raised = await handleBridge(domainA, 'raise_question', request, {
|
|
95
|
+
token,
|
|
96
|
+
ref: 'class.Test.property.value',
|
|
97
|
+
text: 'Which value?',
|
|
98
|
+
options: ['A', 'B'],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(await replied.json()).toEqual({ ok: true, resolved: true })
|
|
102
|
+
expect(await raised.json()).toMatchObject({ ok: true })
|
|
103
|
+
const comments = readComments(rootA).comments
|
|
104
|
+
expect(comments[0]).toMatchObject({
|
|
105
|
+
id: awaiting.id,
|
|
106
|
+
status: 'closed',
|
|
107
|
+
closeNote: 'complete',
|
|
108
|
+
})
|
|
109
|
+
expect(comments[0]!.thread.at(-1)).toMatchObject({ role: 'author', text: 'done' })
|
|
110
|
+
expect(comments.at(-1)).toMatchObject({
|
|
111
|
+
kind: 'question',
|
|
112
|
+
status: 'open',
|
|
113
|
+
thread: [{ role: 'author', type: 'choice', text: 'Which value?', options: ['A', 'B'] }],
|
|
114
|
+
})
|
|
115
|
+
expect(replies).toEqual([`${awaiting.id}:done`])
|
|
116
|
+
expect(progress).toEqual(['working'])
|
|
117
|
+
expect(notifications).toEqual(['comments:domain-a', 'comments:domain-a'])
|
|
118
|
+
|
|
119
|
+
bridge.dispose()
|
|
120
|
+
const expired = await handleBridge(domainA, 'threads', request, { token })
|
|
121
|
+
expect(expired.status).toBe(401)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('returns committed success when UI notification delivery fails', async () => {
|
|
125
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-bridge-notify-'))
|
|
126
|
+
roots.push(root)
|
|
127
|
+
const handle = domain(root, 'domain-notify')
|
|
128
|
+
const comment = upsertComment(root, {
|
|
129
|
+
anchors: ['Test'],
|
|
130
|
+
anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
|
|
131
|
+
text: 'reply once',
|
|
132
|
+
})
|
|
133
|
+
const bridge = startBridge(handle, () => {
|
|
134
|
+
throw new Error('listener failed')
|
|
135
|
+
})
|
|
136
|
+
const configPath = bridge.mcpServers[0]!.args!.at(-1)!
|
|
137
|
+
const { token } = JSON.parse(readFileSync(configPath, 'utf8')) as { token: string }
|
|
138
|
+
|
|
139
|
+
const response = await handleBridge(handle, 'reply', new Request('http://studio.test'), {
|
|
140
|
+
token,
|
|
141
|
+
commentId: comment.id,
|
|
142
|
+
text: 'committed',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(response.status).toBe(200)
|
|
146
|
+
expect(readComments(root).comments[0]!.thread.map((entry) => entry.text)).toEqual([
|
|
147
|
+
'reply once',
|
|
148
|
+
'committed',
|
|
149
|
+
])
|
|
150
|
+
bridge.dispose()
|
|
151
|
+
})
|