@xingular/harness 0.0.0-stage → 0.1.1
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 +269 -2
- package/dist/cli.js +11510 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +338 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +407 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { connect } from 'node:net'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
8
|
+
import { createHarness, type Harness } from '../harness'
|
|
9
|
+
import type { RunEvent } from '../sessions/contracts'
|
|
10
|
+
import type { ToolPolicy } from '../sessions/policy'
|
|
11
|
+
import { loadRun } from '../sessions/runStore'
|
|
12
|
+
import type { Found } from '../plugins/discover'
|
|
13
|
+
import { rootWithProject, writeFile } from '../../tests/helpers'
|
|
14
|
+
import { parseCli } from './cli'
|
|
15
|
+
import { SOCKET_ENV, TOKEN_ENV, socketCall, type SocketConnection } from './client'
|
|
16
|
+
import { SessionSocket } from './socket'
|
|
17
|
+
|
|
18
|
+
const open: Harness[] = []
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
vi.useRealTimers()
|
|
21
|
+
for (const harness of open.splice(0)) await harness.close()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
async function setup(policy: ToolPolicy = {}) {
|
|
25
|
+
const root = rootWithProject()
|
|
26
|
+
const harness = createHarness({ dataRoot: root, stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), policy, piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' } })
|
|
27
|
+
open.push(harness)
|
|
28
|
+
const account = harness.accounts.create('Main', 'apiKey').accounts[0]
|
|
29
|
+
const events: RunEvent[] = []
|
|
30
|
+
const notices: Record<string, unknown>[] = []
|
|
31
|
+
harness.runs.listen((event) => void events.push(event))
|
|
32
|
+
harness.bus.on((notice) => void notices.push(notice))
|
|
33
|
+
const sessionFolder = join(root, 'sessions', 'p1', 'term-1')
|
|
34
|
+
const launch = await harness.terminals.prepare({ sessionId: 'term-1', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder, model: 'chosen-model', launch: { connections: 'Use mcp_list_tools.' } })
|
|
35
|
+
const connection: SocketConnection = { socket: String(launch.env[SOCKET_ENV]), token: String(launch.env[TOKEN_ENV]) }
|
|
36
|
+
/** One telemetry line, as the native extension sends it. */
|
|
37
|
+
const tell = (event: string, data: Record<string, unknown> = {}) =>
|
|
38
|
+
new Promise<any>((resolve, reject) => {
|
|
39
|
+
const stream = connect(connection.socket)
|
|
40
|
+
let buffer = ''
|
|
41
|
+
stream.on('connect', () => stream.write(`${JSON.stringify({ token: connection.token, event, data })}\n`))
|
|
42
|
+
stream.on('data', (chunk) => {
|
|
43
|
+
buffer += chunk.toString()
|
|
44
|
+
if (!buffer.includes('\n')) return
|
|
45
|
+
const reply = JSON.parse(buffer.slice(0, buffer.indexOf('\n')))
|
|
46
|
+
stream.end()
|
|
47
|
+
if (reply.error) reject(new Error(reply.error.message))
|
|
48
|
+
else resolve(reply.result)
|
|
49
|
+
})
|
|
50
|
+
stream.on('error', reject)
|
|
51
|
+
})
|
|
52
|
+
return { harness, root, account, events, notices, launch, connection, tell, sessionFolder }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('a pi terminal session', () => {
|
|
56
|
+
it('launches on the pinned account with compiled instructions, the extension, and the session socket', async () => {
|
|
57
|
+
const { launch, account, harness, sessionFolder, root } = await setup()
|
|
58
|
+
expect(launch.program).toBe('/opt/pi/bin/pi')
|
|
59
|
+
expect(launch.accountId).toBe(account.id)
|
|
60
|
+
expect(launch.args[0]).toBe('--append-system-prompt')
|
|
61
|
+
expect(launch.args[1]).toContain('# Xingular Harness context')
|
|
62
|
+
expect(launch.args[1]).toContain('agentx-wiki')
|
|
63
|
+
// Pi's own discovery is off; its command's built-in extensions are named, and the harness's comes last.
|
|
64
|
+
expect(launch.args.slice(2)).toEqual([
|
|
65
|
+
'--model', 'chosen-model', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes',
|
|
66
|
+
'--extension', 'builtin:llama.cpp', '--extension', 'builtin:codemode', '--extension', 'builtin:tool-search', '--extension', 'builtin:mcp',
|
|
67
|
+
'--extension', '/opt/harness/terminal-pi.js', '--session-dir', join(sessionFolder, 'pi')
|
|
68
|
+
])
|
|
69
|
+
expect(launch.env.PI_CODING_AGENT_DIR).toBe(join(harness.accounts.dir, account.id, 'home', 'pi'))
|
|
70
|
+
expect(String(launch.env.PATH).split(':')[0]).toBe(join(harness.stateDir, 'run', 'bin'))
|
|
71
|
+
expect(readFileSync(join(harness.stateDir, 'run', 'bin', 'agentx-wiki'), 'utf8')).toBe("#!/bin/sh\nexec '/opt/node' '/opt/harness/cli.js' wiki \"$@\"\n")
|
|
72
|
+
expect(readFileSync(join(harness.stateDir, 'run', 'bin', 'agentx-mcp'), 'utf8')).toContain("'/opt/harness/cli.js' mcp")
|
|
73
|
+
// The session keeps the pack it started with.
|
|
74
|
+
expect(JSON.parse(readFileSync(join(sessionFolder, 'context.json'), 'utf8')).profile).toBe('session')
|
|
75
|
+
expect(root).toBeTruthy()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('passes what the harness found as paths, the enabled registry items with them, and its own extension last', async () => {
|
|
79
|
+
const root = rootWithProject()
|
|
80
|
+
writeFile(join(root, 'registry/skills/release/SKILL.md'), '# Release\n')
|
|
81
|
+
writeFile(join(root, 'registry/prompts/review.md'), 'Review this.\n')
|
|
82
|
+
writeFile(join(root, 'config/registry-enabled.json'), JSON.stringify({ p1: ['skills/release', 'prompts/review'] }))
|
|
83
|
+
writeFile(join(root, '.pi/extensions/local.ts'), 'export default function () {}\n')
|
|
84
|
+
const item = (kind: Found['kind'], path: string, origin: Found['origin'], enabled = true): Found => ({ kind, path, origin, project: origin === 'project', packageRoot: null, source: null, enabled })
|
|
85
|
+
const found = [
|
|
86
|
+
item('extension', join(root, '.pi/extensions/local.ts'), 'project'),
|
|
87
|
+
item('extension', '/shared/extensions/mine.ts', 'shared'),
|
|
88
|
+
item('extension', '/shared/extensions/off.ts', 'shared', false),
|
|
89
|
+
item('extension', 'builtin:mcp', 'builtin'),
|
|
90
|
+
item('skill', '/shared/skills/alpha/SKILL.md', 'shared'),
|
|
91
|
+
item('prompt', '/shared/prompts/p.md', 'shared'),
|
|
92
|
+
item('theme', '/shared/themes/dark.json', 'shared')
|
|
93
|
+
]
|
|
94
|
+
const harness = createHarness({ dataRoot: root, stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' }, resolver: async () => found })
|
|
95
|
+
open.push(harness)
|
|
96
|
+
const account = harness.accounts.create('Main', 'apiKey').accounts[0]
|
|
97
|
+
const prepare = async () => (await harness.terminals.prepare({ sessionId: 'term-1', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder: join(root, 'sessions', 'p1', 'term-1') })).args.slice(2)
|
|
98
|
+
|
|
99
|
+
const loaded = [
|
|
100
|
+
'--extension', '/shared/extensions/mine.ts', '--extension', 'builtin:mcp',
|
|
101
|
+
'--skill', join(root, 'registry/skills/release/SKILL.md'), '--skill', '/shared/skills/alpha/SKILL.md',
|
|
102
|
+
'--prompt-template', join(root, 'registry/prompts/review.md'), '--prompt-template', '/shared/prompts/p.md',
|
|
103
|
+
'--theme', '/shared/themes/dark.json',
|
|
104
|
+
'--extension', '/opt/harness/terminal-pi.js', '--session-dir', join(root, 'sessions', 'p1', 'term-1', 'pi')
|
|
105
|
+
]
|
|
106
|
+
const flags = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes']
|
|
107
|
+
expect(await prepare()).toEqual([...flags, ...loaded])
|
|
108
|
+
|
|
109
|
+
// The working folder's own code joins only once a person trusted it.
|
|
110
|
+
const code = (await harness.plugins.loading({ cwd: root, agentDir: join(harness.accounts.dir, account.id, 'home', 'pi') })).projectCode!
|
|
111
|
+
harness.plugins.trust.grant(code, root)
|
|
112
|
+
expect(await prepare()).toEqual([...flags, '--extension', join(root, '.pi/extensions/local.ts'), ...loaded])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('refuses to launch without the pi command, and never on another account', async () => {
|
|
116
|
+
const harness = createHarness({ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), piProgram: () => null })
|
|
117
|
+
open.push(harness)
|
|
118
|
+
harness.accounts.create('Main', 'apiKey')
|
|
119
|
+
await expect(harness.terminals.prepare({ sessionId: 's', cwd: '/tmp', sessionFolder: join(harness.env.root, 'sessions', 's') })).rejects.toMatchObject({ code: 'harness_unavailable' })
|
|
120
|
+
const other = createHarness({ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), piProgram: () => '/opt/pi' })
|
|
121
|
+
open.push(other)
|
|
122
|
+
other.accounts.create('Main', 'apiKey')
|
|
123
|
+
await expect(other.terminals.prepare({ sessionId: 's', accountId: 'missing-account', cwd: '/tmp', sessionFolder: join(other.env.root, 'sessions', 's') })).rejects.toThrow()
|
|
124
|
+
expect(other.terminals.has('s')).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('records what the person typed as a run and follows it to the end', async () => {
|
|
128
|
+
const { tell, events, harness, root, notices } = await setup()
|
|
129
|
+
await tell('startup', { phase: 'Pi extension loaded' })
|
|
130
|
+
await tell('ready', { nativeSessionId: '/history/native.jsonl' })
|
|
131
|
+
expect(notices).toContainEqual({ type: 'terminal.native', sessionId: 'term-1', nativeSessionId: '/history/native.jsonl' })
|
|
132
|
+
await tell('begin', { text: 'Fix the build' })
|
|
133
|
+
const runId = events[0].runId
|
|
134
|
+
expect(loadRun(root, runId)).toMatchObject({ mode: 'code', harness: 'pi', sessionType: 'terminal', sessionId: 'term-1', projectId: 'p1', goal: 'Fix the build', nativeSessionId: '/history/native.jsonl' })
|
|
135
|
+
await tell('tool', { phase: 'start', id: 't1', name: 'read', summary: '{"path":"a"}' })
|
|
136
|
+
await tell('tool', { phase: 'end', id: 't1', name: 'read', ok: true, durationMs: 4, resultChars: 12 })
|
|
137
|
+
await tell('message', { text: 'Done.' })
|
|
138
|
+
await tell('usage', { inputTokens: 5, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, requests: 1 })
|
|
139
|
+
await tell('settled', {})
|
|
140
|
+
expect(events.map((event) => event.type)).toEqual(['started', 'status.changed', 'message.completed', 'tool.started', 'tool.completed', 'message.completed', 'usage.updated', 'completed'])
|
|
141
|
+
expect(loadRun(root, runId).status).toBe('completed')
|
|
142
|
+
expect(harness.terminals.state('term-1')).toEqual({ ready: true, dirty: false, active: false })
|
|
143
|
+
// The run has the pack its session started with.
|
|
144
|
+
expect(JSON.parse(readFileSync(join(root, 'runs', runId, 'context.json'), 'utf8')).runId).toBe(runId)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('delivers an instruction through the extension and reserves its run first', async () => {
|
|
148
|
+
const { tell, harness, root, events } = await setup()
|
|
149
|
+
await tell('ready', {})
|
|
150
|
+
const sent = harness.terminals.send({ sessionId: 'term-1', runId: 'reserved', text: 'Run the tests' })
|
|
151
|
+
await vi.waitFor(async () => expect(await tell('poll')).toEqual({ command: { type: 'send', text: 'Run the tests' } }))
|
|
152
|
+
expect(loadRun(root, 'reserved')).toMatchObject({ status: 'queued', goal: 'Run the tests' })
|
|
153
|
+
await tell('begin', { text: 'Run the tests' })
|
|
154
|
+
expect(await sent).toEqual({ runId: 'reserved' })
|
|
155
|
+
expect(events[0]).toMatchObject({ runId: 'reserved', type: 'started' })
|
|
156
|
+
await expect(harness.terminals.send({ sessionId: 'term-1', text: 'Another' })).rejects.toMatchObject({ code: 'session_busy' })
|
|
157
|
+
await harness.runs.interrupt('reserved')
|
|
158
|
+
expect(await tell('poll')).toEqual({ command: { type: 'interrupt' } })
|
|
159
|
+
expect(loadRun(root, 'reserved').status).toBe('interrupted')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('sends a memory block that changed since the session started along with the next instruction', async () => {
|
|
163
|
+
const { tell, harness, connection } = await setup()
|
|
164
|
+
await tell('ready', {})
|
|
165
|
+
await socketCall(connection, 'memory.append', { block: 'project', text: 'Prefers pnpm.' })
|
|
166
|
+
const first = harness.terminals.send({ sessionId: 'term-1', text: 'Continue' })
|
|
167
|
+
let delivered = ''
|
|
168
|
+
await vi.waitFor(async () => {
|
|
169
|
+
const { command } = await tell('poll')
|
|
170
|
+
expect(command?.type).toBe('send')
|
|
171
|
+
delivered = command.text
|
|
172
|
+
})
|
|
173
|
+
await tell('begin', { text: delivered })
|
|
174
|
+
await first
|
|
175
|
+
expect(delivered.startsWith('[Memory updated since this session')).toBe(true)
|
|
176
|
+
expect(delivered).toContain('Prefers pnpm.')
|
|
177
|
+
expect(delivered.endsWith('\n\nContinue')).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('will not send over a draft the person is typing, and fails a delivery the native session never acknowledges', async () => {
|
|
181
|
+
const { tell, harness, root } = await setup()
|
|
182
|
+
await tell('ready', {})
|
|
183
|
+
harness.terminals.input('term-1', 'half a thought')
|
|
184
|
+
await expect(harness.terminals.send({ sessionId: 'term-1', runId: 'draft', text: 'Task' })).rejects.toMatchObject({ code: 'terminal_has_draft' })
|
|
185
|
+
expect(loadRun(root, 'draft').status).toBe('failed')
|
|
186
|
+
harness.terminals.input('term-1', '\u0015')
|
|
187
|
+
vi.useFakeTimers()
|
|
188
|
+
const send = harness.terminals.send({ sessionId: 'term-1', runId: 'lost', text: 'Task' })
|
|
189
|
+
const rejected = expect(send).rejects.toMatchObject({ code: 'delivery_uncertain' })
|
|
190
|
+
await vi.advanceTimersByTimeAsync(25_001)
|
|
191
|
+
await rejected
|
|
192
|
+
vi.useRealTimers()
|
|
193
|
+
expect(loadRun(root, 'lost').status).toBe('failed')
|
|
194
|
+
// A late acknowledgement does not start a run for an instruction reported as lost.
|
|
195
|
+
await expect(tell('begin', { text: 'Task' })).rejects.toThrow('will not be resent')
|
|
196
|
+
expect(await tell('poll')).toEqual({ command: { type: 'interrupt' } })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('asks before a native action unless the policy allows it, and reads never ask', async () => {
|
|
200
|
+
const { tell, events, harness } = await setup()
|
|
201
|
+
await tell('ready', {})
|
|
202
|
+
await tell('begin', { text: 'Edit' })
|
|
203
|
+
expect(await tell('policy', { tool: 'read', args: { path: 'a' } })).toEqual({ allowed: true })
|
|
204
|
+
expect(await tell('policy', { tool: 'bash', args: { command: 'rm -rf x' } })).toMatchObject({ allowed: false, requiresApproval: true })
|
|
205
|
+
const decision = tell('approval', { requestId: 'call-1', target: 'bash', action: '{"command":"rm -rf x"}', tool: 'bash', args: { command: 'rm -rf x' } })
|
|
206
|
+
await vi.waitFor(() => expect(events.some((event) => event.type === 'approval.requested')).toBe(true))
|
|
207
|
+
const request = events.find((event) => event.type === 'approval.requested')!
|
|
208
|
+
harness.runs.resolveApproval(request.runId, (request.payload as { approvalId: string }).approvalId, 'approved')
|
|
209
|
+
expect(await decision).toEqual({ decision: 'approved' })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('applies the application policy to native tool calls', async () => {
|
|
213
|
+
const seen: string[] = []
|
|
214
|
+
const { tell } = await setup({
|
|
215
|
+
scope: async (call) => {
|
|
216
|
+
seen.push(`${call.run.sessionId}:${call.tool}`)
|
|
217
|
+
return call.tool === 'bash' ? { allowed: false, reason: 'Outside the task.' } : { allowed: true }
|
|
218
|
+
},
|
|
219
|
+
authorize: async (call) => {
|
|
220
|
+
if (call.tool === 'write') throw new Error('Switched off.')
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
await tell('ready', {})
|
|
224
|
+
await tell('begin', { text: 'Go' })
|
|
225
|
+
expect(await tell('policy', { tool: 'bash', args: {} })).toEqual({ allowed: false, reason: 'Outside the task.' })
|
|
226
|
+
expect(await tell('policy', { tool: 'write', args: {} })).toEqual({ allowed: false, reason: 'Switched off.' })
|
|
227
|
+
expect(await tell('policy', { tool: 'edit', args: {} })).toMatchObject({ allowed: true })
|
|
228
|
+
expect(seen).toEqual(['term-1:bash', 'term-1:write', 'term-1:edit'])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('fails the turn and releases its decisions when the native process hangs up mid-question', async () => {
|
|
232
|
+
const { tell, events, harness, connection } = await setup()
|
|
233
|
+
await tell('ready', {})
|
|
234
|
+
await tell('begin', { text: 'task' })
|
|
235
|
+
const socket = connect(connection.socket)
|
|
236
|
+
await new Promise<void>((resolve, reject) => {
|
|
237
|
+
socket.once('connect', resolve)
|
|
238
|
+
socket.once('error', reject)
|
|
239
|
+
})
|
|
240
|
+
socket.write(`${JSON.stringify({ token: connection.token, event: 'question', data: { requestId: 'native-one', question: 'Continue?' } })}\n`)
|
|
241
|
+
await vi.waitFor(() => expect(events.some((event) => event.type === 'question.requested')).toBe(true))
|
|
242
|
+
socket.destroy()
|
|
243
|
+
await vi.waitFor(() => expect(events.some((event) => event.type === 'failed')).toBe(true))
|
|
244
|
+
expect((events.find((event) => event.type === 'failed')!.payload as { message: string }).message).toContain('callback disconnected')
|
|
245
|
+
expect(harness.runs.activeRunIds()).toEqual([])
|
|
246
|
+
expect(harness.terminals.state('term-1').ready).toBe(false)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('serves memory and connected apps to the session over the socket, until the terminal ends', async () => {
|
|
250
|
+
const { connection, harness, tell } = await setup()
|
|
251
|
+
await tell('ready', {})
|
|
252
|
+
expect(await socketCall<string>(connection, 'wiki.entrypoint')).toContain('# Xingular Harness context')
|
|
253
|
+
expect(await socketCall<string>(connection, 'wiki.list')).toContain('library')
|
|
254
|
+
const listed = await socketCall<{ tools: { name: string; parameters: { type: string } }[] }>(connection, 'tools.list')
|
|
255
|
+
expect(listed.tools.map((tool) => tool.name)).toEqual(['mcp_list_tools', 'mcp_call_tool'])
|
|
256
|
+
expect(listed.tools[0].parameters.type).toBe('object')
|
|
257
|
+
await expect(socketCall(connection, 'accounts.list')).rejects.toMatchObject({ code: 'unknown_method' })
|
|
258
|
+
await expect(socketCall({ ...connection, token: 'guess' }, 'wiki.list')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
259
|
+
await harness.terminals.dispose('term-1')
|
|
260
|
+
await expect(socketCall(connection, 'wiki.list')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
261
|
+
expect(harness.terminals.has('term-1')).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('writes the account credentials for the pi command only while a terminal runs', async () => {
|
|
265
|
+
const { harness, account } = await setup()
|
|
266
|
+
const auth = join(harness.accounts.dir, account.id, 'home', 'pi', 'auth.json')
|
|
267
|
+
expect(JSON.parse(readFileSync(auth, 'utf8'))).toEqual({})
|
|
268
|
+
await harness.terminals.dispose('term-1')
|
|
269
|
+
expect(() => readFileSync(auth, 'utf8')).toThrow()
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('the session socket', () => {
|
|
274
|
+
it('answers only calls it knows, for tokens it issued', async () => {
|
|
275
|
+
const socket = new SessionSocket(join(mkdtempSync(join(tmpdir(), 'sock-')), 's.sock'), { memory: (caller, call) => `${caller.runId}:${call}`, apps: async () => 'apps' })
|
|
276
|
+
const token = socket.issue({ sessionId: 's1', caller: () => ({ runId: 'r1' }), broker: null })
|
|
277
|
+
expect(await socket.handle(JSON.stringify({ token, call: 'wiki.search', params: { query: 'x' } }))).toEqual({ result: 'r1:wiki.search' })
|
|
278
|
+
expect(await socket.handle(JSON.stringify({ token, call: 'agents.create' }))).toMatchObject({ error: { code: 'unknown_method' } })
|
|
279
|
+
expect(await socket.handle(JSON.stringify({ token, call: 'mcp.list' }))).toMatchObject({ error: { code: 'mcp_unauthorized' } })
|
|
280
|
+
expect(await socket.handle(JSON.stringify({ token: 'guess', call: 'wiki.search' }))).toMatchObject({ error: { code: 'unauthorized' } })
|
|
281
|
+
expect(await socket.handle('not json')).toMatchObject({ error: { code: 'invalid_request' } })
|
|
282
|
+
socket.revokeSession('s1')
|
|
283
|
+
expect(await socket.handle(JSON.stringify({ token, call: 'wiki.search' }))).toMatchObject({ error: { code: 'unauthorized' } })
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('agentx-wiki command', () => {
|
|
288
|
+
it('maps commands onto memory and Wiki calls', () => {
|
|
289
|
+
expect(parseCli(['search', 'deploy', 'steps', '--kind', 'page'])).toEqual({ kind: 'call', call: 'wiki.search', params: { query: 'deploy steps', kinds: ['page'] } })
|
|
290
|
+
expect(parseCli(['read', '/projects/app/arch'])).toEqual({ kind: 'call', call: 'wiki.read', params: { id: '/projects/app/arch' } })
|
|
291
|
+
expect(parseCli(['list'])).toEqual({ kind: 'call', call: 'wiki.list', params: {} })
|
|
292
|
+
expect(parseCli(['skill', 'review'])).toEqual({ kind: 'call', call: 'wiki.skill', params: { name: 'review' } })
|
|
293
|
+
expect(parseCli(['memory', '--namespace', '/projects/app'])).toEqual({ kind: 'call', call: 'wiki.memory', params: { namespace: '/projects/app' } })
|
|
294
|
+
expect(parseCli(['recall', 'the', 'outage', '--scope', 'all'])).toEqual({ kind: 'call', call: 'recall.search', params: { query: 'the outage', scope: 'all' } })
|
|
295
|
+
expect(parseCli(['propose', '{"kind":"memory","title":"t","body":"b"}'])).toMatchObject({ kind: 'call', call: 'wiki.propose', params: { kind: 'memory' } })
|
|
296
|
+
expect(parseCli(['propose', '-'])).toEqual({ kind: 'stdin-propose' })
|
|
297
|
+
expect(parseCli(['block', 'append', 'project', 'Uses', 'pnpm.'])).toEqual({ kind: 'call', call: 'memory.append', params: { block: 'project', text: 'Uses pnpm.' } })
|
|
298
|
+
expect(parseCli(['block', 'replace', 'project', '--old', 'npm', '--new', 'pnpm'])).toEqual({ kind: 'call', call: 'memory.replace', params: { block: 'project', old: 'npm', new: 'pnpm' } })
|
|
299
|
+
expect(parseCli(['search'])).toEqual({ kind: 'help' })
|
|
300
|
+
expect(parseCli(['nonsense'])).toEqual({ kind: 'help' })
|
|
301
|
+
})
|
|
302
|
+
})
|