@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,112 @@
|
|
|
1
|
+
// The `/plugins` command of a terminal session, without Pi: what it lists, what it offers for
|
|
2
|
+
// each row, and the conversation it has with the person. It asks through whatever interface it
|
|
3
|
+
// is given (Pi's own dialogs in a terminal) and changes things through one function, so the
|
|
4
|
+
// same flow is tested without a terminal.
|
|
5
|
+
import type { Attachment } from '../plugins/attachments'
|
|
6
|
+
import type { Contribution, Plugin } from '../plugins/types'
|
|
7
|
+
|
|
8
|
+
export interface MenuUi {
|
|
9
|
+
select(title: string, options: string[]): Promise<string | undefined>
|
|
10
|
+
confirm(title: string, message: string): Promise<boolean>
|
|
11
|
+
notify(message: string, level?: 'info' | 'warning' | 'error'): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `plugins.list`, `plugins.attach`, `plugins.detach`, `plugins.trust`, `plugins.untrust`. */
|
|
15
|
+
export type MenuCall = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
|
16
|
+
|
|
17
|
+
export interface MenuRow {
|
|
18
|
+
/** The plugin's id, or one contribution's. */
|
|
19
|
+
id: string
|
|
20
|
+
label: string
|
|
21
|
+
on: boolean
|
|
22
|
+
/** The plugin it is, or belongs to. */
|
|
23
|
+
plugin: Plugin
|
|
24
|
+
/** Set when the row is the whole plugin. */
|
|
25
|
+
whole: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const here = (attachment: Attachment, projectId: string | null): boolean => {
|
|
29
|
+
if (projectId && attachment.on.projectIds.includes(projectId)) return true
|
|
30
|
+
if (projectId && attachment.off.projectIds.includes(projectId)) return false
|
|
31
|
+
return attachment.global
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mark = (on: boolean): string => (on ? '●' : '○')
|
|
35
|
+
|
|
36
|
+
/** What can be switched or trusted from here: each found or installed plugin, and each item of the rest that has a switch. */
|
|
37
|
+
export function menuRows(plugins: readonly Plugin[], projectId: string | null): MenuRow[] {
|
|
38
|
+
const rows: MenuRow[] = []
|
|
39
|
+
for (const plugin of plugins) {
|
|
40
|
+
if (plugin.attachment) {
|
|
41
|
+
const on = here(plugin.attachment, projectId)
|
|
42
|
+
rows.push({ id: plugin.id, plugin, whole: true, on, label: `${mark(on)} ${plugin.title}${plugin.trust === 'untrusted' ? ' · code not trusted' : plugin.runsCode ? ' · runs code' : ''}` })
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (plugin.trust !== 'none') rows.push({ id: plugin.id, plugin, whole: true, on: plugin.trust === 'trusted', label: `${mark(plugin.trust === 'trusted')} ${plugin.title} · ${plugin.trust === 'trusted' ? 'code trusted' : 'code not trusted'}` })
|
|
46
|
+
for (const entry of plugin.contributions) {
|
|
47
|
+
if (!entry.attachment) continue
|
|
48
|
+
rows.push({ id: entry.id, plugin, whole: false, on: entry.state === 'on', label: `${mark(entry.state === 'on')} ${entry.name} · ${kindWord(entry)} in ${plugin.title}` })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return rows
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const kindWord = (entry: Contribution): string => (entry.kind === 'connection' ? 'connected app' : entry.kind === 'code' ? 'extension' : entry.kind)
|
|
55
|
+
|
|
56
|
+
const SWITCH_OFF_PROJECT = 'Switch off for this project'
|
|
57
|
+
const SWITCH_ON_PROJECT = 'Switch on for this project'
|
|
58
|
+
const SWITCH_OFF_EVERYWHERE = 'Switch off everywhere'
|
|
59
|
+
const SWITCH_ON_EVERYWHERE = 'Switch on everywhere'
|
|
60
|
+
const TRUST = 'Trust its code'
|
|
61
|
+
const UNTRUST = 'Stop trusting its code'
|
|
62
|
+
const BACK = 'Back'
|
|
63
|
+
const DONE = 'Done'
|
|
64
|
+
|
|
65
|
+
/** What a person can do with one row. A whole plugin whose code waits for trust can be trusted here. */
|
|
66
|
+
export function rowActions(row: MenuRow, projectId: string | null): string[] {
|
|
67
|
+
const switchable = row.whole ? row.plugin.attachment !== null : true
|
|
68
|
+
const actions: string[] = []
|
|
69
|
+
if (switchable) {
|
|
70
|
+
if (projectId) actions.push(row.on ? SWITCH_OFF_PROJECT : SWITCH_ON_PROJECT)
|
|
71
|
+
actions.push(row.on ? SWITCH_OFF_EVERYWHERE : SWITCH_ON_EVERYWHERE)
|
|
72
|
+
}
|
|
73
|
+
if (row.whole && row.plugin.code) actions.push(row.plugin.trust === 'untrusted' ? TRUST : UNTRUST)
|
|
74
|
+
return [...actions, BACK]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Runs the command: list, pick a row, pick what to do, until the person is done. Nothing changes
|
|
79
|
+
* without a choice made here, and trusting code asks once more with the files in view.
|
|
80
|
+
*/
|
|
81
|
+
export async function runPluginsMenu(ui: MenuUi, call: MenuCall, projectId: string | null): Promise<void> {
|
|
82
|
+
let changed = false
|
|
83
|
+
for (;;) {
|
|
84
|
+
const rows = menuRows(await call<Plugin[]>('plugins.list'), projectId)
|
|
85
|
+
if (!rows.length) {
|
|
86
|
+
ui.notify('There is nothing to switch or trust here yet.', 'info')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const picked = await ui.select(`Plugins${changed ? ' (changes apply when this session restarts)' : ''}`, [...rows.map((row) => row.label), DONE])
|
|
90
|
+
const row = rows.find((candidate) => candidate.label === picked)
|
|
91
|
+
if (!row) break
|
|
92
|
+
const action = await ui.select(`${row.plugin.title}: ${row.plugin.description || 'No description.'}`, rowActions(row, projectId))
|
|
93
|
+
if (!action || action === BACK) continue
|
|
94
|
+
try {
|
|
95
|
+
if (action === TRUST || action === UNTRUST) {
|
|
96
|
+
const code = row.plugin.code!
|
|
97
|
+
if (action === TRUST) {
|
|
98
|
+
const sure = await ui.confirm('Trust this code?', `${code.files.join('\n')}\n\nFingerprint ${code.hash.slice(0, 12)}. It runs inside the session and can do anything this app can, including reading your files and credentials.`)
|
|
99
|
+
if (!sure) continue
|
|
100
|
+
}
|
|
101
|
+
await call(action === TRUST ? 'plugins.trust' : 'plugins.untrust', { hash: code.hash })
|
|
102
|
+
} else {
|
|
103
|
+
const on = action === SWITCH_ON_PROJECT || action === SWITCH_ON_EVERYWHERE
|
|
104
|
+
await call(on ? 'plugins.attach' : 'plugins.detach', { id: row.id, everywhere: action === SWITCH_ON_EVERYWHERE || action === SWITCH_OFF_EVERYWHERE })
|
|
105
|
+
}
|
|
106
|
+
changed = true
|
|
107
|
+
} catch (error) {
|
|
108
|
+
ui.notify(error instanceof Error ? error.message : String(error), 'error')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (changed) ui.notify('Saved. It applies when this session restarts.', 'info')
|
|
112
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The session socket: how a process the harness did not start in-process (the `pi` command in
|
|
2
|
+
// a terminal, with its extension and the `agentx-wiki` / `agentx-mcp` commands) reaches the
|
|
3
|
+
// session it belongs to. Owner-only Unix socket, newline-delimited JSON, one request per
|
|
4
|
+
// connection. Each terminal session holds a random token bound to its caller, its connected-apps
|
|
5
|
+
// grant, and its controller; the token stops working when the terminal ends.
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { chmodSync, mkdirSync, rmSync } from 'node:fs'
|
|
8
|
+
import { createServer, type Server, type Socket } from 'node:net'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { log } from '../core/bus'
|
|
13
|
+
import { HarnessError, toErrorShape } from '../core/errors'
|
|
14
|
+
import { newId } from '../core/fs'
|
|
15
|
+
import type { BrokerSession } from '../mcp/service'
|
|
16
|
+
import type { CallerContext } from '../memory/caller'
|
|
17
|
+
|
|
18
|
+
/** What a token stands for. */
|
|
19
|
+
export interface SocketGrant {
|
|
20
|
+
sessionId: string
|
|
21
|
+
/** Who memory, Wiki, and recall calls are made as. The run is filled in while a turn is live. */
|
|
22
|
+
caller: () => CallerContext
|
|
23
|
+
/** The session's connected-apps grant, when it has one. */
|
|
24
|
+
broker: BrokerSession | null
|
|
25
|
+
/** Telemetry from the native process, and what to do when it hangs up mid-request. */
|
|
26
|
+
terminal?: { accept(event: string, data: Record<string, unknown>): Promise<unknown>; disconnected(): void }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SocketHandlers {
|
|
30
|
+
/** `wiki.*`, `memory.*`, `recall.*`. */
|
|
31
|
+
memory(caller: CallerContext, call: string, params: Record<string, unknown>): string
|
|
32
|
+
/** `mcp.list`, `mcp.call`, `mcp.revision`, `tools.list`, `tools.call`. */
|
|
33
|
+
apps(session: BrokerSession, call: string, params: Record<string, unknown>): Promise<unknown>
|
|
34
|
+
/**
|
|
35
|
+
* `plugins.*`. `control` is true only for the session's own extension, which holds a key the
|
|
36
|
+
* session's shell never sees; anything that changes what loads needs it.
|
|
37
|
+
*/
|
|
38
|
+
plugins?(grant: SocketGrant, call: string, params: Record<string, unknown>, control: boolean): Promise<unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_REQUEST_BYTES = 1024 * 1024
|
|
42
|
+
/** macOS refuses a socket path longer than 103 bytes. */
|
|
43
|
+
const MAX_SOCKET_PATH = 100
|
|
44
|
+
|
|
45
|
+
export function socketPath(stateDir: string): string {
|
|
46
|
+
const path = join(stateDir, 'run', 'harness.sock')
|
|
47
|
+
if (Buffer.byteLength(path) <= MAX_SOCKET_PATH) return path
|
|
48
|
+
return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class SessionSocket {
|
|
52
|
+
private server: Server | null = null
|
|
53
|
+
private listening: Promise<void> | null = null
|
|
54
|
+
private readonly grants = new Map<string, SocketGrant>()
|
|
55
|
+
/**
|
|
56
|
+
* A second key per token, handed out once. The token is in the session's environment, where a
|
|
57
|
+
* command the model runs can read it; this key is claimed by the session's extension as it
|
|
58
|
+
* loads, before any model turn, and kept in its memory only.
|
|
59
|
+
*/
|
|
60
|
+
private readonly controls = new Map<string, string>()
|
|
61
|
+
private readonly clients = new Set<Socket>()
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
readonly path: string,
|
|
65
|
+
private readonly handlers: SocketHandlers
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
issue(grant: SocketGrant): string {
|
|
69
|
+
const token = `${newId()}${newId()}`.replaceAll('-', '')
|
|
70
|
+
this.grants.set(token, grant)
|
|
71
|
+
return token
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
revoke(token: string): void {
|
|
75
|
+
this.grants.delete(token)
|
|
76
|
+
this.controls.delete(token)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
revokeSession(sessionId: string): void {
|
|
80
|
+
for (const [token, grant] of this.grants) if (grant.sessionId === sessionId) this.revoke(token)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Runs one request line and returns the reply line's object. */
|
|
84
|
+
async handle(line: string, connection?: { waiting: (grant: SocketGrant | null) => void }): Promise<{ result: unknown } | { error: unknown }> {
|
|
85
|
+
try {
|
|
86
|
+
let request: Record<string, unknown>
|
|
87
|
+
try {
|
|
88
|
+
request = JSON.parse(line) as Record<string, unknown>
|
|
89
|
+
} catch {
|
|
90
|
+
throw new HarnessError('invalid_request', 'The request is not JSON.')
|
|
91
|
+
}
|
|
92
|
+
const token = typeof request.token === 'string' ? request.token : ''
|
|
93
|
+
const grant = this.grants.get(token)
|
|
94
|
+
if (!grant) throw new HarnessError('unauthorized', 'This session token is not valid. It ends when its terminal session ends.')
|
|
95
|
+
if (typeof request.event === 'string') {
|
|
96
|
+
if (!grant.terminal) throw new HarnessError('unknown_method', 'This session has no terminal controller.')
|
|
97
|
+
connection?.waiting(grant)
|
|
98
|
+
try {
|
|
99
|
+
return { result: (await grant.terminal.accept(request.event, (request.data ?? {}) as Record<string, unknown>)) ?? {} }
|
|
100
|
+
} finally {
|
|
101
|
+
connection?.waiting(null)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const call = typeof request.call === 'string' ? request.call : ''
|
|
105
|
+
const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>
|
|
106
|
+
if (['wiki.', 'memory.', 'recall.'].some((prefix) => call.startsWith(prefix))) return { result: this.handlers.memory(grant.caller(), call, params) }
|
|
107
|
+
if (['mcp.', 'tools.'].some((prefix) => call.startsWith(prefix))) {
|
|
108
|
+
if (!grant.broker) throw new HarnessError('mcp_unauthorized', 'Connected apps are not available to this session.')
|
|
109
|
+
return { result: await this.handlers.apps(grant.broker, call, params) }
|
|
110
|
+
}
|
|
111
|
+
if (call === 'control.claim') {
|
|
112
|
+
if (this.controls.has(token)) throw new HarnessError('unauthorized', "This session's control key was already claimed.")
|
|
113
|
+
const key = `${newId()}${newId()}`.replaceAll('-', '')
|
|
114
|
+
this.controls.set(token, key)
|
|
115
|
+
return { result: { key } }
|
|
116
|
+
}
|
|
117
|
+
if (call.startsWith('plugins.') && this.handlers.plugins) {
|
|
118
|
+
const key = this.controls.get(token)
|
|
119
|
+
return { result: await this.handlers.plugins(grant, call, params, key !== undefined && request.control === key) }
|
|
120
|
+
}
|
|
121
|
+
throw new HarnessError('unknown_method', `Unknown call ${call}.`)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return { error: toErrorShape(error) }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private serve(client: Socket): void {
|
|
128
|
+
let buffer = ''
|
|
129
|
+
let waiting: SocketGrant | null = null
|
|
130
|
+
this.clients.add(client)
|
|
131
|
+
client.on('error', () => {})
|
|
132
|
+
client.once('close', () => {
|
|
133
|
+
this.clients.delete(client)
|
|
134
|
+
// A decision the native process was waiting on can no longer reach it.
|
|
135
|
+
waiting?.terminal?.disconnected()
|
|
136
|
+
})
|
|
137
|
+
client.on('data', (chunk) => {
|
|
138
|
+
buffer += chunk.toString('utf8')
|
|
139
|
+
if (Buffer.byteLength(buffer) > MAX_REQUEST_BYTES) {
|
|
140
|
+
client.destroy()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const newline = buffer.indexOf('\n')
|
|
144
|
+
if (newline < 0) return
|
|
145
|
+
client.pause()
|
|
146
|
+
void this.handle(buffer.slice(0, newline), { waiting: (grant) => void (waiting = grant) }).then((reply) => client.end(`${JSON.stringify(reply)}\n`))
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Binds the socket once; later calls wait for the same bind. */
|
|
151
|
+
listen(): Promise<void> {
|
|
152
|
+
this.listening ??= new Promise<void>((resolve, reject) => {
|
|
153
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
|
|
154
|
+
rmSync(this.path, { force: true })
|
|
155
|
+
const server = createServer((client) => this.serve(client))
|
|
156
|
+
server.once('error', (error) => {
|
|
157
|
+
this.listening = null
|
|
158
|
+
reject(error)
|
|
159
|
+
})
|
|
160
|
+
server.listen(this.path, () => {
|
|
161
|
+
try {
|
|
162
|
+
chmodSync(this.path, 0o600)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
log('session socket permissions', error instanceof Error ? error.message : String(error))
|
|
165
|
+
}
|
|
166
|
+
this.server = server
|
|
167
|
+
resolve()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
return this.listening
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async close(): Promise<void> {
|
|
174
|
+
this.grants.clear()
|
|
175
|
+
const server = this.server
|
|
176
|
+
this.server = null
|
|
177
|
+
this.listening = null
|
|
178
|
+
for (const client of this.clients) client.destroy()
|
|
179
|
+
if (server) await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
180
|
+
rmSync(this.path, { force: true })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import type { ChildProcess, spawn as nodeSpawn } from 'node:child_process'
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
|
|
9
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
10
|
+
import { createHarness, type Harness, type HarnessOptions } from '../harness'
|
|
11
|
+
import { defaultAssets } from './terminals'
|
|
12
|
+
import { parseStandaloneOptions, runStandaloneTerminal, standaloneArguments } from './standalone'
|
|
13
|
+
import { socketPath } from './socket'
|
|
14
|
+
|
|
15
|
+
const scratch: string[] = []
|
|
16
|
+
const harnesses: Harness[] = []
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
for (const harness of harnesses.splice(0)) await harness.close()
|
|
20
|
+
for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true })
|
|
21
|
+
vi.restoreAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function temporaryDirectory(prefix: string): string {
|
|
25
|
+
const path = mkdtempSync(join(tmpdir(), prefix))
|
|
26
|
+
scratch.push(path)
|
|
27
|
+
return path
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fakeSpawn(onSpawn: (program: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: string }) => void, outcome: { code?: number; error?: Error } = {}): typeof nodeSpawn {
|
|
31
|
+
return ((program: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: string }) => {
|
|
32
|
+
onSpawn(program, args, options)
|
|
33
|
+
const child = new EventEmitter() as ChildProcess
|
|
34
|
+
child.kill = vi.fn(() => true)
|
|
35
|
+
queueMicrotask(() => {
|
|
36
|
+
if (outcome.error) child.emit('error', outcome.error)
|
|
37
|
+
else child.emit('exit', outcome.code ?? 0, null)
|
|
38
|
+
})
|
|
39
|
+
return child
|
|
40
|
+
}) as unknown as typeof nodeSpawn
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function harnessFactory(options: HarnessOptions): Harness {
|
|
44
|
+
const harness = createHarness({ ...options, secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi' })
|
|
45
|
+
harnesses.push(harness)
|
|
46
|
+
return harness
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('standalone terminal startup', () => {
|
|
50
|
+
it('routes the no-command and terminal invocations to Pi, leaving host commands explicit', () => {
|
|
51
|
+
expect(standaloneArguments([])).toEqual([])
|
|
52
|
+
expect(standaloneArguments(['--data', '/tmp/data'])).toEqual(['--data', '/tmp/data'])
|
|
53
|
+
expect(standaloneArguments(['terminal', '--state', '/tmp/state'])).toEqual(['--state', '/tmp/state'])
|
|
54
|
+
expect(standaloneArguments(['serve', '--state', '/tmp/state'])).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('uses a dedicated default data and state root and accepts path overrides', () => {
|
|
58
|
+
expect(parseStandaloneOptions([], '/home/test', '/work/project')).toEqual({
|
|
59
|
+
dataRoot: '/home/test/.xingular-harness/data',
|
|
60
|
+
stateDir: '/home/test/.xingular-harness/state'
|
|
61
|
+
})
|
|
62
|
+
expect(parseStandaloneOptions(['--data', './knowledge', '--state', './device'], '/home/test', '/work/project')).toEqual({
|
|
63
|
+
dataRoot: '/work/project/knowledge',
|
|
64
|
+
stateDir: '/work/project/device'
|
|
65
|
+
})
|
|
66
|
+
expect(() => parseStandaloneOptions(['--data'])).toThrow('--data needs a directory path.')
|
|
67
|
+
expect(() => parseStandaloneOptions(['--model', 'unknown'])).toThrow('Unknown standalone option: --model')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('starts Pi in the current directory, creates the first isolated account, and cleans up on exit', async () => {
|
|
71
|
+
const home = temporaryDirectory('harness-standalone-')
|
|
72
|
+
const cwd = temporaryDirectory('harness-cwd-')
|
|
73
|
+
const paths = parseStandaloneOptions([], home)
|
|
74
|
+
let invocation: { program: string; args: string[]; options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: string } } | undefined
|
|
75
|
+
const spawn = fakeSpawn((program, args, options) => {
|
|
76
|
+
invocation = { program, args, options }
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(await runStandaloneTerminal([], { home, cwd, stdinIsTTY: true, stdoutIsTTY: true, createSessionId: () => 'standalone', createHarness: harnessFactory, spawn })).toBe(0)
|
|
80
|
+
|
|
81
|
+
const harness = harnesses.shift()!
|
|
82
|
+
expect(harness.accounts.overview().accounts).toMatchObject([{ label: 'Default', isDefault: true }])
|
|
83
|
+
expect(invocation?.program).toBe('/opt/pi/bin/pi')
|
|
84
|
+
expect(invocation?.options).toMatchObject({ cwd, stdio: 'inherit' })
|
|
85
|
+
expect(invocation?.args).toContain('--extension')
|
|
86
|
+
expect(invocation?.args).toContain(defaultAssets().piExtension)
|
|
87
|
+
expect(invocation?.args).toContain(join(paths.dataRoot, 'sessions', 'terminal', createHash('sha256').update(resolve(cwd)).digest('hex'), 'pi'))
|
|
88
|
+
expect(invocation?.options.env?.PI_CODING_AGENT_DIR).toContain(join(paths.stateDir, 'accounts'))
|
|
89
|
+
expect(invocation?.options.env?.XINGULAR_HARNESS_SOCKET).toBeTruthy()
|
|
90
|
+
expect(invocation?.options.env?.XINGULAR_HARNESS_TOKEN).toBeTruthy()
|
|
91
|
+
expect(existsSync(join(paths.stateDir, 'accounts', harness.accounts.overview().accounts[0].id, 'home', 'pi', 'auth.json'))).toBe(false)
|
|
92
|
+
expect(existsSync(socketPath(paths.stateDir))).toBe(false)
|
|
93
|
+
expect(harness.terminals.has('standalone')).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('requires an interactive TTY before creating Harness data', async () => {
|
|
97
|
+
const create = vi.fn(harnessFactory)
|
|
98
|
+
await expect(runStandaloneTerminal([], { stdinIsTTY: false, stdoutIsTTY: true, createHarness: create })).rejects.toThrow('needs a TTY')
|
|
99
|
+
expect(create).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('cleans up the prepared terminal when Pi cannot be spawned', async () => {
|
|
103
|
+
const home = temporaryDirectory('harness-standalone-fail-')
|
|
104
|
+
const cwd = temporaryDirectory('harness-cwd-fail-')
|
|
105
|
+
const spawn = fakeSpawn(() => undefined, { error: new Error('spawn failed') })
|
|
106
|
+
|
|
107
|
+
await expect(runStandaloneTerminal([], { home, cwd, stdinIsTTY: true, stdoutIsTTY: true, createSessionId: () => 'failed', createHarness: harnessFactory, spawn })).rejects.toThrow('spawn failed')
|
|
108
|
+
|
|
109
|
+
const harness = harnesses.shift()!
|
|
110
|
+
const account = harness.accounts.overview().accounts[0]
|
|
111
|
+
expect(existsSync(join(harness.accounts.dir, account.id, 'home', 'pi', 'auth.json'))).toBe(false)
|
|
112
|
+
expect(existsSync(socketPath(harness.stateDir))).toBe(false)
|
|
113
|
+
expect(harness.terminals.has('failed')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The standalone entry point for people using the harness directly. Pi owns the interface;
|
|
2
|
+
// the harness prepares the isolated account, context, extension, and session socket.
|
|
3
|
+
import { spawn as nodeSpawn } from 'node:child_process'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { createHarness, type Harness, type HarnessOptions } from '../harness'
|
|
9
|
+
import type { TerminalLaunch } from './terminals'
|
|
10
|
+
|
|
11
|
+
export interface StandalonePaths {
|
|
12
|
+
dataRoot: string
|
|
13
|
+
stateDir: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StandaloneDependencies {
|
|
17
|
+
home?: string
|
|
18
|
+
cwd?: string
|
|
19
|
+
stdinIsTTY?: boolean
|
|
20
|
+
stdoutIsTTY?: boolean
|
|
21
|
+
createSessionId?: () => string
|
|
22
|
+
createHarness?: (options: HarnessOptions) => Harness
|
|
23
|
+
spawn?: typeof nodeSpawn
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function standaloneArguments(argv: string[]): string[] | null {
|
|
27
|
+
const command = argv[0]
|
|
28
|
+
if (!command || command.startsWith('--') || command === '-h') return argv
|
|
29
|
+
return command === 'terminal' ? argv.slice(1) : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseStandaloneOptions(argv: string[], home = homedir(), cwd = process.cwd()): StandalonePaths {
|
|
33
|
+
const root = join(home, '.xingular-harness')
|
|
34
|
+
const paths: StandalonePaths = {
|
|
35
|
+
dataRoot: join(root, 'data'),
|
|
36
|
+
stateDir: join(root, 'state')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
const argument = argv[index]
|
|
41
|
+
if (argument !== '--data' && argument !== '--state') throw new Error(`Unknown standalone option: ${argument}`)
|
|
42
|
+
const value = argv[index + 1]
|
|
43
|
+
if (!value || value.startsWith('--')) throw new Error(`${argument} needs a directory path.`)
|
|
44
|
+
paths[argument === '--data' ? 'dataRoot' : 'stateDir'] = resolve(cwd, value)
|
|
45
|
+
index += 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return paths
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function processEnvironment(overrides: Record<string, string | null>): NodeJS.ProcessEnv {
|
|
52
|
+
const environment = { ...process.env }
|
|
53
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
54
|
+
if (value === null) delete environment[name]
|
|
55
|
+
else environment[name] = value
|
|
56
|
+
}
|
|
57
|
+
return environment
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function exitCode(code: number | null, signal: NodeJS.Signals | null): number {
|
|
61
|
+
if (code !== null) return code
|
|
62
|
+
if (signal === 'SIGINT') return 130
|
|
63
|
+
if (signal === 'SIGTERM') return 143
|
|
64
|
+
if (signal === 'SIGHUP') return 129
|
|
65
|
+
return 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function spawnPi(launch: TerminalLaunch, cwd: string, spawn = nodeSpawn): Promise<number> {
|
|
69
|
+
return new Promise((resolveExit, reject) => {
|
|
70
|
+
const child = spawn(launch.program, launch.args, {
|
|
71
|
+
cwd,
|
|
72
|
+
env: processEnvironment(launch.env),
|
|
73
|
+
stdio: 'inherit'
|
|
74
|
+
})
|
|
75
|
+
let settled = false
|
|
76
|
+
const cleanup = (): void => {
|
|
77
|
+
process.off('SIGINT', onInterrupt)
|
|
78
|
+
process.off('SIGTERM', onTerminate)
|
|
79
|
+
process.off('SIGHUP', onHangup)
|
|
80
|
+
}
|
|
81
|
+
const finish = (action: () => void): void => {
|
|
82
|
+
if (settled) return
|
|
83
|
+
settled = true
|
|
84
|
+
cleanup()
|
|
85
|
+
action()
|
|
86
|
+
}
|
|
87
|
+
// Ctrl+C is delivered to both processes in the foreground process group. Keep this host
|
|
88
|
+
// alive while Pi handles it so the account credentials and harness socket are cleaned up.
|
|
89
|
+
const onInterrupt = (): void => undefined
|
|
90
|
+
const onTerminate = (): void => {
|
|
91
|
+
child.kill('SIGTERM')
|
|
92
|
+
}
|
|
93
|
+
const onHangup = (): void => undefined
|
|
94
|
+
|
|
95
|
+
process.on('SIGINT', onInterrupt)
|
|
96
|
+
process.on('SIGTERM', onTerminate)
|
|
97
|
+
process.on('SIGHUP', onHangup)
|
|
98
|
+
child.once('error', (error) => finish(() => reject(error)))
|
|
99
|
+
child.once('exit', (code, signal) => finish(() => resolveExit(exitCode(code, signal))))
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runStandaloneTerminal(argv: string[], dependencies: StandaloneDependencies = {}): Promise<number> {
|
|
104
|
+
if (!(dependencies.stdinIsTTY ?? process.stdin.isTTY) || !(dependencies.stdoutIsTTY ?? process.stdout.isTTY)) {
|
|
105
|
+
throw new Error('The interactive Harness terminal needs a TTY. Run it from a terminal, or use `xingular-harness serve` for a host process.')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cwd = resolve(dependencies.cwd ?? process.cwd())
|
|
109
|
+
const paths = parseStandaloneOptions(argv, dependencies.home, cwd)
|
|
110
|
+
const makeHarness = dependencies.createHarness ?? createHarness
|
|
111
|
+
const harness = makeHarness({ dataRoot: paths.dataRoot, stateDir: paths.stateDir, productName: 'Xingular Harness' })
|
|
112
|
+
const sessionId = (dependencies.createSessionId ?? randomUUID)()
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await harness.ready
|
|
116
|
+
if (harness.accounts.overview().accounts.length === 0) harness.accounts.create('Default', 'subscription')
|
|
117
|
+
|
|
118
|
+
const cwdHash = createHash('sha256').update(cwd).digest('hex')
|
|
119
|
+
const sessionFolder = join(paths.dataRoot, 'sessions', 'terminal', cwdHash)
|
|
120
|
+
const launch = await harness.terminals.prepare({ sessionId, cwd, sessionFolder })
|
|
121
|
+
return await spawnPi(launch, cwd, dependencies.spawn)
|
|
122
|
+
} finally {
|
|
123
|
+
await harness.terminals.dispose(sessionId)
|
|
124
|
+
await harness.close()
|
|
125
|
+
}
|
|
126
|
+
}
|