@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.49
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/CHANGELOG.md +9 -0
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +5 -1
- package/docs/docker-runtime.md +28 -0
- package/docs/scheduling.md +41 -0
- package/docs/upgrades.md +4 -10
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +76 -12
- package/src/application-channel.ts +9 -9
- package/src/auth-profile.ts +53 -0
- package/src/codex-session.ts +14 -3
- package/src/control-state.ts +28 -21
- package/src/delivery-socket.ts +2 -0
- package/src/executor.ts +29 -26
- package/src/host-executor.ts +35 -10
- package/src/index.ts +206 -112
- package/src/menu.ts +5 -5
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +27 -8
- package/src/runs.ts +13 -2
- package/src/schedule-cli.ts +33 -7
- package/src/scheduler.ts +9 -4
- package/src/setup-fallback.ts +122 -0
- package/src/task-executor.ts +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/test/auth-profiles.test.ts +187 -0
- package/test/plugin-broker.test.mjs +3 -0
- package/test/plugin-manager.test.mjs +24 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/updates.test.mjs +58 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.49
|
|
4
|
+
|
|
5
|
+
- Upgrade compatible plugins while native work continues, draining in-flight plugin calls for activation and preserving rollback. Core updates still wait for idle.
|
|
6
|
+
- Keep separate named Codex and Claude native login homes, with account-bound conversations, profile-aware model selection and fail-closed admission.
|
|
7
|
+
- Try ordered saved setups for a scheduled task only after a typed failure before work begins. Hooks, model/tool activity, access denial, cancellation and uncertain transport stop fallback; quota rejection skips the same native CLI/provider scope.
|
|
8
|
+
- Preserve each scheduled attempt's captured auth profile and admit only the active setup on the host transport.
|
|
9
|
+
|
|
10
|
+
Beta limits: live fresh-host/reboot acceptance, two-real-account channel switching and real exhausted-account/provider rejection remain pending. Fallback applies to scheduled agent tasks only; OpenCode/Pi/Grok advance only when their CLI is unavailable. The fallback command does not select named profiles, and different credentials do not establish independent quota allowances. Publication does not establish an installed-agent upgrade or authorize provider/domain writes.
|
|
11
|
+
|
|
3
12
|
## 0.1.0-beta.48
|
|
4
13
|
|
|
5
14
|
- Skip empty or unchanged scheduled work through owner-configured installed read-only plugin preflight. Invalid or unavailable reads fail closed; saved execution settings and recurring timing remain intact. The bound plugin broker is required.
|
|
@@ -6,8 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
-
import { atomic, snapshot } from '../src/plugins/manager.mjs';
|
|
10
|
-
import { execute, refreshBroker } from '../src/updates/runtime.mjs';
|
|
9
|
+
import { atomic, snapshot, publishBrokerPlugins } from '../src/plugins/manager.mjs';
|
|
10
|
+
import { execute, refreshBroker, verifyBroker, brokerNeedsRefresh } from '../src/updates/runtime.mjs';
|
|
11
11
|
const docker=(...args)=>execFileSync('docker',args,{encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout:120000}).trim();
|
|
12
12
|
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
13
13
|
if(process.argv[2]!=='--inside') {
|
|
@@ -69,6 +69,20 @@ if(process.argv[2]!=='--inside') {
|
|
|
69
69
|
assert.equal((await mounted()).trim(),ready.hostSha256);assert.equal((await prepare()).trim(),'prepared');
|
|
70
70
|
assert.equal(ready.plugins[0].revision,b.revision);
|
|
71
71
|
console.log(JSON.stringify({stage:'activation',oldBroker,newBroker,relay,hostSha256:ready.hostSha256,registrySha256:ready.registrySha256,revision:b.revision}));
|
|
72
|
+
// With the current host binding already approving both revisions, plugin-only
|
|
73
|
+
// activation/rollback must leave the broker socket and relay intact.
|
|
74
|
+
await publish(a);await publishBrokerPlugins(home,controlDir);
|
|
75
|
+
const pluginRollback=await verifyBroker(config);
|
|
76
|
+
assert.equal(pluginRollback.plugins[0].revision,a.revision);
|
|
77
|
+
assert.equal(await container('plugin-broker'),newBroker);assert.equal(await container('relay'),relay);
|
|
78
|
+
assert.equal(await brokerNeedsRefresh(config),false);assert.equal((await prepare()).trim(),'prepared');
|
|
79
|
+
await publish(b);await publishBrokerPlugins(home,controlDir);
|
|
80
|
+
const pluginActivation=await verifyBroker(config);
|
|
81
|
+
assert.equal(pluginActivation.plugins[0].revision,b.revision);
|
|
82
|
+
assert.equal(await container('plugin-broker'),newBroker);assert.equal(await container('relay'),relay);
|
|
83
|
+
assert.equal((await prepare()).trim(),'prepared');
|
|
84
|
+
console.log('PASS plugin activation/rollback: current registry accepted; broker and relay IDs preserved');
|
|
85
|
+
|
|
72
86
|
await atomic(hostConfig,host([a.revision]));
|
|
73
87
|
await assert.rejects(refreshBroker(config),/STRUCTURAL_READINESS_FAILED/);await assert.rejects(prepare(),/not pinned/);
|
|
74
88
|
console.log('PASS unapproved B remains rejected after recreation');
|
package/docker/upgrade-smoke.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const exec=promisify(execFile),root=await fs.realpath(await fs.mkdtemp(path.join
|
|
|
14
14
|
const source=path.join(root,'source'),home=path.join(root,'tools'),mind=path.join(root,'mind'),control=path.join(root,'control');
|
|
15
15
|
const input=path.resolve(process.env.EZ_WHATSAPP_SOURCE||new URL('../../ez_whatsapp',import.meta.url).pathname);
|
|
16
16
|
const bin=new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname;
|
|
17
|
-
const call=async(...args)=>JSON.parse((await exec(process.execPath,[bin,'--home',home,...args],{maxBuffer:4*1024*1024})).stdout);
|
|
17
|
+
const call=async(...args)=>JSON.parse((await exec(process.execPath,[bin,'--home',home,...args],{cwd:mind,maxBuffer:4*1024*1024})).stdout);
|
|
18
18
|
let record;
|
|
19
19
|
try {
|
|
20
20
|
for(const dir of [source,mind,control])await fs.mkdir(dir);
|
|
@@ -12,7 +12,11 @@ Each named conversation saves its model and reasoning level. Switching back thro
|
|
|
12
12
|
`/ai` shows the current choice and saves changes immediately. Changing model or
|
|
13
13
|
reasoning level on the same client and provider preserves the conversation's name
|
|
14
14
|
and native history; subsequent turns pass the new settings to the native engine.
|
|
15
|
-
Changing client or
|
|
15
|
+
Changing client, provider or auth profile starts a separate native conversation.
|
|
16
|
+
A conversation records its client and auth profile when created; launch rejects a
|
|
17
|
+
choice for another profile, so a native session never resumes under another
|
|
18
|
+
account. Provisioned profiles appear as additional catalog entries for their
|
|
19
|
+
client (see [auth profiles](../docker-runtime.md#auth-profiles)). Old bindings
|
|
16
20
|
remain available so accepted work can finish and owners can reopen their chats.
|
|
17
21
|
|
|
18
22
|
Grok/Claude accept caller-selected native UUIDs. Codex/OpenCode generate IDs, so
|
package/docs/docker-runtime.md
CHANGED
|
@@ -295,6 +295,34 @@ Without any login, Claude turns fail closed with the native "not logged in"
|
|
|
295
295
|
error. Host-capable agents use the same agent-bound directory but share the
|
|
296
296
|
installer login in place (`CLAUDE_SECURESTORAGE_CONFIG_DIR` empty).
|
|
297
297
|
|
|
298
|
+
## Auth profiles
|
|
299
|
+
|
|
300
|
+
One agent can keep several owner-provisioned Codex or Claude logins, for example
|
|
301
|
+
a work and a personal account. Each named profile is a separate native home,
|
|
302
|
+
`control/cli/profiles/<name>/<cli>` (`CODEX_HOME` or `CLAUDE_CONFIG_DIR`), with
|
|
303
|
+
its own credentials, configuration and native sessions. The unnamed default
|
|
304
|
+
keeps the bindings above unchanged. Create the empty home and get the exact
|
|
305
|
+
native login command with:
|
|
306
|
+
|
|
307
|
+
```sh
|
|
308
|
+
ezenciel-agents-ai profile add --cli claude --auth-profile work
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Then the owner logs in once with the printed command, inside the relay for
|
|
312
|
+
isolated agents. Claude may instead use a `claude setup-token` token in that
|
|
313
|
+
home's `oauth-token` (mode `600`). Ez never reads, copies or prints the
|
|
314
|
+
credential. A named profile never links the host login or another home: an
|
|
315
|
+
unprovisioned or logged-out profile fails closed with the native error.
|
|
316
|
+
`ezenciel-agents-ai profiles` reports each home's login status (logged in,
|
|
317
|
+
method, credential file names) from the native CLI without tokens or account
|
|
318
|
+
identifiers.
|
|
319
|
+
|
|
320
|
+
Select a profile with `ezenciel-agents-ai select --cli claude --auth-profile work`,
|
|
321
|
+
the profile entries in Choose AI, or `ezenciel-agents-schedule create ...
|
|
322
|
+
--auth-profile work`. A conversation is bound to its CLI and profile; changing
|
|
323
|
+
profile starts a new native conversation, and a run whose request names another
|
|
324
|
+
profile than its captured choice is rejected before launch.
|
|
325
|
+
|
|
298
326
|
## OpenCode auth
|
|
299
327
|
|
|
300
328
|
Isolated agents run the image OpenCode with `XDG_DATA_HOME` set to the
|
package/docs/scheduling.md
CHANGED
|
@@ -55,6 +55,47 @@ Paused, stale-owner, overlapping or held tasks and unmet conditions cannot be
|
|
|
55
55
|
triggered. Historical deferred tasks keep their source context
|
|
56
56
|
available through `ezenciel-agents-schedule context`.
|
|
57
57
|
|
|
58
|
+
## Ordered setup fallback
|
|
59
|
+
|
|
60
|
+
An agent task may list up to three fallback setups after its saved one, for
|
|
61
|
+
example `claude · opus → claude · sonnet → codex · gpt-6-sol`:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
ezenciel-agents-schedule create --name Daily --cli claude --model opus \
|
|
65
|
+
--cron '0 9 * * *' --timezone Asia/Dubai --text 'Prepare the daily report.' \
|
|
66
|
+
--fallback cli=claude,model=sonnet,effort=medium --fallback cli=codex,model=gpt-6-sol
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`edit` keeps the saved fallbacks unless `--fallback` replaces them or
|
|
70
|
+
`--clear-fallbacks` removes them. Setups must be distinct and each needs a model.
|
|
71
|
+
|
|
72
|
+
Core advances to the next setup only with typed evidence that nothing ran: the
|
|
73
|
+
CLI is not installed or executable, or the provider rejected the turn before any
|
|
74
|
+
model output, tool or hook item (quota/rate limit, billing, overload, server
|
|
75
|
+
error, unavailable model or bad request). It stops, without replay, on access
|
|
76
|
+
denial (authentication, account or organization), cancellation, relay shutdown,
|
|
77
|
+
a schedule edit, a policy or unknown error, an interrupted transport, or any
|
|
78
|
+
failure after work began. Evidence comes from structured events only: Codex
|
|
79
|
+
native-session `codexErrorInfo` and Claude `stream-json` error fields (scheduled
|
|
80
|
+
Claude runs use `--output-format stream-json`; the host forwards only a compact
|
|
81
|
+
startup summary). Other engines advance only when their CLI is unavailable.
|
|
82
|
+
|
|
83
|
+
A quota rejection skips the remaining setups in the same quota scope: the native
|
|
84
|
+
client (`claude`, `codex` including desktop and custom Codex providers, `grok`)
|
|
85
|
+
or the upstream provider for OpenCode/Pi models. A different credential alone is
|
|
86
|
+
never treated as an independent allowance. Each setup runs at most once per
|
|
87
|
+
occurrence.
|
|
88
|
+
|
|
89
|
+
Every attempt keeps the same run ID, occurrence, schedule revision, workspace,
|
|
90
|
+
delivery binding and scheduled concurrency slot, in a fresh native session.
|
|
91
|
+
`run RUN_ID` shows `attempts`: each setup, quota scope, outcome, failure category
|
|
92
|
+
(`cli-unavailable`, `quota`, `provider-rejected`, `access-denied`,
|
|
93
|
+
`after-work-began`, `uncertain`, or `same-quota-scope` for a skipped setup), exit
|
|
94
|
+
code and reported reset time. If every setup is unavailable the run fails with
|
|
95
|
+
reason `setups-unavailable`, a summary of each setup, and an owner notice on
|
|
96
|
+
Telegram-bound tasks. Core never purchases credits, enables overage or consumes
|
|
97
|
+
reset credits; the next regular occurrence runs normally.
|
|
98
|
+
|
|
58
99
|
## Registered script schedules
|
|
59
100
|
|
|
60
101
|
A schedule has one execution type. **Agent** schedules run the saved prompt with
|
package/docs/upgrades.md
CHANGED
|
@@ -16,7 +16,7 @@ automatic channel for core and plugins without a saved policy. Existing explicit
|
|
|
16
16
|
stable or manual policies are preserved. The owner may select either per target.
|
|
17
17
|
The main target and installed plugins version independently.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Core upgrades leave channel admission open while native work runs, then pause it after work is idle and recheck races. Plugin upgrades build while native work continues. Activation closes the existing registry admission lock, drains in-flight plugin command leases, and replaces the plugin under that lock. New native plugin calls wait for admission; an outdated revision fails before provider execution and must be rediscovered. No admitted provider operation is replayed. The shared broker and native sessions remain running. Core upgrades still require the full native-work drain. A plugin update also retains that drain if an operator replaced the broker’s single-file host binding (for example to approve new network pins), because that case still requires broker recreation.
|
|
20
20
|
|
|
21
21
|
The owner chooses the saved update policy. The host supervisor owns
|
|
22
22
|
interruption-safe replacement. There is one host service per agent, not a second
|
|
@@ -153,9 +153,7 @@ is required. Stable automation excludes prereleases, major changes and 0.x minor
|
|
|
153
153
|
changes. Beta policy permits compatible prereleases. Downgrades are rejected;
|
|
154
154
|
rollback is a recovery operation for the previous installation only.
|
|
155
155
|
|
|
156
|
-
`apply` records one durable queued job. **
|
|
157
|
-
queuing it.** It must not wait for completion in that same turn. The supervisor
|
|
158
|
-
pauses new run admission, drains already-started work, then consumes the job.
|
|
156
|
+
`apply` records one durable queued job. **For a core upgrade, the agent must finish its turn after queuing it** so the supervisor can drain native work. A plugin upgrade can complete while that turn continues; only plugin-call admission is held during activation. Calls already executing finish first.
|
|
159
157
|
One job at a time; an unresolved recovery blocks new upgrades. The agent processes
|
|
160
158
|
multiple requested packages over successive update requests.
|
|
161
159
|
|
|
@@ -294,12 +292,8 @@ Actual update transactions retain their existing admission, drain and rollback r
|
|
|
294
292
|
|
|
295
293
|
### Isolated broker readiness
|
|
296
294
|
|
|
297
|
-
Isolated activation and rollback explicitly recreate only `plugin-broker` with
|
|
298
|
-
`--no-deps --force-recreate --no-build --pull never --wait`. Plugin-only refresh
|
|
299
|
-
pins the installed broker image ID; main activation uses the newly built image
|
|
300
|
-
and rollback uses the recorded old image. This refreshes the narrow read-only
|
|
301
|
-
host-configuration file mount after atomic replacement. The relay and native
|
|
302
|
-
sessions are preserved during plugin-only refresh. Host-capable updates do not
|
|
295
|
+
Isolated core activation and rollback explicitly recreate only `plugin-broker` with
|
|
296
|
+
`--no-deps --force-recreate --no-build --pull never --wait`. Core activation uses the newly built image and rollback uses the recorded old image to refresh the single-file host binding. Plugin-only activation and rollback change directory-mounted registry files, verify the existing broker in place, and refresh its generated plugin-version inventory. They preserve the broker socket, relay and native sessions. Host-capable updates do not
|
|
303
297
|
start a broker. Existing update admission and drain remain authoritative.
|
|
304
298
|
|
|
305
299
|
Socket health is liveness only. Before reporting completion, the updater runs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.49",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
package/src/ai-cli.ts
CHANGED
|
@@ -1,38 +1,51 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util'
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { readFile } from 'node:fs/promises'
|
|
5
|
-
import { readModels, presetProvider, validateSelection, type AiPreset } from './ai.js'
|
|
4
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
5
|
+
import { authProfileStatus, readModels, presetProvider, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
6
|
+
import { cliHome } from './auth-profile.js'
|
|
6
7
|
import { ControlStore } from './control-state.js'
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const usage='Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--auth-profile <name>] [--provider <provider>] [--model <model>] [--effort <effort>] | default | profiles | profile add --cli codex|claude --auth-profile <name>'
|
|
10
|
+
const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},'auth-profile':{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
|
|
9
11
|
if(values.help){
|
|
10
|
-
console.log(
|
|
12
|
+
console.log(`${usage}\nChoose only values returned by list. Selection affects subsequent messages; default makes the selected AI the choice for new conversations. Queued work is unchanged.\nAuth profiles are separate owner-provisioned Codex/Claude logins with their own configuration and sessions; changing profile starts a new conversation. profile add creates an empty home and prints the native login command for the owner to run; profiles reports read-only login status without secrets.`)
|
|
11
13
|
process.exit(0)
|
|
12
14
|
}
|
|
13
15
|
if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const controlDir=process.env.EZ_CONTROL_DIR
|
|
17
|
+
const authProfile=values['auth-profile']
|
|
18
|
+
const catalog=():Promise<ModelChoice[]>=>readFile(join(controlDir,'host-executor','models.json'),'utf8')
|
|
19
|
+
.then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(controlDir,'cli','codex'),undefined,undefined,undefined,controlDir))
|
|
20
|
+
if(positionals[0]==='list')console.log(JSON.stringify(await catalog()))
|
|
21
|
+
else if(positionals[0]==='profiles')console.log(JSON.stringify(await authProfileStatus(controlDir)))
|
|
22
|
+
else if(positionals[0]==='profile' && positionals[1]==='add'){
|
|
23
|
+
if(!authProfile)throw new Error('profile add requires --auth-profile <name>')
|
|
24
|
+
const home=cliHome(controlDir,values.cli||'',authProfile)
|
|
25
|
+
const created=await mkdir(home,{recursive:true,mode:0o700})
|
|
26
|
+
const login=values.cli==='codex' ? `CODEX_HOME=${home} codex login`
|
|
27
|
+
: `CLAUDE_CONFIG_DIR=${home} claude auth login (or store a \`claude setup-token\` token in ${home}/oauth-token, mode 600)`
|
|
28
|
+
console.log(JSON.stringify({cli:values.cli,authProfile,home,created:created!==undefined,ownerLogin:login}))
|
|
29
|
+
}
|
|
17
30
|
else if(positionals[0]==='default'){
|
|
18
|
-
const control=new ControlStore(
|
|
31
|
+
const control=new ControlStore(controlDir,900000)
|
|
19
32
|
const state=await control.status()
|
|
20
33
|
const selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
|
|
21
34
|
if(!selected)throw new Error('No selected AI')
|
|
22
|
-
await validateSelection(selected,catalog)
|
|
35
|
+
await validateSelection(selected,await catalog())
|
|
23
36
|
await control.defaultPreset(selected.id)
|
|
24
37
|
console.log(JSON.stringify({default:selected,applies:'new conversations; current and queued work unchanged'}))
|
|
25
38
|
}
|
|
26
39
|
else if(positionals[0]==='select'){
|
|
27
|
-
const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',provider:values.provider,model:values.model,effort:values.effort}
|
|
28
|
-
await validateSelection(preset,catalog)
|
|
29
|
-
const control=new ControlStore(
|
|
40
|
+
const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.provider,values.model||values.cli,values.effort].filter(Boolean).join(' · ')+(authProfile ? ` @${authProfile}` : ''),cli:values.cli||'',provider:values.provider,...(authProfile ? {authProfile} : {}),model:values.model,effort:values.effort}
|
|
41
|
+
await validateSelection(preset,await catalog())
|
|
42
|
+
const control=new ControlStore(controlDir,900000)
|
|
30
43
|
const state=await control.status()
|
|
31
44
|
if(!state.ai)throw new Error('Agent AI settings are not initialized')
|
|
32
|
-
const existing=state.ai.presets.find(p=>p
|
|
45
|
+
const existing=state.ai.presets.find(p=>sameChoice(p,preset))
|
|
33
46
|
const selected=existing||preset
|
|
34
47
|
if(!existing)await control.savePreset(selected)
|
|
35
48
|
const current=state.ai.presets.find(p=>p.id===state.ai!.selectedId)!
|
|
36
|
-
await control.selectPreset(selected.id,state.activeSession?.sessionId??null,
|
|
49
|
+
await control.selectPreset(selected.id,state.activeSession?.sessionId??null,!sameEngine(current,selected)||presetProvider(current)!==presetProvider(selected)||Boolean(state.activeSession&&!state.activeSession.cli))
|
|
37
50
|
console.log(JSON.stringify({selected,defaultUnchanged:true,applies:'subsequent messages; queued work keeps its captured choice'}))
|
|
38
|
-
}else throw new Error(
|
|
51
|
+
}else throw new Error(usage)
|
package/src/ai.ts
CHANGED
|
@@ -5,12 +5,20 @@ import { execFile } from 'node:child_process'
|
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import { join, delimiter } from 'node:path'
|
|
8
|
-
import { executorEnvironment, executorInvocation, executorKey, resolveExecutor } from './executor.js'
|
|
8
|
+
import { CliUnavailableError, executorEnvironment, executorInvocation, executorKey, resolveExecutor } from './executor.js'
|
|
9
9
|
import { desktopCodexPath } from './desktop-bridge.js'
|
|
10
|
+
import { AUTH_PROFILE_CLIS, claudeAuthEnvironment, cliHome, isAuthProfile, listAuthProfiles } from './auth-profile.js'
|
|
10
11
|
|
|
11
|
-
export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
|
|
12
|
-
|
|
13
|
-
export type
|
|
12
|
+
export type AiPreset = { id: string; name: string; cli: string; provider?: string; authProfile?: string; model?: string; effort?: string }
|
|
13
|
+
// Optional ordered fallbacks are tried only for a scheduled occurrence that never started work.
|
|
14
|
+
export type ExecutionChoice = { sessionId: string; preset: AiPreset; fallbacks?: AiPreset[] }
|
|
15
|
+
export const MAX_FALLBACKS = 3
|
|
16
|
+
export type ModelChoice = { cli: string; provider?: string; authProfile?: string; model?: string; name: string; efforts: string[] }
|
|
17
|
+
type Engine = { cli?: string; authProfile?: string }
|
|
18
|
+
// A native conversation belongs to one client and one credential home.
|
|
19
|
+
export const sameEngine = (a: Engine, b: Engine) => a.cli === b.cli && a.authProfile === b.authProfile
|
|
20
|
+
export const sameChoice = (a: Omit<AiPreset, 'id' | 'name'>, b: Omit<AiPreset, 'id' | 'name'>) =>
|
|
21
|
+
sameEngine(a, b) && a.provider === b.provider && a.model === b.model && a.effort === b.effort
|
|
14
22
|
const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
|
|
15
23
|
const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'pi']
|
|
16
24
|
const nativeEffort = (cli: string, effort: string) =>
|
|
@@ -20,13 +28,15 @@ export const isPreset = (p: unknown): p is AiPreset => {
|
|
|
20
28
|
const v = p as AiPreset
|
|
21
29
|
return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
|
|
22
30
|
knownClis.includes(v.cli) &&
|
|
23
|
-
(v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
|
|
31
|
+
(v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort)) &&
|
|
32
|
+
(v.authProfile === undefined || (isAuthProfile(v.authProfile) && AUTH_PROFILE_CLIS.includes(v.cli)))
|
|
24
33
|
}
|
|
25
34
|
export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
|
|
26
35
|
const c = v as ExecutionChoice | undefined
|
|
27
|
-
return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset)
|
|
36
|
+
return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset) &&
|
|
37
|
+
(c.fallbacks === undefined || (Array.isArray(c.fallbacks) && c.fallbacks.length > 0 && c.fallbacks.length <= MAX_FALLBACKS && c.fallbacks.every(isPreset))))
|
|
28
38
|
}
|
|
29
|
-
export const presetLabel = (p: AiPreset) => `${p.cli}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
|
|
39
|
+
export const presetLabel = (p: AiPreset) => `${p.cli}${p.authProfile ? `@${p.authProfile}` : ''}${p.provider ? ` (${p.provider})` : ''} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
|
|
30
40
|
// OpenCode encodes its provider in the native provider/model identifier.
|
|
31
41
|
export const presetProvider = (p: AiPreset) => p.cli === 'opencode' ? p.model?.split('/')[0] : p.provider
|
|
32
42
|
// The seed delegates model selection to the native client. Project its resolved
|
|
@@ -206,7 +216,11 @@ export const readModels = async (home = homedir(), available = installed, codexH
|
|
|
206
216
|
// establish which models Pi can execute.
|
|
207
217
|
if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
|
|
208
218
|
const curated = await readCuratedModels(curationDir)
|
|
209
|
-
|
|
219
|
+
const profiles = await listAuthProfiles(curationDir)
|
|
220
|
+
// Each provisioned profile offers its client's entries under its own home.
|
|
221
|
+
const withProfiles = (listed: ModelChoice[]) => [...listed, ...profiles.flatMap(({ cli, authProfile }) =>
|
|
222
|
+
listed.filter((model) => model.cli === cli).map((model) => ({ ...model, authProfile, name: `${authProfile} · ${model.name}`.slice(0, 80) })))]
|
|
223
|
+
if (!curated.length) return withProfiles(models)
|
|
210
224
|
const scoped: ModelChoice[] = []
|
|
211
225
|
for (const entry of curated) {
|
|
212
226
|
if (!(await available(entry.cli))) continue
|
|
@@ -216,7 +230,7 @@ export const readModels = async (home = homedir(), available = installed, codexH
|
|
|
216
230
|
}
|
|
217
231
|
const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
|
|
218
232
|
const curatedKeys = new Set(scoped.map(key))
|
|
219
|
-
return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
|
|
233
|
+
return withProfiles([...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))])
|
|
220
234
|
}
|
|
221
235
|
export const opencodeCatalogModels = async (
|
|
222
236
|
opencodeRunner?: (args: string[]) => Promise<string>,
|
|
@@ -252,9 +266,59 @@ export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env):
|
|
|
252
266
|
|
|
253
267
|
export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
|
|
254
268
|
assertEffort(p.effort, p.model, p.cli)
|
|
255
|
-
if (!isPreset(p)
|
|
256
|
-
if (!p.
|
|
257
|
-
|
|
269
|
+
if (!isPreset(p)) throw new Error('This CLI is not installed.')
|
|
270
|
+
if (!(await available(p.cli))) throw new CliUnavailableError('This CLI is not installed.')
|
|
271
|
+
if (!p.provider && !p.authProfile && !p.model && !p.effort) return
|
|
272
|
+
if (p.authProfile && !p.provider && !p.model && !p.effort) {
|
|
273
|
+
if (!catalog.some((m) => sameEngine(m, p))) throw new Error('This auth profile is not provisioned for the selected CLI.')
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
const model = catalog.find((m) => sameEngine(m, p) && m.provider === p.provider && m.model === p.model)
|
|
258
277
|
if (!model || (p.effort && !model.efforts.includes(p.effort)))
|
|
259
278
|
throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
|
|
260
279
|
}
|
|
280
|
+
|
|
281
|
+
export type AuthProfileStatus = { cli: string; authProfile: string | null; provisioned: boolean; sharesHostLogin: boolean
|
|
282
|
+
credentialFiles: string[]; loggedIn: boolean | null; method: string | null }
|
|
283
|
+
type StatusRunner = (command: string, args: string[], env: NodeJS.ProcessEnv, cwd: string) => Promise<{ code: number | null; stdout: string; stderr: string }>
|
|
284
|
+
const nativeStatus: StatusRunner = (command, args, env, cwd) => new Promise((resolve) => {
|
|
285
|
+
const invocation = executorInvocation(command, args)
|
|
286
|
+
execFile(invocation.command, invocation.args, { env, cwd, timeout: 8000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) =>
|
|
287
|
+
resolve({ code: error ? (typeof error.code === 'number' ? error.code : null) : 0, stdout: String(stdout), stderr: String(stderr) }))
|
|
288
|
+
})
|
|
289
|
+
const credentialNames: Record<string, string[]> = { codex: ['auth.json'], claude: ['oauth-token', '.credentials.json'] }
|
|
290
|
+
|
|
291
|
+
// Read-only auth diagnostics per credential home. Native status runs with the
|
|
292
|
+
// same home and credential environment as execution; only whitelisted fields
|
|
293
|
+
// are projected, never tokens, account identifiers or raw native output.
|
|
294
|
+
export const authProfileStatus = async (controlDir: string, available = installed, run = nativeStatus,
|
|
295
|
+
isolated = process.env.EZ_ISOLATION === 'isolated'): Promise<AuthProfileStatus[]> => {
|
|
296
|
+
const homes: { cli: string; authProfile?: string }[] = [...AUTH_PROFILE_CLIS.map((cli) => ({ cli })), ...await listAuthProfiles(controlDir)]
|
|
297
|
+
const statuses: AuthProfileStatus[] = []
|
|
298
|
+
for (const { cli, authProfile } of homes) {
|
|
299
|
+
if (!authProfile && !(await available(cli))) continue
|
|
300
|
+
const home = cliHome(controlDir, cli, authProfile)
|
|
301
|
+
const provisioned = await access(home).then(() => true, () => false)
|
|
302
|
+
const credentialFiles: string[] = []
|
|
303
|
+
for (const name of credentialNames[cli]) if (await access(join(home, name)).then(() => true, () => false)) credentialFiles.push(name)
|
|
304
|
+
let loggedIn: boolean | null = null, method: string | null = null
|
|
305
|
+
if (provisioned) {
|
|
306
|
+
try {
|
|
307
|
+
if (cli === 'codex') {
|
|
308
|
+
const result = await run('codex', ['login', 'status'], { ...executorEnvironment(), CODEX_HOME: home }, home)
|
|
309
|
+
loggedIn = result.code === null ? null : result.code === 0
|
|
310
|
+
// Codex prints its login status on stderr.
|
|
311
|
+
const text = result.stdout + result.stderr
|
|
312
|
+
method = loggedIn ? /chatgpt/i.test(text) ? 'chatgpt' : /api key/i.test(text) ? 'api-key' : 'other' : null
|
|
313
|
+
} else {
|
|
314
|
+
const result = await run('claude', ['auth', 'status', '--json'], { ...executorEnvironment(), ...await claudeAuthEnvironment(home, authProfile, isolated) }, home)
|
|
315
|
+
const status = result.code === null ? undefined : JSON.parse(result.stdout) as { loggedIn?: unknown; authMethod?: unknown }
|
|
316
|
+
loggedIn = status ? status.loggedIn === true : null
|
|
317
|
+
method = loggedIn && safe(status?.authMethod) ? status.authMethod : null
|
|
318
|
+
}
|
|
319
|
+
} catch { loggedIn = false }
|
|
320
|
+
}
|
|
321
|
+
statuses.push({ cli, authProfile: authProfile ?? null, provisioned, sharesHostLogin: !isolated && !authProfile, credentialFiles, loggedIn, method })
|
|
322
|
+
}
|
|
323
|
+
return statuses
|
|
324
|
+
}
|
|
@@ -6,7 +6,7 @@ import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owne
|
|
|
6
6
|
import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
|
|
7
7
|
import { ownsRun } from './identity.js'
|
|
8
8
|
import { applicationId, validApplicationOrigin } from './application-origin.js'
|
|
9
|
-
import { isPreset, type AiPreset, type ModelChoice } from './ai.js'
|
|
9
|
+
import { isPreset, sameChoice, sameEngine, type AiPreset, type ModelChoice } from './ai.js'
|
|
10
10
|
import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
|
|
11
11
|
import { assertEffort } from './model-policy.js'
|
|
12
12
|
import { ApprovalStore } from './approval.js'
|
|
@@ -158,7 +158,7 @@ export class ApplicationChannel {
|
|
|
158
158
|
const value = input as {action?: unknown; expectedSession?: unknown; sessionId?: unknown; presetId?: unknown; cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown}
|
|
159
159
|
if (!value || !['new','switch','select','model'].includes(String(value.action)) ||
|
|
160
160
|
!(value.expectedSession === null || (typeof value.expectedSession === 'string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
|
|
161
|
-
const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
|
|
161
|
+
const fields = {new:[],switch:['sessionId'],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'switch'|'select'|'model']!
|
|
162
162
|
if (Object.keys(value).some(key => !['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
|
|
163
163
|
const control = new ControlStore(this.options.controlDir, 900000)
|
|
164
164
|
const expected = value.expectedSession as string | null
|
|
@@ -181,7 +181,7 @@ export class ApplicationChannel {
|
|
|
181
181
|
await controls.select(preset, expected, guard)
|
|
182
182
|
}
|
|
183
183
|
if (value.action === 'model') {
|
|
184
|
-
const model = (await controls.catalog()).find(item => item
|
|
184
|
+
const model = (await controls.catalog()).find(item => sameEngine(item, value as {cli?:string;authProfile?:string}) && item.provider === value.provider && item.model === value.model)
|
|
185
185
|
if (!model || (value.effort !== undefined && (typeof value.effort !== 'string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
186
186
|
await this.sharedBinding(bindingId)
|
|
187
187
|
const preset = await controls.saveSelection(model, value.effort as string | undefined, guard)
|
|
@@ -211,10 +211,10 @@ export class ApplicationChannel {
|
|
|
211
211
|
const owner = await this.sharedBinding(bindingId, false)
|
|
212
212
|
const controls = this.options.aiControls
|
|
213
213
|
if (!controls) throw new Error('Application controls unavailable')
|
|
214
|
-
const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; model?:unknown; effort?:unknown}
|
|
214
|
+
const value = input as {action?:unknown; expectedSession?:unknown; presetId?:unknown; cli?:unknown; provider?:unknown; authProfile?:unknown; model?:unknown; effort?:unknown}
|
|
215
215
|
if (!value || !['new','select','model'].includes(String(value.action)) ||
|
|
216
216
|
!(value.expectedSession===null || (typeof value.expectedSession==='string' && /^[a-f0-9-]{36}$/.test(value.expectedSession)))) throw new Error('Invalid application control request')
|
|
217
|
-
const fields = {new:[],select:['presetId'],model:['cli','provider','model','effort']}[value.action as 'new'|'select'|'model']!
|
|
217
|
+
const fields = {new:[],select:['presetId'],model:['cli','provider','authProfile','model','effort']}[value.action as 'new'|'select'|'model']!
|
|
218
218
|
if (Object.keys(value).some(key=>!['action','expectedSession',...fields].includes(key))) throw new Error('Invalid application control fields')
|
|
219
219
|
const hashedScope = applicationScope(bindingId,scope)
|
|
220
220
|
const guard:ControlGuard = {owner,applicationScope:hashedScope,expectedSession:value.expectedSession as string|null,
|
|
@@ -231,7 +231,7 @@ export class ApplicationChannel {
|
|
|
231
231
|
await controls.validate(preset)
|
|
232
232
|
}
|
|
233
233
|
if (value.action==='model') {
|
|
234
|
-
const model = (await controls.catalog()).find(item=>item
|
|
234
|
+
const model = (await controls.catalog()).find(item=>sameEngine(item,value as {cli?:string;authProfile?:string}) && item.provider===value.provider && item.model===value.model)
|
|
235
235
|
if (!model || (value.effort!==undefined && (typeof value.effort!=='string' || !model.efforts.includes(value.effort)))) throw new Error('Invalid application model selection')
|
|
236
236
|
preset = await controls.saveSelection(model,value.effort as string|undefined,guard)
|
|
237
237
|
}
|
|
@@ -257,7 +257,7 @@ export class ApplicationChannel {
|
|
|
257
257
|
const run=await this.runs.create({id,ownerId:ownerId(access.task.owner),ownerEpoch:ownerEpoch(access.task.owner),taskId:access.task.id,texts:[v.text],execution:access.execution,application:{bindingId,scope,requestId:v.requestId,taskAccess:ticketHash}})
|
|
258
258
|
this.options.wake();return run
|
|
259
259
|
}
|
|
260
|
-
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; provider?: unknown; model?: unknown; effort?: unknown } }
|
|
260
|
+
const value = input as { requestId?: unknown; scope?: unknown; text?: unknown; attachment?: { name?: unknown; data?: unknown }; context?: Record<string, unknown>; expectedNativeSessionId?: unknown; activateTelegram?: unknown; followTelegram?: unknown; ai?: { cli?: unknown; provider?: unknown; authProfile?: unknown; model?: unknown; effort?: unknown } }
|
|
261
261
|
if (!value || !applicationId(value.requestId) || !applicationId(value.scope) || typeof value.text !== 'string' || (!value.text.trim() && value.attachment === undefined) || value.text.length > 16000 || Object.keys(value).some(key => !['requestId','scope','text','attachment','context','expectedNativeSessionId','activateTelegram','followTelegram','ai'].includes(key))) throw new Error('Invalid application request')
|
|
262
262
|
if (value.expectedNativeSessionId !== undefined && (typeof value.expectedNativeSessionId !== 'string' || !/^[a-zA-Z0-9_-]{1,160}$/.test(value.expectedNativeSessionId))) throw new Error('Invalid application native session assertion')
|
|
263
263
|
if (value.activateTelegram !== undefined && typeof value.activateTelegram !== 'boolean') throw new Error('Invalid application Telegram activation')
|
|
@@ -266,7 +266,7 @@ export class ApplicationChannel {
|
|
|
266
266
|
let requestedPreset: AiPreset | undefined
|
|
267
267
|
if (value.ai !== undefined) {
|
|
268
268
|
const candidate = { ...value.ai, id: 'application', name: 'Application selection' }
|
|
269
|
-
if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
|
|
269
|
+
if (!value.ai || Object.keys(value.ai).some(key => !['cli', 'provider', 'authProfile', 'model', 'effort'].includes(key)) || !isPreset(candidate)) throw new Error('Invalid application AI selection')
|
|
270
270
|
assertEffort(candidate.effort, candidate.model, candidate.cli)
|
|
271
271
|
requestedPreset = candidate
|
|
272
272
|
}
|
|
@@ -289,7 +289,7 @@ export class ApplicationChannel {
|
|
|
289
289
|
const existing = await this.runs.get(id)
|
|
290
290
|
if (existing) {
|
|
291
291
|
await this.bindings.authorize(existing)
|
|
292
|
-
if (requestedPreset && (existing.execution
|
|
292
|
+
if (requestedPreset && (!existing.execution || !sameChoice(existing.execution.preset, requestedPreset))) throw new Error('Application request ID conflicts with prior AI selection')
|
|
293
293
|
if (Boolean(existing.application?.followTelegram) !== Boolean(value.followTelegram) || existing.application?.scope !== value.scope || (existing.application?.inputText ?? existing.texts[0]) !== value.text || existing.application?.attachmentHash !== attachmentHash) throw new Error('Application request ID conflicts with prior scope or text')
|
|
294
294
|
return existing // Retried context never replaces already admitted capabilities.
|
|
295
295
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Owner-provisioned named credential homes beside the agent default binding.
|
|
5
|
+
// Unset keeps control/cli/<cli>; a name selects control/cli/profiles/<name>/<cli>
|
|
6
|
+
// with its own credentials, configuration and native sessions. Ez never stores,
|
|
7
|
+
// copies or prints credentials; the native CLI logs in inside the home.
|
|
8
|
+
export const AUTH_PROFILE_CLIS: readonly string[] = ['codex', 'claude']
|
|
9
|
+
export const isAuthProfile = (value: unknown): value is string =>
|
|
10
|
+
typeof value === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value)
|
|
11
|
+
|
|
12
|
+
export const cliHome = (controlDir: string, cli: string, authProfile?: string): string => {
|
|
13
|
+
if (authProfile === undefined) return path.join(controlDir, 'cli', cli)
|
|
14
|
+
if (!isAuthProfile(authProfile) || !AUTH_PROFILE_CLIS.includes(cli)) throw new Error('Invalid auth profile selection')
|
|
15
|
+
return path.join(controlDir, 'cli', 'profiles', authProfile, cli)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const realDirectory = (directory: string) => lstat(directory).then(stat => stat.isDirectory(), () => false)
|
|
19
|
+
|
|
20
|
+
// A named home exists only once the owner provisioned it as a real directory.
|
|
21
|
+
// Execution never creates, links or falls back from it.
|
|
22
|
+
export const provisionedHome = async (controlDir: string, cli: string, authProfile?: string): Promise<string> => {
|
|
23
|
+
const home = cliHome(controlDir, cli, authProfile)
|
|
24
|
+
if (authProfile !== undefined && !await realDirectory(home)) throw new Error(`Auth profile ${authProfile} is not provisioned for ${cli}`)
|
|
25
|
+
return home
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const listAuthProfiles = async (controlDir?: string): Promise<{ cli: string; authProfile: string }[]> => {
|
|
29
|
+
if (!controlDir) return []
|
|
30
|
+
const names = await readdir(path.join(controlDir, 'cli', 'profiles')).catch(() => [] as string[])
|
|
31
|
+
const found: { cli: string; authProfile: string }[] = []
|
|
32
|
+
for (const authProfile of names.filter(isAuthProfile).sort())
|
|
33
|
+
for (const cli of AUTH_PROFILE_CLIS)
|
|
34
|
+
if (await realDirectory(cliHome(controlDir, cli, authProfile))) found.push({ cli, authProfile })
|
|
35
|
+
return found
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Claude credentials for one home. Host-capable default homes share the
|
|
39
|
+
// installer login in place; named homes and isolated agents use only their
|
|
40
|
+
// own store, plus an optional owner-provisioned long-lived `oauth-token`.
|
|
41
|
+
export const claudeAuthEnvironment = async (home: string, authProfile?: string, isolated = process.env.EZ_ISOLATION === 'isolated'): Promise<NodeJS.ProcessEnv> => {
|
|
42
|
+
const environment: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: home }
|
|
43
|
+
if (!isolated && authProfile === undefined) environment.CLAUDE_SECURESTORAGE_CONFIG_DIR = ''
|
|
44
|
+
const token = await readFile(path.join(home, 'oauth-token'), 'utf8').then(value => value.trim(), (error: NodeJS.ErrnoException) => {
|
|
45
|
+
if (error.code === 'ENOENT') return undefined
|
|
46
|
+
throw error
|
|
47
|
+
})
|
|
48
|
+
if (token !== undefined) {
|
|
49
|
+
if (!/^[A-Za-z0-9._-]{20,512}$/.test(token)) throw new Error('Invalid Claude token binding')
|
|
50
|
+
environment.CLAUDE_CODE_OAUTH_TOKEN = token
|
|
51
|
+
}
|
|
52
|
+
return environment
|
|
53
|
+
}
|
package/src/codex-session.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline'
|
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { terminateJob, validateCodexProvider, type CodexProviderBinding } from './executor.js'
|
|
8
|
+
import { codexRejection, codexResetAt, startupLine } from './setup-fallback.js'
|
|
8
9
|
|
|
9
10
|
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;additionalWorkspaces?:string[];model?:string;effort?:string;prompt:string;codexSandbox?:'external';codexProvider?:CodexProviderBinding}
|
|
10
11
|
type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
|
|
@@ -17,7 +18,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
17
18
|
const provider = options.codexProvider ? validateCodexProvider(options.codexProvider) : undefined
|
|
18
19
|
const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
|
|
19
20
|
const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
|
|
20
|
-
let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false
|
|
21
|
+
let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=false,workBegan=false,resetAt:string|undefined
|
|
21
22
|
let resolveDone!:(code:number)=>void
|
|
22
23
|
const done=new Promise<number>(resolve=>{resolveDone=resolve})
|
|
23
24
|
const pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>()
|
|
@@ -38,7 +39,10 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
38
39
|
finish(0)
|
|
39
40
|
}
|
|
40
41
|
child.stderr?.pipe(process.stderr)
|
|
41
|
-
child.on('error',
|
|
42
|
+
child.on('error',error=>{
|
|
43
|
+
if((error as NodeJS.ErrnoException).code==='ENOENT')emit(startupLine({workBegan:false,rejection:{category:'cli-unavailable'}}))
|
|
44
|
+
fail(error)
|
|
45
|
+
})
|
|
42
46
|
child.stdin?.on('error',fail)
|
|
43
47
|
child.once('close',()=>{
|
|
44
48
|
for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Codex app-server closed'))}
|
|
@@ -57,16 +61,23 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
57
61
|
// Never turn an unexpected approval/elicitation request into permission.
|
|
58
62
|
send({id:message.id,error:{code:-32601,message:`Unsupported unattended request: ${message.method}`}});fail(new Error(`Codex requires attention: ${message.method}`));return
|
|
59
63
|
}
|
|
64
|
+
if(message.method==='account/rateLimits/updated')resetAt=codexResetAt(message.params?.rateLimits) ?? resetAt
|
|
60
65
|
if(message.params?.threadId!==threadId)return
|
|
61
66
|
if(message.method==='turn/started'){activeTurn=message.params.turn.id;sawTurn=true}
|
|
67
|
+
// Any item beyond the echoed input means the provider accepted the turn.
|
|
68
|
+
if(message.method==='item/started' && message.params.item?.type!=='userMessage' && !workBegan){workBegan=true;emit(startupLine({workBegan}))}
|
|
62
69
|
if(message.method==='thread/goal/updated')settled(message.params.goal)
|
|
63
70
|
if(message.method==='thread/goal/cleared')settled(null)
|
|
64
71
|
if(message.method==='turn/completed'){
|
|
65
72
|
if(activeTurn===message.params.turn.id)activeTurn=undefined
|
|
66
73
|
if(message.params.turn.status!=='completed'){
|
|
67
74
|
if(message.params.turn.status==='interrupted')finish(130)
|
|
68
|
-
else
|
|
75
|
+
else {
|
|
76
|
+
const category=workBegan ? undefined : codexRejection(message.params.turn.error?.codexErrorInfo)
|
|
77
|
+
if(category)emit(startupLine({workBegan,rejection:{category,...(category==='quota' && resetAt ? {resetAt} : {})}}))
|
|
78
|
+
fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
|
|
69
79
|
? message.params.turn.error.message : 'Native turn failed without error details')))
|
|
80
|
+
}
|
|
70
81
|
return
|
|
71
82
|
}
|
|
72
83
|
// Completion of a turn is not completion of a native goal. The native
|