@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28
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/.env.example +10 -1
- package/AGENTS.md +40 -9
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +31 -1
- package/Dockerfile +1 -0
- package/README.md +84 -12
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +29 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +54 -5
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +43 -18
- package/package.json +8 -4
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +7 -10
- package/src/config.ts +23 -5
- package/src/control-state.ts +274 -21
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/desktop-bridge.ts +11 -43
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +29 -58
- package/src/host-executor.ts +11 -9
- package/src/identity.ts +11 -3
- package/src/index.ts +191 -93
- package/src/menu.ts +76 -55
- package/src/message-history.ts +52 -0
- package/src/message-send.ts +1 -1
- package/src/message.ts +49 -7
- package/src/model-policy.ts +5 -15
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +93 -23
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +33 -15
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +55 -22
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/control.mjs +4 -0
- package/src/updates/supervisor.mjs +10 -4
- package/src/web-launcher.ts +19 -0
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +90 -37
- package/templates/deployments.md +24 -0
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +18 -10
- package/test/config.test.ts +16 -1
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +68 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +58 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +169 -25
- package/test/message-history.test.ts +127 -0
- package/test/model-policy.test.ts +23 -48
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +70 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +44 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import { request as httpRequest } from 'node:http'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { randomBytes } from 'node:crypto'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { ApplicationChannel, ApplicationBindings, applicationScope } from '../src/application-channel.js'
|
|
12
|
+
import { ControlStore } from '../src/control-state.js'
|
|
13
|
+
import { RunStore } from '../src/runs.js'
|
|
14
|
+
import { initialPreset } from '../src/ai.js'
|
|
15
|
+
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
16
|
+
import { createRelay } from '../src/index.js'
|
|
17
|
+
import { EXECUTOR_REGISTRY, executorEnvironment } from '../src/executor.js'
|
|
18
|
+
|
|
19
|
+
const token = () => randomBytes(32).toString('base64url')
|
|
20
|
+
const owner = async (dir: string) => {
|
|
21
|
+
const control = new ControlStore(dir,1000)
|
|
22
|
+
await control.requestPairing(42,42); await control.approveOwner(42)
|
|
23
|
+
return (await control.status()).owner!
|
|
24
|
+
}
|
|
25
|
+
const waitFor = async (condition: () => Promise<boolean>) => {
|
|
26
|
+
for (let attempt=0; attempt<200; attempt++) { if (await condition()) return; await new Promise(resolve=>setTimeout(resolve,10)) }
|
|
27
|
+
throw new Error('Timed out waiting for application run')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('HTTP auth, idempotency, origin/context isolation, revocation and persisted scope continuity', async t => {
|
|
31
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-channel-'))
|
|
32
|
+
const owned=await owner(root), runs=new RunStore(root), control=new ControlStore(root,1000)
|
|
33
|
+
let wakes=0
|
|
34
|
+
const channel=new ApplicationChannel({controlDir:root,initial:initialPreset('codex'),wake:()=>{wakes++},cancel:async id=>{await runs.patch(id,{status:'cancelled'})}})
|
|
35
|
+
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
36
|
+
const firstToken=token(), secondToken=token()
|
|
37
|
+
const first=(await channel.bindings.register('first',firstToken,owned))!
|
|
38
|
+
const second=(await channel.bindings.register('second',secondToken,owned))!
|
|
39
|
+
const address=await channel.listen(0) as {port:number}
|
|
40
|
+
const url=`http://127.0.0.1:${address.port}`
|
|
41
|
+
const request=(path:string,bearer:string,body?:unknown)=>fetch(url+path,{method:body===undefined?'GET':'POST',headers:{Authorization:`Bearer ${bearer}`},...(body===undefined?{}:{body:JSON.stringify(body)})})
|
|
42
|
+
assert.equal((await request('/v1/runs','bad',{})).status,401)
|
|
43
|
+
assert.equal((await runs.list()).length,0)
|
|
44
|
+
const input={requestId:'request-1',scope:'person:program',text:'Discuss next lesson',context:{capability:'not-a-prompt',reference:'lesson'}}
|
|
45
|
+
const created=await request('/v1/runs',firstToken,input)
|
|
46
|
+
assert.equal(created.status,202)
|
|
47
|
+
const result=await created.json() as {id:string}
|
|
48
|
+
const retry=await request('/v1/runs',firstToken,{...input,context:{capability:'replacement'}})
|
|
49
|
+
assert.equal((await retry.json() as {id:string}).id,result.id)
|
|
50
|
+
assert.equal(wakes,1)
|
|
51
|
+
const unicode=Buffer.from(JSON.stringify({...input,requestId:'unicode',text:'Café lesson'}))
|
|
52
|
+
const split=unicode.indexOf(Buffer.from('é'))+1
|
|
53
|
+
const unicodeResponse=await new Promise<string>((resolve,reject)=>{
|
|
54
|
+
const req=httpRequest(url+'/v1/runs',{method:'POST',headers:{Authorization:`Bearer ${firstToken}`}},res=>{
|
|
55
|
+
let body='';res.setEncoding('utf8');res.on('data',chunk=>body+=chunk);res.on('end',()=>resolve(body))
|
|
56
|
+
});req.on('error',reject);req.write(unicode.subarray(0,split));setTimeout(()=>req.end(unicode.subarray(split)),20)
|
|
57
|
+
})
|
|
58
|
+
assert.equal((await runs.get(JSON.parse(unicodeResponse).id))?.texts[0],'Café lesson')
|
|
59
|
+
|
|
60
|
+
assert.equal((await runs.get(result.id))?.application?.context?.capability,'not-a-prompt')
|
|
61
|
+
assert.equal((await request('/v1/runs',firstToken,{...input,text:'changed'})).status,409)
|
|
62
|
+
assert.equal((await request('/v1/runs',firstToken,{...input,scope:'other'})).status,409)
|
|
63
|
+
assert.equal((await request(`/v1/runs/${result.id}`,secondToken)).status,404)
|
|
64
|
+
assert.equal((await request(`/v1/runs/${result.id}/cancel`,secondToken,{})).status,404)
|
|
65
|
+
assert.equal((await request('/v1/runs',firstToken,{...input,requestId:'../escape'})).status,400)
|
|
66
|
+
assert.equal((await request('/v1/runs',firstToken,{...input,requestId:'extra',workspace:'/etc'})).status,400)
|
|
67
|
+
const run=(await runs.get(result.id))!
|
|
68
|
+
assert.equal(run.messageId,undefined)
|
|
69
|
+
assert.equal(run.external,undefined)
|
|
70
|
+
await runs.patch(run.id,{status:'running'})
|
|
71
|
+
assert.equal((await requireOwnerExecution(root,run.id)).application?.scope,input.scope)
|
|
72
|
+
await control.saveNativeSession(run.execution!.sessionId,'native_scope_one')
|
|
73
|
+
const later=await channel.submit(first.bindingId,{...input,requestId:'later'})
|
|
74
|
+
assert.equal(later.execution?.sessionId,run.execution?.sessionId)
|
|
75
|
+
assert.equal((await new ControlStore(root,1000).executionSession(later.execution!)).nativeSessionId,'native_scope_one')
|
|
76
|
+
const otherScope=await channel.submit(first.bindingId,{...input,requestId:'other-scope',scope:'different'})
|
|
77
|
+
const otherBinding=await channel.submit(second.bindingId,input)
|
|
78
|
+
assert.notEqual(otherScope.execution?.sessionId,run.execution?.sessionId)
|
|
79
|
+
assert.notEqual(otherBinding.execution?.sessionId,run.execution?.sessionId)
|
|
80
|
+
assert.equal((await control.listSessions()).length,0,'app conversations do not replace or appear in Telegram selector')
|
|
81
|
+
const telegram=await control.captureChoice(initialPreset('codex'))
|
|
82
|
+
assert.notEqual(telegram.sessionId,run.execution?.sessionId)
|
|
83
|
+
const message=await runs.enqueueMessage(run.id,'Here is the proposal')
|
|
84
|
+
await runs.claimOutbox(message.id)
|
|
85
|
+
await channel.deliver(run,message)
|
|
86
|
+
assert.deepEqual(await runs.waitForDelivery(message.id),{delivered:true})
|
|
87
|
+
assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).messages,[{id:message.id,text:'Here is the proposal'}])
|
|
88
|
+
await channel.bindings.register('first',null,owned)
|
|
89
|
+
await assert.rejects(requireOwnerExecution(root,run.id),/revoked/)
|
|
90
|
+
await assert.rejects(channel.deliver(run,message),/revoked/)
|
|
91
|
+
assert.equal((await request(`/v1/runs/${run.id}`,firstToken)).status,401)
|
|
92
|
+
await writeFile(join(root,'application-bindings.json'),'{')
|
|
93
|
+
assert.equal((await request('/v1/runs',secondToken,input)).status,401)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('owner replacement invalidates binding and client secrets never enter native environment',async t=>{
|
|
97
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-owner-'))
|
|
98
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
99
|
+
const first=await owner(root), bindings=new ApplicationBindings(root), secret=token()
|
|
100
|
+
await bindings.register('app',secret,first)
|
|
101
|
+
await new ControlStore(root,1000).revokeOwner()
|
|
102
|
+
await assert.rejects(bindings.authenticate(secret),/Unauthorized/)
|
|
103
|
+
assert.equal(executorEnvironment({TELEGRAM_BOT_TOKEN:'telegram-secret',EZ_APPLICATION_TOKEN:secret,PATH:'/bin'}).EZ_APPLICATION_TOKEN,undefined)
|
|
104
|
+
assert.equal(executorEnvironment({TELEGRAM_BOT_TOKEN:'telegram-secret'}).TELEGRAM_BOT_TOKEN,undefined)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('app run uses real core executor process, scoped native resume and outbox without Telegram delivery',async t=>{
|
|
108
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-executor-'))
|
|
109
|
+
const original=EXECUTOR_REGISTRY.codex
|
|
110
|
+
const prompts:string[]=[], resumeIds:(string|undefined)[]=[]
|
|
111
|
+
const require=createRequire(import.meta.url)
|
|
112
|
+
const fixture=join(root,'engine.mjs')
|
|
113
|
+
const nativeId='11111111-1111-1111-1111-111111111111'
|
|
114
|
+
await writeFile(fixture,`import {spawnSync} from 'node:child_process';
|
|
115
|
+
console.log(JSON.stringify({type:'thread.started',thread_id:${JSON.stringify(nativeId)}}));
|
|
116
|
+
const result=spawnSync(process.execPath,[${JSON.stringify(fileURLToPath(new URL('../bin/ezenciel-agents-message.mjs',import.meta.url)))},'--text','Fixture engine reply'],{env:process.env,encoding:'utf8'});
|
|
117
|
+
if(result.status!==0){console.error(result.stderr);process.exit(1)};
|
|
118
|
+
`)
|
|
119
|
+
EXECUTOR_REGISTRY.codex={...original,command:process.execPath,buildArgs:(options,_file,prompt)=>{prompts.push(prompt);resumeIds.push(options.isResume?options.sessionId:undefined);return ['--import',require.resolve('tsx'),fixture]}}
|
|
120
|
+
const relay=createRelay({controlDir:root,workspace:root,pairingTtlMs:1000,telegramBotToken:'fixture',executorTimeoutMs:0,executorCli:'codex'})
|
|
121
|
+
let telegramCalls=0
|
|
122
|
+
relay.bot.api.config.use(async()=>{telegramCalls++;return {ok:true,result:{message_id:1}} as never})
|
|
123
|
+
const owned=await owner(root), secret=token()
|
|
124
|
+
const binding=(await relay.applicationChannel.bindings.register('app',secret,owned))!
|
|
125
|
+
const drain=setInterval(()=>void relay.drainOutbox(),10)
|
|
126
|
+
t.after(async()=>{clearInterval(drain);await relay.stop();EXECUTOR_REGISTRY.codex=original;await rm(root,{recursive:true,force:true})})
|
|
127
|
+
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'one',scope:'program',text:'Hello from the app',context:{secret:'must-stay-out-of-prompt'}})
|
|
128
|
+
await waitFor(async()=> (await new RunStore(root).get(first.id))?.status==='completed')
|
|
129
|
+
const second=await relay.applicationChannel.submit(binding.bindingId,{requestId:'two',scope:'program',text:'Continue'})
|
|
130
|
+
await waitFor(async()=> (await new RunStore(root).get(second.id))?.status==='completed')
|
|
131
|
+
assert.deepEqual(resumeIds,[undefined,nativeId])
|
|
132
|
+
assert.equal(telegramCalls,0)
|
|
133
|
+
assert.ok(prompts[0].startsWith('Hello from the app'))
|
|
134
|
+
assert.ok(prompts[0].includes('[Application channel]'))
|
|
135
|
+
assert.ok(!prompts[0].includes('must-stay-out-of-prompt'))
|
|
136
|
+
assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,second.id)).messages[0].text,'Fixture engine reply')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
test('packaged admin command discovery and existing core child cancellation',async t=>{
|
|
141
|
+
const help=spawnSync(process.execPath,[fileURLToPath(new URL('../bin/ezenciel-agents-application.mjs',import.meta.url)),'--help'],{encoding:'utf8'})
|
|
142
|
+
assert.equal(help.status,0,help.stderr)
|
|
143
|
+
assert.match(help.stdout,/--token-file/)
|
|
144
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-cancel-')), original=EXECUTOR_REGISTRY.codex
|
|
145
|
+
EXECUTOR_REGISTRY.codex={...original,command:process.execPath,buildArgs:()=>['-e','setInterval(()=>{},1000)']}
|
|
146
|
+
const relay=createRelay({controlDir:root,workspace:root,pairingTtlMs:1000,telegramBotToken:'fixture',executorTimeoutMs:0,executorCli:'codex'})
|
|
147
|
+
t.after(async()=>{await relay.stop();EXECUTOR_REGISTRY.codex=original;await rm(root,{recursive:true,force:true})})
|
|
148
|
+
const owned=await owner(root),secret=token(),binding=(await relay.applicationChannel.bindings.register('app',secret,owned))!
|
|
149
|
+
const address=await relay.applicationChannel.listen(0) as {port:number}
|
|
150
|
+
const run=await relay.applicationChannel.submit(binding.bindingId,{requestId:'cancel',scope:'scope',text:'Wait'})
|
|
151
|
+
await waitFor(async()=> (await new RunStore(root).get(run.id))?.status==='running')
|
|
152
|
+
const response=await fetch(`http://127.0.0.1:${address.port}/v1/runs/${run.id}/cancel`,{method:'POST',headers:{Authorization:`Bearer ${secret}`}})
|
|
153
|
+
assert.equal(response.status,200)
|
|
154
|
+
await waitFor(async()=> (await new RunStore(root).get(run.id))?.status==='cancelled')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('native history assertion blocks accidental fresh cutover without choosing a native session', async t => {
|
|
158
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-import-'))
|
|
159
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
160
|
+
const owned = await owner(root), control = new ControlStore(root, 1000)
|
|
161
|
+
const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
|
|
162
|
+
const binding = (await channel.bindings.register('app', token(), owned))!
|
|
163
|
+
const input = { requestId: 'job', scope: 'main', text: 'Continue', expectedNativeSessionId: 'existing-native' }
|
|
164
|
+
await assert.rejects(channel.submit(binding.bindingId, input), /import the existing scope/)
|
|
165
|
+
assert.equal((await new RunStore(root).list()).length, 0)
|
|
166
|
+
const choice = await control.captureApplicationChoice(initialPreset('codex'), applicationScope(binding.bindingId, 'main'))
|
|
167
|
+
await control.saveNativeSession(choice.sessionId, 'existing-native')
|
|
168
|
+
const run = await channel.submit(binding.bindingId, input)
|
|
169
|
+
assert.equal((await channel.snapshot(binding.bindingId, run.id)).nativeSessionId, 'existing-native')
|
|
170
|
+
await assert.rejects(channel.submit(binding.bindingId, { ...input, requestId: 'other', expectedNativeSessionId: 'someone-else' }), /conflicts/)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('explicit shared channel grant resumes one native session from app and Telegram', async t => {
|
|
174
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-shared-'))
|
|
175
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
176
|
+
const owned = await owner(root), control = new ControlStore(root, 1000)
|
|
177
|
+
const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
|
|
178
|
+
const binding = (await channel.bindings.register('app', token(), owned, true))!
|
|
179
|
+
const input = { requestId: 'app-1', scope: 'biology', text: 'Continue', activateTelegram: true }
|
|
180
|
+
const app = await channel.submit(binding.bindingId, input)
|
|
181
|
+
await control.saveNativeSession(app.execution!.sessionId, 'biology-history')
|
|
182
|
+
const telegram = await control.captureChoice(initialPreset('codex'))
|
|
183
|
+
assert.equal(telegram.sessionId, app.execution!.sessionId)
|
|
184
|
+
assert.equal((await control.executionSession(telegram)).nativeSessionId, 'biology-history')
|
|
185
|
+
const selection = await channel.submit(binding.bindingId, { ...input, requestId: 'selection', scope: 'selection:1', activateTelegram: false })
|
|
186
|
+
assert.notEqual(selection.execution!.sessionId, telegram.sessionId)
|
|
187
|
+
assert.equal((await control.captureChoice(initialPreset('codex'))).sessionId, telegram.sessionId)
|
|
188
|
+
const appAgain = await channel.submit(binding.bindingId, { ...input, requestId: 'app-2' })
|
|
189
|
+
assert.equal(appAgain.execution!.sessionId, telegram.sessionId)
|
|
190
|
+
assert.equal((await control.listSessions()).filter(s => s.sessionId === telegram.sessionId).length, 1)
|
|
191
|
+
const other = (await channel.bindings.register('private', token(), owned))!
|
|
192
|
+
await channel.submit(other.bindingId, { ...input, requestId: 'private' })
|
|
193
|
+
assert.equal((await control.captureChoice(initialPreset('codex'))).sessionId, telegram.sessionId, 'an unshared app cannot switch Telegram')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('application AI choice retains same-engine history and rejects cross-engine resume and changed retries', async t => {
|
|
197
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-ai-'))
|
|
198
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
199
|
+
const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
|
|
200
|
+
const binding = (await channel.bindings.register('app', token(), await owner(root)))!
|
|
201
|
+
const input = { requestId: 'one', scope: 'main:codex', text: 'Hello', ai: { cli: 'codex', model: 'gpt-5.6-luna', effort: 'high' } }
|
|
202
|
+
const first = await channel.submit(binding.bindingId, input)
|
|
203
|
+
assert.equal(first.execution!.preset.model, input.ai.model)
|
|
204
|
+
const second = await channel.submit(binding.bindingId, { ...input, requestId: 'two', ai: { ...input.ai, model: 'gpt-5.6-terra' } })
|
|
205
|
+
assert.equal(first.execution!.sessionId, second.execution!.sessionId)
|
|
206
|
+
await assert.rejects(channel.submit(binding.bindingId, { ...input, ai: { ...input.ai, model: 'gpt-5.6-terra' } }), /conflicts/)
|
|
207
|
+
await assert.rejects(channel.submit(binding.bindingId, { ...input, requestId: 'three', ai: { cli: 'grok' } }), /engine/)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('rejected history assertion cannot activate Telegram and incomplete native metadata does not hide cancellation', async t => {
|
|
211
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-assertion-'))
|
|
212
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
213
|
+
const owned = await owner(root), control = new ControlStore(root, 1000)
|
|
214
|
+
const before = await control.captureChoice(initialPreset('codex'))
|
|
215
|
+
const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
|
|
216
|
+
const binding = (await channel.bindings.register('app', token(), owned, true))!
|
|
217
|
+
const input = { requestId: 'one', scope: 'main', text: 'Hello', activateTelegram: true, expectedNativeSessionId: 'old-native' }
|
|
218
|
+
await assert.rejects(channel.submit(binding.bindingId, input), /import/)
|
|
219
|
+
assert.equal((await control.captureChoice(initialPreset('codex'))).sessionId, before.sessionId)
|
|
220
|
+
const run = await channel.submit(binding.bindingId, { ...input, expectedNativeSessionId: undefined })
|
|
221
|
+
await control.markSessionStarted(run.execution!.sessionId)
|
|
222
|
+
assert.equal((await channel.snapshot(binding.bindingId, run.id)).id, run.id)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('HTTP admission errors distinguish absent work from an existing conflicting run', async t => {
|
|
226
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-admission-'))
|
|
227
|
+
const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
|
|
228
|
+
t.after(async () => { await channel.stop(); await rm(root, { recursive: true, force: true }) })
|
|
229
|
+
const credential = token()
|
|
230
|
+
await channel.bindings.register('app', credential, await owner(root))
|
|
231
|
+
const address = await channel.listen(0) as { port: number }
|
|
232
|
+
const post = (body: unknown) => fetch(`http://127.0.0.1:${address.port}/v1/runs`, { method: 'POST', headers: { authorization: `Bearer ${credential}` }, body: JSON.stringify(body) })
|
|
233
|
+
const input = { requestId: 'same', scope: 'main', text: 'Hello' }
|
|
234
|
+
const rejected = await post({ ...input, expectedNativeSessionId: 'unimported' })
|
|
235
|
+
assert.equal(rejected.status, 409)
|
|
236
|
+
assert.equal((await rejected.json() as { admitted: boolean }).admitted, false)
|
|
237
|
+
const accepted = await post(input)
|
|
238
|
+
const run = await accepted.json() as { id: string }
|
|
239
|
+
const conflict = await post({ ...input, text: 'Changed' })
|
|
240
|
+
const error = await conflict.json() as { admitted: boolean; runId: string }
|
|
241
|
+
assert.equal(error.admitted, true); assert.equal(error.runId, run.id)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('following Telegram uses current conversation and model, but retries retain admitted work', async t => {
|
|
245
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-follow-'))
|
|
246
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
247
|
+
const owned = await owner(root), control = new ControlStore(root, 1000)
|
|
248
|
+
const initial = initialPreset('codex')
|
|
249
|
+
const channel = new ApplicationChannel({ controlDir: root, initial, wake: () => {}, cancel: async () => {} })
|
|
250
|
+
const binding = (await channel.bindings.register('app', token(), owned, true))!
|
|
251
|
+
const input = { requestId: 'first', scope: 'main', text: 'Continue', followTelegram: true }
|
|
252
|
+
const current = await control.captureChoice(initial)
|
|
253
|
+
const first = await channel.submit(binding.bindingId, input)
|
|
254
|
+
assert.deepEqual(first.execution, current)
|
|
255
|
+
await control.resetSession()
|
|
256
|
+
const selected = { ...initial, id: 'different', name: 'Different', model: 'gpt-6-astra', effort: 'high' }
|
|
257
|
+
await control.savePreset(selected)
|
|
258
|
+
await control.selectPreset(selected.id, (await control.getActiveSession())!.sessionId)
|
|
259
|
+
const next = await channel.submit(binding.bindingId, { ...input, requestId: 'second' })
|
|
260
|
+
assert.notEqual(next.execution!.sessionId, first.execution!.sessionId)
|
|
261
|
+
assert.equal(next.execution!.preset.effort, 'high')
|
|
262
|
+
assert.equal(next.execution!.preset.model, 'gpt-6-astra')
|
|
263
|
+
assert.deepEqual((await channel.submit(binding.bindingId, input)).execution, first.execution)
|
|
264
|
+
await assert.rejects(channel.submit(binding.bindingId, { ...input, followTelegram: false }), /conflicts/)
|
|
265
|
+
const detail = await channel.submit(binding.bindingId, { requestId: 'detail', scope: 'exercise', text: 'Discuss' })
|
|
266
|
+
assert.notEqual(detail.execution!.sessionId, next.execution!.sessionId)
|
|
267
|
+
assert.equal((await control.getActiveSession())!.sessionId, next.execution!.sessionId)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('following Telegram requires an explicit sharing grant and cannot override selected state', async t => {
|
|
271
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-follow-authority-'))
|
|
272
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
273
|
+
const owned = await owner(root)
|
|
274
|
+
const channel = new ApplicationChannel({ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {} })
|
|
275
|
+
const binding = (await channel.bindings.register('app', token(), owned))!
|
|
276
|
+
const input = { requestId: 'one', scope: 'main', text: 'Continue', followTelegram: true }
|
|
277
|
+
await assert.rejects(channel.submit(binding.bindingId, input), /authority/)
|
|
278
|
+
const shared = (await channel.bindings.register('shared', token(), owned, true))!
|
|
279
|
+
for (const extra of [{ ai: { cli: 'codex' } }, { activateTelegram: true }, { expectedNativeSessionId: 'old' }, { followTelegram: 'true' }]) {
|
|
280
|
+
await assert.rejects(channel.submit(shared.bindingId, { ...input, ...extra }), /Invalid application/)
|
|
281
|
+
}
|
|
282
|
+
assert.equal((await new RunStore(root).list()).length, 0)
|
|
283
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { applicationBinding, applicationCall, runApplication } from '../src/application-client.mjs'
|
|
7
|
+
|
|
8
|
+
const id = `r_app_${'a'.repeat(64)}`
|
|
9
|
+
test('control transport uses the same credential boundary without expanding arbitrary operations', async () => {
|
|
10
|
+
const calls = []
|
|
11
|
+
const connection = {url:'http://agent:8787',token:'secret',fetchImpl:async(url,options)=>{
|
|
12
|
+
calls.push([String(url),options.method]);return Response.json({activeSessionId:null})
|
|
13
|
+
}}
|
|
14
|
+
await applicationCall('/v1/control',undefined,connection)
|
|
15
|
+
await applicationCall('/v1/control',{action:'new',expectedSession:null},connection)
|
|
16
|
+
await applicationCall('/v1/scope-control?scope=exercise',undefined,connection)
|
|
17
|
+
await assert.rejects(applicationCall('/v1/control/../secrets',undefined,connection),/Invalid/)
|
|
18
|
+
assert.deepEqual(calls,[['http://agent:8787/v1/control','GET'],['http://agent:8787/v1/control','POST'],['http://agent:8787/v1/scope-control?scope=exercise','GET']])
|
|
19
|
+
await assert.rejects(applicationCall('/v1/scopes/../control',undefined,connection),/Invalid/)
|
|
20
|
+
for (const path of ['/v1/control','/v1/scope-control?scope=exercise']) {
|
|
21
|
+
await assert.rejects(applicationCall(path,{action:'new',expectedSession:null},{...connection,
|
|
22
|
+
fetchImpl:async()=>{throw new Error('lost response')}}),error=>error.retryable===false)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
test('principal bindings fail closed on unknown, revoked, duplicate and shared endpoints', async t => {
|
|
26
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-client-'))
|
|
27
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
28
|
+
const file = join(dir, 'bindings.json'), tokenFile = join(dir, 'token')
|
|
29
|
+
await writeFile(tokenFile, 'private-token\n', { mode: 0o600 })
|
|
30
|
+
let bindings = [{ principalId: 'alice', url: 'http://alice:8787', tokenFile }, { principalId: 'bob', url: 'http://bob:8787', tokenFile }]
|
|
31
|
+
const save = () => writeFile(file, JSON.stringify({ version: 1, bindings }))
|
|
32
|
+
await save()
|
|
33
|
+
assert.equal((await applicationBinding(file, 'alice')).url, 'http://alice:8787')
|
|
34
|
+
assert.equal((await applicationBinding(file, 'bob')).url, 'http://bob:8787')
|
|
35
|
+
await assert.rejects(applicationBinding(file, 'eve'), /No authorized/)
|
|
36
|
+
bindings[1].revoked = true; await save()
|
|
37
|
+
await assert.rejects(applicationBinding(file, 'bob'), /No authorized/)
|
|
38
|
+
bindings[1].url = 'http://alice:8787/'; await save()
|
|
39
|
+
await assert.rejects(applicationBinding(file, 'alice'), /separate Ez endpoints/)
|
|
40
|
+
bindings[1].url = 'http://bob:8787'; bindings[1].principalId = 'alice'; await save()
|
|
41
|
+
await assert.rejects(applicationBinding(file, 'alice'), /Invalid/)
|
|
42
|
+
await writeFile(file, '{')
|
|
43
|
+
await assert.rejects(applicationBinding(file, 'alice'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('shared client preserves one submission, exposes native continuity and bounds uncertain failure', async () => {
|
|
47
|
+
const calls = [], admitted = []
|
|
48
|
+
const fetchImpl = async (url, options) => {
|
|
49
|
+
calls.push({ url: String(url), ...options })
|
|
50
|
+
return Response.json({ id, status: calls.length === 1 ? 'queued' : 'completed', nativeSessionId: 'native-history', messages: [{ id: 'message', text: 'Done' }] })
|
|
51
|
+
}
|
|
52
|
+
const input = { requestId: 'same-job', scope: 'main', text: 'hello', context: { capability: 'scoped' } }
|
|
53
|
+
const result = await runApplication(input, { url: 'http://agent:8787', token: 'secret', fetchImpl, pollMs: 1, onAdmitted: value => admitted.push(value) })
|
|
54
|
+
assert.equal(result.reply, 'Done'); assert.equal(result.nativeSessionId, 'native-history')
|
|
55
|
+
assert.deepEqual(admitted, [id]); assert.equal(calls.length, 2)
|
|
56
|
+
assert.deepEqual(JSON.parse(calls[0].body), input)
|
|
57
|
+
assert.equal(calls[1].method, 'GET'); assert.equal(calls[0].redirect, 'error')
|
|
58
|
+
await assert.rejects(applicationCall('/v1/runs', input, { url: 'http://agent:8787', token: 'secret', fetchImpl: async () => new Response('{', { status: 200 }) }), error => error.retryable === true)
|
|
59
|
+
await assert.rejects(applicationCall('/v1/runs', input, { url: 'http://agent:8787', token: 'secret', fetchImpl: async () => new Response('', { status: 403 }) }), error => error.retryable === false)
|
|
60
|
+
await assert.rejects(applicationCall('//other-host', input, { url: 'http://agent:8787', token: 'secret', fetchImpl }), /Invalid/)
|
|
61
|
+
await assert.rejects(runApplication(input, { url: 'http://agent:8787', token: 'secret', fetchImpl: async () => Response.json({ id, status: 'cancelled' }) }), error => error.terminal === true)
|
|
62
|
+
assert.equal(calls.length, 2, 'invalid operation never sends a credential')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('opt-in reconnect retains admission identity and polls through temporary network failure', async () => {
|
|
66
|
+
const calls = []
|
|
67
|
+
const input = { requestId: 'durable', scope: 'main', text: 'Update', context: { capability: 'original' } }
|
|
68
|
+
const result = await runApplication(input, { url: 'http://agent:8787', token: 'secret', pollMs: 1, reconnect: true,
|
|
69
|
+
fetchImpl: async (_url, options) => {
|
|
70
|
+
calls.push(options)
|
|
71
|
+
if (calls.length === 1 || calls.length === 3) throw new Error('connection lost')
|
|
72
|
+
return Response.json({ id, status: calls.length === 2 ? 'running' : 'completed', messages: [{ text: 'Saved' }] })
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
assert.equal(result.reply, 'Saved')
|
|
76
|
+
assert.equal(calls[0].body, calls[1].body)
|
|
77
|
+
assert.equal(calls[2].method, 'GET'); assert.equal(calls[3].method, 'GET')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('client preserves definitive non-admission evidence without inventing it for authentication errors', async () => {
|
|
81
|
+
const connection = { url: 'http://agent:8787', token: 'secret' }
|
|
82
|
+
await assert.rejects(applicationCall('/v1/runs', {}, { ...connection, fetchImpl: async () => Response.json({ admitted: false }, { status: 409 }) }), error => error.admitted === false)
|
|
83
|
+
await assert.rejects(applicationCall('/v1/runs', {}, { ...connection, fetchImpl: async () => Response.json({ error: 'Unauthorized' }, { status: 401 }) }), error => error.admitted === undefined)
|
|
84
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, open } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { ApplicationChannel, applicationScope } from '../src/application-channel.js'
|
|
8
|
+
import { ControlStore } from '../src/control-state.js'
|
|
9
|
+
import { createAiMenu } from '../src/menu.js'
|
|
10
|
+
import { RunStore } from '../src/runs.js'
|
|
11
|
+
|
|
12
|
+
test('private scope reset and model controls preserve running sessions and shared state', async t => {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(),'ez-private-controls-'))
|
|
14
|
+
const control = new ControlStore(root,1000)
|
|
15
|
+
await control.requestPairing(42,42); const owner = await control.approveOwner(42)
|
|
16
|
+
const menu = createAiMenu(control,'codex',async()=>[
|
|
17
|
+
{cli:'codex',model:'fixture-model',name:'Fixture',efforts:['low','high']},
|
|
18
|
+
{cli:'opencode',model:'fixture-other',name:'Other',efforts:[]},
|
|
19
|
+
],root,join(root,'native-home'),async()=>true)
|
|
20
|
+
const channel = new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{}})
|
|
21
|
+
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
22
|
+
const token = randomBytes(32).toString('base64url')
|
|
23
|
+
const binding = (await channel.bindings.register('private',token,owner))!
|
|
24
|
+
const second = (await channel.bindings.register('second',randomBytes(32).toString('base64url'),owner))!
|
|
25
|
+
const shared = await control.captureChoice(menu.initial)
|
|
26
|
+
const run = await channel.submit(binding.bindingId,{requestId:'before-reset',scope:'exercise',text:'still executing'})
|
|
27
|
+
await control.saveNativeSession(run.execution!.sessionId,'native-private')
|
|
28
|
+
const selectedBefore = (await control.status()).ai!.selectedId
|
|
29
|
+
const address = await channel.listen(0) as {port:number}
|
|
30
|
+
const call = (body?:unknown) => fetch(`http://127.0.0.1:${address.port}/v1/scope-control?scope=exercise`,{
|
|
31
|
+
method:body===undefined?'GET':'POST',headers:{authorization:`Bearer ${token}`},
|
|
32
|
+
...(body===undefined?{}:{body:JSON.stringify(body)}),
|
|
33
|
+
})
|
|
34
|
+
const before = await (await call()).json() as any
|
|
35
|
+
assert.equal(before.activeSessionId,run.execution!.sessionId)
|
|
36
|
+
assert.equal(JSON.stringify(before).includes('native-private'),false)
|
|
37
|
+
assert.equal((await channel.scopeControls(second.bindingId,'exercise')).activeSessionId,null)
|
|
38
|
+
await assert.rejects(channel.changeScopeControls(second.bindingId,'exercise',{action:'new',expectedSession:before.activeSessionId}),/changed/)
|
|
39
|
+
assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,200)
|
|
40
|
+
const next = await channel.scopeControls(binding.bindingId,'exercise')
|
|
41
|
+
assert.notEqual(next.activeSessionId,before.activeSessionId)
|
|
42
|
+
assert.equal(next.ai.selectedId,menu.initial.id)
|
|
43
|
+
assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,400)
|
|
44
|
+
assert.equal((await control.executionSession(run.execution!)).nativeSessionId,'native-private')
|
|
45
|
+
assert.equal((await control.getActiveSession())!.sessionId,shared.sessionId)
|
|
46
|
+
assert.equal((await control.listSessions()).some(item=>item.sessionId===before.activeSessionId || item.sessionId===next.activeSessionId),false)
|
|
47
|
+
const after = await channel.submit(binding.bindingId,{requestId:'after-reset',scope:'exercise',text:'new context'})
|
|
48
|
+
assert.equal(after.execution!.sessionId,next.activeSessionId)
|
|
49
|
+
const presets = (await control.status()).ai!.presets
|
|
50
|
+
assert.equal((await call({action:'model',expectedSession:before.activeSessionId,cli:'codex',model:'fixture-model',effort:'high'})).status,400)
|
|
51
|
+
assert.deepEqual((await control.status()).ai!.presets,presets)
|
|
52
|
+
assert.equal((await call({action:'model',expectedSession:next.activeSessionId,cli:'codex',model:'fixture-model',effort:'high'})).status,200)
|
|
53
|
+
const updated = await channel.submit(binding.bindingId,{requestId:'updated-model',scope:'exercise',text:'same engine'})
|
|
54
|
+
assert.equal(updated.execution!.sessionId,next.activeSessionId)
|
|
55
|
+
assert.equal(updated.execution!.preset.effort,'high')
|
|
56
|
+
assert.deepEqual((await new RunStore(root).get(after.id))!.execution,after.execution)
|
|
57
|
+
assert.equal((await control.status()).ai!.selectedId,selectedBefore)
|
|
58
|
+
for (const scope of ['_detail','.', '..', 'x'.repeat(180)]) {
|
|
59
|
+
const own = await channel.submit(binding.bindingId,{requestId:`scope-${scope}`,scope,text:'scope contract',ai:{cli:'codex',model:'fixture-model',effort:'low'}})
|
|
60
|
+
const response = await fetch(`http://127.0.0.1:${address.port}/v1/scope-control?${new URLSearchParams({scope})}`,{headers:{authorization:`Bearer ${token}`}})
|
|
61
|
+
assert.equal(response.status,200)
|
|
62
|
+
const state = await response.json() as any
|
|
63
|
+
assert.equal(state.activeSessionId,own.execution!.sessionId)
|
|
64
|
+
await channel.changeScopeControls(binding.bindingId,scope,{action:'select',expectedSession:state.activeSessionId,presetId:state.ai.selectedId})
|
|
65
|
+
}
|
|
66
|
+
assert.equal((await call({action:'model',expectedSession:next.activeSessionId,cli:'opencode',model:'fixture-other'})).status,200)
|
|
67
|
+
assert.notEqual((await channel.scopeControls(binding.bindingId,'exercise')).activeSessionId,next.activeSessionId)
|
|
68
|
+
await channel.bindings.register('private',null,owner)
|
|
69
|
+
assert.equal((await call()).status,401)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('application controls share native choices, protect hidden scopes and preserve admitted runs', async t => {
|
|
73
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-app-controls-'))
|
|
74
|
+
const control = new ControlStore(root, 1000)
|
|
75
|
+
await control.requestPairing(42, 42); const owner = await control.approveOwner(42)
|
|
76
|
+
const menu = createAiMenu(control, 'codex', async () => [
|
|
77
|
+
{cli:'codex', model:'gpt-6-astra', name:'Fixture model', efforts:['low','high']},
|
|
78
|
+
], root, join(root, 'native-home'), async () => true)
|
|
79
|
+
const channel = new ApplicationChannel({controlDir:root, initial:menu.initial, aiControls:menu, wake:()=>{}, cancel:async()=>{}})
|
|
80
|
+
t.after(async()=>{await channel.stop(); await rm(root,{recursive:true,force:true})})
|
|
81
|
+
const token = randomBytes(32).toString('base64url'), privateToken = randomBytes(32).toString('base64url')
|
|
82
|
+
const shared = (await channel.bindings.register('shared', token, owner, true))!
|
|
83
|
+
const isolated = (await channel.bindings.register('isolated', privateToken, owner))!
|
|
84
|
+
const address = await channel.listen(0) as {port:number}
|
|
85
|
+
const request = (body?:unknown, bearer=token) => fetch(`http://127.0.0.1:${address.port}/v1/control`, {
|
|
86
|
+
method:body===undefined?'GET':'POST', headers:{authorization:`Bearer ${bearer}`},
|
|
87
|
+
...(body===undefined?{}:{body:JSON.stringify(body)}),
|
|
88
|
+
})
|
|
89
|
+
assert.equal((await request(undefined, 'invalid')).status,401)
|
|
90
|
+
assert.equal((await request(undefined, privateToken)).status,403)
|
|
91
|
+
assert.equal((await request({action:'new',expectedSession:null}, privateToken)).status,403)
|
|
92
|
+
const initial = await (await request()).json() as any
|
|
93
|
+
assert.equal(initial.ai.selectedId,menu.initial.id)
|
|
94
|
+
assert.equal(initial.ai.presets.find((p:any)=>p.id===menu.initial.id).model,undefined)
|
|
95
|
+
assert.equal((await control.status()).ai,undefined,'reading controls does not initialize or rewrite saved state')
|
|
96
|
+
const run = await channel.submit(shared.bindingId,{requestId:'queued',scope:'main',followTelegram:true,text:'Keep the admitted native choice'})
|
|
97
|
+
const old = run.execution!
|
|
98
|
+
await control.saveNativeSession(old.sessionId,'native-private-id')
|
|
99
|
+
const hidden = await control.captureApplicationChoice(menu.initial,applicationScope(isolated.bindingId,'private'))
|
|
100
|
+
const visible = await (await request()).json() as any
|
|
101
|
+
assert.equal(JSON.stringify(visible).includes('native-private-id'),false)
|
|
102
|
+
assert.equal(JSON.stringify(visible).includes(hidden.sessionId),false)
|
|
103
|
+
assert.equal((await request({action:'switch',sessionId:hidden.sessionId,expectedSession:old.sessionId})).status,400)
|
|
104
|
+
assert.equal((await request({action:'model',cli:'codex',model:'invented',expectedSession:old.sessionId})).status,400)
|
|
105
|
+
const presetsBefore = (await control.aiState(menu.initial)).presets
|
|
106
|
+
assert.equal((await request({action:'model',cli:'codex',model:'gpt-6-astra',effort:'high',expectedSession:null})).status,400)
|
|
107
|
+
assert.deepEqual((await control.aiState(menu.initial)).presets,presetsBefore)
|
|
108
|
+
const selected = await request({action:'model',cli:'codex',model:'gpt-6-astra',effort:'high',expectedSession:old.sessionId})
|
|
109
|
+
assert.equal(selected.status,200)
|
|
110
|
+
assert.equal((await control.captureChoice(menu.initial)).preset.effort,'high')
|
|
111
|
+
assert.deepEqual((await new RunStore(root).get(run.id))!.execution,old)
|
|
112
|
+
const reset = await request({action:'new',expectedSession:old.sessionId})
|
|
113
|
+
assert.equal(reset.status,200)
|
|
114
|
+
const next = (await control.getActiveSession())!
|
|
115
|
+
assert.notEqual(next.sessionId,old.sessionId)
|
|
116
|
+
assert.equal((await request({action:'new',expectedSession:old.sessionId})).status,400)
|
|
117
|
+
assert.equal((await control.getActiveSession())!.sessionId,next.sessionId)
|
|
118
|
+
await channel.bindings.register('shared',null,owner)
|
|
119
|
+
assert.equal((await request()).status,401)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('atomic control mutations reject a replaced owner even when both active sessions are empty', async t => {
|
|
123
|
+
const root = await mkdtemp(join(tmpdir(),'ez-control-owner-'))
|
|
124
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
125
|
+
const control = new ControlStore(root,1000)
|
|
126
|
+
await control.requestPairing(42,42); const previous = await control.approveOwner(42)
|
|
127
|
+
await control.revokeOwner()
|
|
128
|
+
await control.requestPairing(43,43); await control.approveOwner(43)
|
|
129
|
+
await assert.rejects(control.resetSession(null,{owner:previous,authorize:async()=>{}}),/owner changed/)
|
|
130
|
+
assert.equal(await control.getActiveSession(),null)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('scope control guards reject revocation after waiting for the state lock', async t => {
|
|
134
|
+
const root = await mkdtemp(join(tmpdir(),'ez-scope-lock-'))
|
|
135
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
136
|
+
const control = new ControlStore(root,1000)
|
|
137
|
+
await control.requestPairing(42,42); const owner = await control.approveOwner(42)
|
|
138
|
+
const preset = {id:'fixture',name:'Fixture',cli:'codex'}
|
|
139
|
+
await control.aiState(preset)
|
|
140
|
+
const scope = applicationScope('binding','private')
|
|
141
|
+
const guard = {owner,applicationScope:scope,expectedSession:null as string|null,authorize:async()=>{}}
|
|
142
|
+
const first = await control.changeApplicationSession(scope,guard)
|
|
143
|
+
assert.equal(await control.getActiveSession(),null)
|
|
144
|
+
const before = await control.status()
|
|
145
|
+
let authorized = true
|
|
146
|
+
const lockPath = join(root,'control-state.lock'), lock = await open(lockPath,'wx',0o600)
|
|
147
|
+
const pending = control.changeApplicationSession(scope,{...guard,expectedSession:first.sessionId,authorize:async()=>{
|
|
148
|
+
if (!authorized) throw new Error('Application authority revoked')
|
|
149
|
+
}})
|
|
150
|
+
const rejection = assert.rejects(pending,/revoked/)
|
|
151
|
+
await new Promise(resolve=>setTimeout(resolve,50)); authorized=false
|
|
152
|
+
await lock.close();await rm(lockPath)
|
|
153
|
+
await rejection
|
|
154
|
+
assert.deepEqual(await control.status(),before)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('revocation while native model validation is pending prevents selection', async t => {
|
|
158
|
+
const root = await mkdtemp(join(tmpdir(),'ez-control-revoke-'))
|
|
159
|
+
const control = new ControlStore(root,1000)
|
|
160
|
+
await control.requestPairing(42,42); const owner = await control.approveOwner(42)
|
|
161
|
+
let catalogCalls = 0
|
|
162
|
+
const menu = createAiMenu(control,'codex',async()=>{
|
|
163
|
+
if (++catalogCalls === 2) await channel.bindings.register('shared',null,owner)
|
|
164
|
+
return [{cli:'codex',model:'gpt-6-astra',name:'Fixture',efforts:['low']}]
|
|
165
|
+
},root,join(root,'native-home'),async()=>true)
|
|
166
|
+
const channel = new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{}})
|
|
167
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
168
|
+
const binding = (await channel.bindings.register('shared',randomBytes(32).toString('base64url'),owner,true))!
|
|
169
|
+
const original = await control.captureChoice(menu.initial)
|
|
170
|
+
await assert.rejects(channel.changeControls(binding.bindingId,{
|
|
171
|
+
action:'model',cli:'codex',model:'gpt-6-astra',effort:'low',expectedSession:original.sessionId,
|
|
172
|
+
}),/authority/)
|
|
173
|
+
assert.deepEqual(await control.captureChoice(menu.initial),original)
|
|
174
|
+
assert.equal((await control.aiState(menu.initial)).presets.length,1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('revocation during a control-lock wait prevents every guarded mutation', async t => {
|
|
178
|
+
const root = await mkdtemp(join(tmpdir(),'ez-controls-lock-'))
|
|
179
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
180
|
+
const control = new ControlStore(root,1000)
|
|
181
|
+
await control.requestPairing(42,42); const owner = await control.approveOwner(42)
|
|
182
|
+
const preset = {id:'fixture',name:'Fixture',cli:'codex'}
|
|
183
|
+
await control.aiState(preset)
|
|
184
|
+
const old = await control.captureChoice(preset)
|
|
185
|
+
const current = await control.resetSession()
|
|
186
|
+
const before = await control.status()
|
|
187
|
+
for (const operation of [
|
|
188
|
+
(guard:any)=>control.resetSession(current.sessionId,guard),
|
|
189
|
+
(guard:any)=>control.switchSession(old.sessionId,current.sessionId,guard),
|
|
190
|
+
(guard:any)=>control.savePreset({...preset,id:'new-preset'},guard),
|
|
191
|
+
(guard:any)=>control.selectPreset(preset.id,current.sessionId,false,guard),
|
|
192
|
+
]) {
|
|
193
|
+
let authorized = true, checks = 0
|
|
194
|
+
const lockPath = join(root,'control-state.lock'), lock = await open(lockPath,'wx',0o600)
|
|
195
|
+
const pending = operation({owner,expectedSession:current.sessionId,authorize:async()=>{
|
|
196
|
+
checks++; if (!authorized) throw new Error('Application authority revoked')
|
|
197
|
+
}})
|
|
198
|
+
const rejection = assert.rejects(pending,/revoked/)
|
|
199
|
+
await new Promise(resolve=>setTimeout(resolve,50))
|
|
200
|
+
assert.equal(checks,0,'authority is checked inside the acquired lock')
|
|
201
|
+
authorized = false
|
|
202
|
+
await lock.close(); await rm(lockPath)
|
|
203
|
+
await rejection
|
|
204
|
+
assert.equal(checks,1)
|
|
205
|
+
assert.deepEqual(await control.status(),before)
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
test('web control guards survive Telegram linking but reject another owner generation', async t => {
|
|
211
|
+
const root = await mkdtemp(join(tmpdir(),'ez-owner-channel-controls-'))
|
|
212
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
213
|
+
const control = new ControlStore(root,1000)
|
|
214
|
+
const owner = await control.registerOwner('web-owner')
|
|
215
|
+
const guard = {owner,authorize:async()=>{}}
|
|
216
|
+
await control.requestPairing(42,42); await control.approveOwner(42)
|
|
217
|
+
const session = await control.resetSession(null,guard)
|
|
218
|
+
await control.unlinkTelegram()
|
|
219
|
+
const next = await control.resetSession(session.sessionId,guard)
|
|
220
|
+
await assert.rejects(control.resetSession(next.sessionId,{
|
|
221
|
+
...guard,owner:{...owner,generation:'00000000-0000-0000-0000-000000000000'},
|
|
222
|
+
}),/owner changed/)
|
|
223
|
+
assert.equal((await control.getActiveSession())!.sessionId,next.sessionId)
|
|
224
|
+
})
|