@jc_stack/ez-agents 0.1.0-beta.43 → 0.1.0-beta.45
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/AGENTS.md +2 -2
- package/CHANGELOG.md +26 -0
- package/bin/ez +1 -1
- package/docs/local-qa.md +1 -1
- package/docs/plugin-connection.md +3 -2
- package/docs/plugin-guide.md +10 -2
- package/docs/plugins.md +19 -0
- package/docs/responsive-channels.md +12 -5
- package/docs/scheduling.md +9 -5
- package/docs/upgrades.md +8 -3
- package/package.json +1 -1
- package/scripts/stage-qa.mjs +2 -2
- package/src/codex-session.ts +7 -1
- package/src/host-executor.ts +22 -8
- package/src/index.ts +3 -2
- package/src/plugin-broker.mjs +1 -0
- package/src/plugins/manager.mjs +33 -20
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +1 -1
- package/src/updates/binding.mjs +14 -7
- package/src/updates/runtime.mjs +3 -2
- package/src/updates/supervisor.mjs +11 -7
- package/test/codex-session.test.ts +15 -2
- package/test/host-executor.test.ts +19 -5
- package/test/local-qa.test.mjs +2 -2
- package/test/plugin-broker.test.mjs +12 -1
- package/test/plugin-connection.test.mjs +11 -0
- package/test/plugin-manager.test.mjs +45 -3
- package/test/scheduler-host.test.ts +4 -5
- package/test/scheduler-relay.test.ts +49 -7
- package/test/updates.test.mjs +34 -5
package/AGENTS.md
CHANGED
|
@@ -120,8 +120,8 @@ round merely to seek more criticism, tests or hypothetical edge cases.
|
|
|
120
120
|
- **Rate limits:** Avoid spamming bubbles to respect Telegram’s 1 message/sec per-chat rate limit.
|
|
121
121
|
|
|
122
122
|
## 6. Concurrency & Workspace Invariants
|
|
123
|
-
- **
|
|
124
|
-
-
|
|
123
|
+
- **Responsive owner channels:** One foreground turn and one owner-scheduled turn may run concurrently; each stream stays serial. Separate native sessions isolate history and engine state, while the agent-owned Markdown workspace is shared. The engine coordinates shared file updates and plugins guard canonical record writes. Do not add a second reply agent, scheduler or execution queue.
|
|
124
|
+
- The delivery socket carries every cross-process enqueue/receipt, never control/ files. Host plugin workspace invocations remain excluded until both native turns finish. Scheduled jobs keep separate native CLI sessions and control-side task state. Restricted contact tasks retain their separate authorization boundary. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
|
|
125
125
|
|
|
126
126
|
## 7. Fail-Closed Authority (Channel Access ≠ Execution)
|
|
127
127
|
- Incoming messages outside the approved owner binding must **never** spawn the executor. A first DM or group message records a pending request only. Explicitly approved owner groups grant owner access to all human members in that exact chat; bots and anonymous posts are ignored.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.45
|
|
4
|
+
|
|
5
|
+
- Keep owner chat responsive while a scheduled native turn runs. Foreground
|
|
6
|
+
turns and scheduled turns each stay serial, with separate native sessions.
|
|
7
|
+
Host workspace invocations remain excluded until both streams finish.
|
|
8
|
+
|
|
9
|
+
- Expose the existing immediate scheduling command in bounded managed guidance;
|
|
10
|
+
scope the owner-requested preference to foreground turns and prevent scheduled
|
|
11
|
+
work from scheduling itself again. Upgrade and rollback render the selected
|
|
12
|
+
package's guidance while preserving agent-owned text.
|
|
13
|
+
- Support real external agent workspaces and private control directories without
|
|
14
|
+
deployment convenience symlinks, including plugin grants and updater backups.
|
|
15
|
+
- Treat a concurrently released workspace lock as busy without adding retries,
|
|
16
|
+
and allow bounded host initialization beyond the former ten-second window.
|
|
17
|
+
- Preserve failed native Codex turn diagnostics for channel delivery instead of
|
|
18
|
+
losing the failure when a native turn completes without response text.
|
|
19
|
+
|
|
20
|
+
## 0.1.0-beta.44
|
|
21
|
+
|
|
22
|
+
- Let isolated native agents read declared installed plugin skills through the
|
|
23
|
+
existing authorized broker. Discovery supplies literal skill-read commands,
|
|
24
|
+
so tools such as EzStudy work without copying package instructions into the
|
|
25
|
+
agent workspace or rebuilding native context.
|
|
26
|
+
- Keep skill reads bound to the installed revision and active authorized run;
|
|
27
|
+
reject undeclared paths, traversal, symlinks and oversized files.
|
|
28
|
+
|
|
3
29
|
## 0.1.0-beta.43
|
|
4
30
|
|
|
5
31
|
- Keep model and reasoning settings with each named conversation and preserve
|
package/bin/ez
CHANGED
|
@@ -88,7 +88,7 @@ const manager = async args => {
|
|
|
88
88
|
const main = async () => {
|
|
89
89
|
const args = process.argv.slice(2);
|
|
90
90
|
if (!args.length || args[0] === '--help' || args[0] === '-h') {
|
|
91
|
-
process.stdout.write(JSON.stringify({commands:['tools list [--details]','<registered CLI> ...'],native:await installedNativeHelp(fileURLToPath(new URL('.',import.meta.url)))})+'\n');
|
|
91
|
+
process.stdout.write(JSON.stringify({commands:['tools list [--details]','tools skill <plugin> <declared-path>','<registered CLI> ...'],native:await installedNativeHelp(fileURLToPath(new URL('.',import.meta.url)))})+'\n');
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
const stdin = ['plugins', 'tools', 'updates', 'status'].includes(args[0]) ? '' : await readStdin();
|
package/docs/local-qa.md
CHANGED
|
@@ -16,7 +16,7 @@ Commit, push and open a PR first — then stage only from that PR's exact commit
|
|
|
16
16
|
```sh
|
|
17
17
|
node scripts/stage-qa.mjs --source /absolute/feature-checkout \
|
|
18
18
|
--catalog /absolute/deployment/tools/qa --label beta-12 \
|
|
19
|
-
--version 0.1.0-beta.12.
|
|
19
|
+
--version 0.1.0-beta.12.rc.1 --flow /absolute/feature-QA.md
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
`beta-12` is a local catalog label, distinct from public `0.1.0-beta.12`. The
|
|
@@ -64,8 +64,9 @@ environment. Calls have a 30-second timeout and 256 KiB combined output limit;
|
|
|
64
64
|
connection frames are limited to 1 MiB. Connection closure aborts pending calls
|
|
65
65
|
and removes their exact command containers. There is no automatic retry.
|
|
66
66
|
|
|
67
|
-
Arbitrary calls take the same fail-fast workspace lease as
|
|
68
|
-
|
|
67
|
+
Arbitrary calls take the same fail-fast workspace lease as native jobs. Host
|
|
68
|
+
foreground and scheduled turns share that lease until both finish. Native work
|
|
69
|
+
stays pending while a call runs; calls refuse pending/running
|
|
69
70
|
native work. Ordinary CLI calls inside an admitted native job do not reacquire
|
|
70
71
|
the lease. Isolated scheduled work keeps its existing workspace behavior. A stale
|
|
71
72
|
native `workspace-writer.lock` is recovered at host startup only after its host
|
package/docs/plugin-guide.md
CHANGED
|
@@ -57,7 +57,16 @@ At minimum, provide:
|
|
|
57
57
|
machine-readable commands, stable exit codes, and readback for writes.
|
|
58
58
|
|
|
59
59
|
The manager snapshots the package according to `package.json.files` plus the
|
|
60
|
-
required manifests and Docker inputs.
|
|
60
|
+
required manifests and Docker inputs. `ez tools list --details` includes
|
|
61
|
+
`skillReads`: literal command argument arrays for reading each installed skill.
|
|
62
|
+
Use `ez tools skill <plugin> <declared-path>` to return its text and pinned
|
|
63
|
+
revision. The isolated executor cannot open private host package paths directly;
|
|
64
|
+
the authorized broker reads only manifest-declared regular skill files, without
|
|
65
|
+
copying them into the agent workspace. Skill reads reject symlinks and files over
|
|
66
|
+
64 KiB. The existing generic discovery footer remains accurate; no plugin-specific
|
|
67
|
+
usage snippet or per-turn instruction is needed for this read contract.
|
|
68
|
+
|
|
69
|
+
Keep one canonical checkout; do not keep
|
|
61
70
|
permanent packaged copies or a second registry. See the full
|
|
62
71
|
[plugin contribution requirements](plugin-contributions.md) and the
|
|
63
72
|
[plugin manager contract](plugins.md#deployment-descriptors).
|
|
@@ -199,4 +208,3 @@ hash.
|
|
|
199
208
|
|
|
200
209
|
For contribution, security, and removal details, follow the links above and the
|
|
201
210
|
plugin's own `CONTRIBUTING.md`, `SECURITY.md`, and README.
|
|
202
|
-
|
package/docs/plugins.md
CHANGED
|
@@ -281,6 +281,19 @@ survive command-time Compose regeneration and restarts. Before a routed plugin
|
|
|
281
281
|
update, the host adds the inspected candidate revision to that route; both the
|
|
282
282
|
current and candidate revisions may remain authorized during the transition.
|
|
283
283
|
|
|
284
|
+
An isolated broker mounts `host-executor.json` as a read-only file. After the host
|
|
285
|
+
atomically replaces that configuration, recreate the declared broker service
|
|
286
|
+
through the deployment's bound launcher before activating the candidate:
|
|
287
|
+
`ezenciel-agents-docker up -d --no-deps --no-build --force-recreate plugin-broker`.
|
|
288
|
+
This refreshes the file mount without replacing the native relay or its sessions.
|
|
289
|
+
Host-capable executors read the current host file directly. Verify a native plugin
|
|
290
|
+
call and its provider receipt after the configuration change.
|
|
291
|
+
|
|
292
|
+
Before a data-preserving uninstall, record the host-owned routes and any
|
|
293
|
+
operator-created plugin network's name, labels and IPAM configuration. Restore
|
|
294
|
+
that same network configuration before activation if uninstall removed it;
|
|
295
|
+
private volumes alone do not preserve Docker networks.
|
|
296
|
+
|
|
284
297
|
This is deployment configuration, not a package-descriptor field or an `ez`
|
|
285
298
|
command: agents and plugins cannot request or alter host networks. The host
|
|
286
299
|
installer owns the binding and must ensure the selected service is stopped
|
|
@@ -400,6 +413,12 @@ and `skills`. Descriptions are limited to 200 characters in this view; full
|
|
|
400
413
|
instructions stay in the skill. Installation, replacement and removal are reflected
|
|
401
414
|
on the next read, without hooks, LLM calls or a cached inventory file.
|
|
402
415
|
|
|
416
|
+
The additive `skillReads` field supplies literal argument arrays for
|
|
417
|
+
`ez tools skill <plugin> <declared-path>`. This read returns JSON with the
|
|
418
|
+
installed plugin revision, declared path and skill content. Use it in isolated
|
|
419
|
+
agents, where host package paths are private. It reads only the installed
|
|
420
|
+
manifest's skills; arbitrary files, symlinks and files over 64 KiB are rejected.
|
|
421
|
+
|
|
403
422
|
`ez tools list` retains its alias mapping for existing clients. Native AGENTS.md
|
|
404
423
|
contains only the agent-bound discovery shortcut. New workspaces do not seed
|
|
405
424
|
TOOLS.md, SOUL.md, USER.md, or an `inbox/`/`work/` layout; upgrades preserve
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# Responsive channels
|
|
2
2
|
|
|
3
|
-
Owner input passes unchanged to the selected engine.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Owner input passes unchanged to the selected engine. In a foreground owner turn,
|
|
4
|
+
when work can continue independently, the global managed guidance tells the agent
|
|
5
|
+
to prefer an immediate scheduled turn so the foreground channel remains available.
|
|
6
|
+
A scheduled turn executes its assigned work directly instead of scheduling itself
|
|
7
|
+
again. The agent still makes that choice; ez does not infer task duration or
|
|
8
|
+
auto-schedule work. ez queues foreground input while a foreground run is active;
|
|
9
|
+
it does not create a separate busy-reply agent.
|
|
10
|
+
Owner-scheduled turns run independently of the foreground channel stream, so a
|
|
11
|
+
long review does not block chat. Each stream runs one turn at a time. They share
|
|
12
|
+
the agent-owned workspace, but use separate native sessions; session isolation
|
|
13
|
+
does not isolate Markdown files. The agent coordinates shared file updates and
|
|
14
|
+
plugins enforce their canonical record-write contracts.
|
|
8
15
|
|
|
9
16
|
Background tasks receive literal task text. Their native sessions contain no
|
|
10
17
|
generated role instructions or copied identity files; native workspace instructions
|
package/docs/scheduling.md
CHANGED
|
@@ -49,10 +49,14 @@ No identity files or role instructions are generated. Existing workspace Markdow
|
|
|
49
49
|
provides context, including native `AGENTS.md` discovery and relative links; the
|
|
50
50
|
engine chooses what to read. Per-run native state stays under `control/`.
|
|
51
51
|
|
|
52
|
-
One
|
|
53
|
-
queue behind
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
One foreground turn and one owner-scheduled turn can run at the same time.
|
|
53
|
+
Foreground inputs queue behind foreground work; scheduled turns queue behind
|
|
54
|
+
scheduled work. A long scheduled goal therefore does not block chat. Both streams
|
|
55
|
+
use the existing admission and executor path, with separate native sessions.
|
|
56
|
+
They share the agent-owned Markdown workspace: the agent coordinates shared file
|
|
57
|
+
updates, and plugins enforce canonical record-write guards. The host keeps plugin
|
|
58
|
+
workspace invocations excluded until both native turns finish.
|
|
59
|
+
The agent decides when to send through the message CLI.
|
|
56
60
|
A recurring schedule has at most one pending or active occurrence. Shared
|
|
57
61
|
provider resources still need writer coordination. The former four background
|
|
58
62
|
slots are no longer used for owner schedules.
|
|
@@ -150,7 +154,7 @@ Telegram delivery, and a sleep test does not prove native goal persistence.
|
|
|
150
154
|
|
|
151
155
|
Create a normal recurring schedule with `--every-seconds 900 --when unreviewed-failures --text-file templates/failure-review.md`. The condition advances empty occurrences without launching an executor. It considers only failures belonging to the paired owner. No separate monitor or automatic retry is introduced.
|
|
152
156
|
|
|
153
|
-
`failures [--all] [--limit N]` returns failedAt, reason, exit code, native session, captured error and runtime versions. Capture keeps at most 4 KiB of redacted stderr; historical failures are not backfilled. `run RUN_ID` reads an owned run. `review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT` records the investigation without rewriting execution history. A stale timestamp is rejected; a later failure needs a new review. Restricted reply, external and isolated-task callers cannot review failures. An attention review is handed off, not repeatedly relaunched; another new failure wakes the next review.
|
|
157
|
+
`failures [--all] [--limit N]` returns failedAt, reason, exit code, native session, captured error and runtime versions. Capture keeps at most 4 KiB of redacted stderr, including a failed native Codex turn's error message when supplied. A failure without error details remains explicitly unknown; historical failures are not backfilled. `run RUN_ID` reads an owned run. `review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT` records the investigation without rewriting execution history. A stale timestamp is rejected; a later failure needs a new review. Restricted reply, external and isolated-task callers cannot review failures. An attention review is handed off, not repeatedly relaunched; another new failure wakes the next review.
|
|
154
158
|
|
|
155
159
|
The prompt controls diagnosis, authorized recovery and quiet notification behavior. Inspect prior effects and receipts before retrying anything. A failed review run itself remains visible as a new failure for the next occurrence.
|
|
156
160
|
|
package/docs/upgrades.md
CHANGED
|
@@ -245,10 +245,15 @@ handbook from workspace `AGENTS.md` (and existing `AGENTS.override.md`).
|
|
|
245
245
|
Personal text outside that block is preserved byte-for-byte. Do not reinsert
|
|
246
246
|
coding, KISS, contribution or repair loops into operating minds.
|
|
247
247
|
|
|
248
|
-
The package-owned
|
|
249
|
-
`ez` launcher, `ez --help`,
|
|
250
|
-
|
|
248
|
+
The package-owned guidance is the managed `<!-- ez tools -->` block: the bound
|
|
249
|
+
`ez` launcher, `ez --help`, `ez tools list --details`, and one routing hint for
|
|
250
|
+
foreground owner turns to prefer `ezenciel-agents-schedule create --now` when work
|
|
251
|
+
can continue independently without blocking interaction. The hint tells scheduled
|
|
252
|
+
turns to execute their assigned work directly instead of rescheduling themselves.
|
|
253
|
+
Inventory and full scheduler usage are generated on read. Plugin registry init and update binding refresh that block.
|
|
251
254
|
Malformed markers and symlinks fail visibly rather than overwriting personal work.
|
|
255
|
+
Upgrade and rollback render the block through the exact package root being activated,
|
|
256
|
+
so an already-running package cannot leave stale guidance behind.
|
|
252
257
|
|
|
253
258
|
Codex, Grok and OpenCode discover workspace instructions natively. Claude
|
|
254
259
|
receives the native `--append-system-prompt-file` binding to `AGENTS.md`, alongside
|
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.45",
|
|
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/scripts/stage-qa.mjs
CHANGED
|
@@ -8,10 +8,10 @@ import {parseArgs} from 'node:util';
|
|
|
8
8
|
import {digest, extract, version, newer} from '../src/updates/artifact.mjs';
|
|
9
9
|
|
|
10
10
|
const {values:v}=parseArgs({options:Object.fromEntries(['source','catalog','label','version','flow'].map(k=>[k,{type:'string'}]))});
|
|
11
|
-
if(!['source','catalog','label','version','flow'].every(k=>v[k]))throw Error('Required: --source CHECKOUT --catalog DIRECTORY --label beta-12 --version 0.1.0-beta.12.
|
|
11
|
+
if(!['source','catalog','label','version','flow'].every(k=>v[k]))throw Error('Required: --source CHECKOUT --catalog DIRECTORY --label beta-12 --version 0.1.0-beta.12.rc.1 --flow QA.md');
|
|
12
12
|
if(!/^beta-[1-9]\d*$/.test(v.label))throw Error('Label must be beta-N');
|
|
13
13
|
version(v.version);
|
|
14
|
-
if(!/^\d+\.\d+\.\d+-beta\.\d+\.
|
|
14
|
+
if(!/^\d+\.\d+\.\d+-beta\.\d+\.rc\.[1-9]\d*$/.test(v.version))throw Error('Use a distinct private version: X.Y.Z-beta.N.rc.M');
|
|
15
15
|
const source=await fs.realpath(v.source),catalog=path.resolve(v.catalog);
|
|
16
16
|
const run=(cmd,args,cwd=source)=>execFileSync(cmd,args,{cwd,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
17
17
|
if(run('git',['status','--porcelain']))throw Error('Commit the reviewed source before staging QA');
|
package/src/codex-session.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { executionDefaults } from './model-policy.js'
|
|
2
|
+
import { redactFailure } from './failure.js'
|
|
2
3
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
4
|
import { createInterface } from 'node:readline'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
@@ -62,7 +63,12 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
62
63
|
if(message.method==='thread/goal/cleared')settled(null)
|
|
63
64
|
if(message.method==='turn/completed'){
|
|
64
65
|
if(activeTurn===message.params.turn.id)activeTurn=undefined
|
|
65
|
-
if(message.params.turn.status!=='completed'){
|
|
66
|
+
if(message.params.turn.status!=='completed'){
|
|
67
|
+
if(message.params.turn.status==='interrupted')finish(130)
|
|
68
|
+
else fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
|
|
69
|
+
? message.params.turn.error.message : 'Native turn failed without error details')))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
66
72
|
// Completion of a turn is not completion of a native goal. The native
|
|
67
73
|
// app-server remains running and owns any automatic next turn.
|
|
68
74
|
void request('thread/goal/get',{threadId}).then(result=>settled(result.goal)).catch(fail)
|
package/src/host-executor.ts
CHANGED
|
@@ -51,7 +51,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
51
51
|
let catalogRefresh: Promise<void> | undefined
|
|
52
52
|
const locks: string[] = []
|
|
53
53
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
54
|
-
const activeWorkspaces = new Set<
|
|
54
|
+
const activeWorkspaces = new Map<HostBinding, { lanes: Set<boolean>; release?: () => Promise<void> }>()
|
|
55
55
|
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
56
56
|
const catalog = async (agent: HostBinding) => {
|
|
57
57
|
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
|
|
@@ -158,13 +158,27 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
158
158
|
}
|
|
159
159
|
const sharedWorkspace=sharedWorkspaces.get(agent)
|
|
160
160
|
const base=path.join(directory,id)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
161
|
+
const scheduled = Boolean(run?.scheduled)
|
|
162
|
+
let workspace = activeWorkspaces.get(agent)
|
|
163
|
+
if (workspace?.lanes.has(scheduled)) continue
|
|
164
|
+
if (!workspace) {
|
|
165
|
+
const release = agent.toolsHome
|
|
166
|
+
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
167
|
+
if (agent.toolsHome && !release) continue
|
|
168
|
+
workspace = { lanes: new Set(), release }
|
|
169
|
+
activeWorkspaces.set(agent, workspace)
|
|
170
|
+
}
|
|
171
|
+
workspace.lanes.add(scheduled)
|
|
172
|
+
// Keep plugin workspace invocations excluded until both streams finish.
|
|
173
|
+
const releaseWorkspace = async () => {
|
|
174
|
+
workspace.lanes.delete(scheduled)
|
|
175
|
+
if (!workspace.lanes.size) {
|
|
176
|
+
activeWorkspaces.delete(agent)
|
|
177
|
+
await workspace.release?.()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
166
180
|
try { await rename(base+'.request.json',base+'.running.json') }
|
|
167
|
-
catch (error) {
|
|
181
|
+
catch (error) { await releaseWorkspace(); throw error }
|
|
168
182
|
const task=(async()=>{
|
|
169
183
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
170
184
|
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
@@ -220,7 +234,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
220
234
|
await rm(base+'.process.json',{force:true})
|
|
221
235
|
await rm(base+'.cancel',{force:true})
|
|
222
236
|
active.delete(base)
|
|
223
|
-
|
|
237
|
+
await releaseWorkspace()
|
|
224
238
|
}
|
|
225
239
|
})()
|
|
226
240
|
tasks.add(task); void task.finally(()=>tasks.delete(task))
|
package/src/index.ts
CHANGED
|
@@ -205,8 +205,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
205
205
|
return
|
|
206
206
|
}
|
|
207
207
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
208
|
-
//
|
|
209
|
-
|
|
208
|
+
// A long scheduled turn must not occupy the owner's chat stream.
|
|
209
|
+
// Each stream remains serial, with separate native sessions.
|
|
210
|
+
if (run.scheduled ? background.size > 0 : activeChild !== null) return
|
|
210
211
|
let texts = run.texts
|
|
211
212
|
if (run.external) {
|
|
212
213
|
// Availability failures leave durable queued work for a later check.
|
package/src/plugin-broker.mjs
CHANGED
|
@@ -59,6 +59,7 @@ const validateManagerArgs = async (value, workspace) => {
|
|
|
59
59
|
if (!args.length) throw Error('Missing broker management command');
|
|
60
60
|
const [group, action, name, extra, ...tail] = args;
|
|
61
61
|
if (group === 'tools' && action === 'list' && (args.length === 2 || args.length === 3 && name === '--details')) return args;
|
|
62
|
+
if (group === 'tools' && action === 'skill' && IDENTIFIER.test(name) && args.length === 4) return args;
|
|
62
63
|
if (group === 'tools' && action === 'exposure' && args.length === 2) return args;
|
|
63
64
|
if (group === 'plugins' && action === 'available' && args.length === 2) return args;
|
|
64
65
|
if (group === 'plugins' && action === 'list' && args.length === 2) return args;
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -41,16 +41,24 @@ export async function stewardOwned(file) {
|
|
|
41
41
|
const parent = await fs.stat(path.dirname(file));
|
|
42
42
|
await fs.chown(file, parent.uid, parent.gid);
|
|
43
43
|
}
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Native instructions keep only the registry locator and one crucial routing
|
|
45
|
+
// hint. Inventory and full scheduler usage are generated on read.
|
|
46
|
+
export async function toolDiscoveryBlock(home,hostConfig) {
|
|
47
|
+
home=await fs.realpath(home);
|
|
47
48
|
let launcher=path.join(home,'bin','ez');
|
|
48
|
-
const config=await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
|
|
49
|
+
const config=hostConfig?{hostConfig}:await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
|
|
49
50
|
if(config?.hostConfig) {
|
|
50
51
|
const host=await json(config.hostConfig);
|
|
51
52
|
if(host.isolation==='isolated') launcher='ez';
|
|
52
53
|
}
|
|
53
54
|
const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
|
|
55
|
+
const block=start+'\nUse `'+launcher+'`; run it with `--help` or `tools list --details` to discover installed capabilities, and read the matching skill when needed.\nKeep the channel responsive: during a foreground owner turn, prefer `ezenciel-agents-schedule create --now ...` when work can continue independently. A `[schedule ...]` turn executes its assigned work directly and must not schedule it again.\n'+end;
|
|
56
|
+
if(Buffer.byteLength(block)>600)throw Error('Tool discovery guidance exceeds the 600-byte core budget');
|
|
57
|
+
return {block,start,end};
|
|
58
|
+
}
|
|
59
|
+
export async function bindToolDiscovery(home,workspace) {
|
|
60
|
+
home=await fs.realpath(home);workspace=await fs.realpath(workspace);
|
|
61
|
+
const {block,start,end}=await toolDiscoveryBlock(home);
|
|
54
62
|
for(const name of ['AGENTS.md','AGENTS.override.md']) {
|
|
55
63
|
const file=path.join(workspace,name);
|
|
56
64
|
const stat=await fs.lstat(file).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
|
|
@@ -59,7 +67,6 @@ export async function bindToolDiscovery(home,workspace) {
|
|
|
59
67
|
const prior=stat?await fs.readFile(file,'utf8'):'';
|
|
60
68
|
const from=prior.indexOf(start),to=prior.indexOf(end);
|
|
61
69
|
if((from<0)!==(to<0)||(from>=0&&(to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)))throw Error('Malformed tool discovery block');
|
|
62
|
-
const block=start+'\nInstalled capabilities: `'+launcher+' --help` and `'+launcher+' tools list --details`. Use this bound launcher; read the matching skill when needed.\n'+end;
|
|
63
70
|
const next=from<0?prior+'\n'+block+'\n':prior.slice(0,from)+block+prior.slice(to+end.length);
|
|
64
71
|
if(next===prior)continue;
|
|
65
72
|
const tmp=file+'.'+randomUUID()+'.tmp';
|
|
@@ -226,18 +233,11 @@ async function hostFolderRoots(config, record, home) {
|
|
|
226
233
|
if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host folder bindings');
|
|
227
234
|
const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
|
|
228
235
|
if (candidates.length !== 1) throw Error('Host folder binding does not belong to this registry');
|
|
229
|
-
const agent = candidates[0]
|
|
236
|
+
const agent = candidates[0];
|
|
230
237
|
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
231
238
|
realpathSync(agent.workspace) !== config.workspace ||
|
|
232
239
|
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
233
240
|
throw Error('Invalid host folder binding deployment');
|
|
234
|
-
// The isolated broker receives only the exact workspace, control directory,
|
|
235
|
-
// tools home, and host config mounts. It must not require the host
|
|
236
|
-
// deployment's convenience mind/control symlinks (or a deployment mount).
|
|
237
|
-
if (process.env.EZ_DOCKER_COMPOSE !== 'standalone' &&
|
|
238
|
-
(realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
239
|
-
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
|
|
240
|
-
throw Error('Invalid host folder binding deployment');
|
|
241
241
|
const configured = agent.pluginFolderRoots;
|
|
242
242
|
if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
|
|
243
243
|
throw Error('Invalid host folder roots');
|
|
@@ -280,7 +280,7 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
280
280
|
if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host network bindings');
|
|
281
281
|
const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
|
|
282
282
|
if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
|
|
283
|
-
const agent = candidates[0]
|
|
283
|
+
const agent = candidates[0];
|
|
284
284
|
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
285
285
|
realpathSync(agent.workspace) !== config.workspace ||
|
|
286
286
|
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
@@ -292,11 +292,6 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
292
292
|
if (route === undefined) return [];
|
|
293
293
|
const isolated = process.env.EZ_DOCKER_COMPOSE === 'standalone';
|
|
294
294
|
if (isolated && host.isolation !== 'isolated') throw Error('Isolated broker requires an isolated host binding');
|
|
295
|
-
// Like folder grants, network grants use the exact mounted identities. The
|
|
296
|
-
// broker does not receive the host deployment's convenience symlinks.
|
|
297
|
-
if (!isolated && (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
298
|
-
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
|
|
299
|
-
throw Error('Invalid host network binding deployment');
|
|
300
295
|
keys(route, ['revisions', 'bindings']);
|
|
301
296
|
const trusted = await snapshot(record.source);
|
|
302
297
|
if (record.revision !== trusted.revision || record.source !== trusted.source ||
|
|
@@ -509,6 +504,7 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
|
|
|
509
504
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
510
505
|
if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
|
|
511
506
|
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
|
|
507
|
+
await toolDiscoveryBlock(home,hostConfig);
|
|
512
508
|
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
513
509
|
catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
|
|
514
510
|
const sources=await json(catalogFile);
|
|
@@ -600,17 +596,34 @@ export async function main(args) {
|
|
|
600
596
|
return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
|
|
601
597
|
}
|
|
602
598
|
if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
|
|
603
|
-
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],native:await installedNativeHelp(path.join(home,'bin')),foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
|
|
599
|
+
if(group==='--help'||!group) return emit({commands:['status','updates check|policy|prepare|apply|status','plugins available|catalog-add|list|inspect|install|start|stop|status|logs|uninstall|export','tools list [--details]|skill <plugin> <declared-path>|exposure|connect <alias> <args...>|serve <host-port:container-port> <alias> <args...>','<registered CLI> ...'],native:await installedNativeHelp(path.join(home,'bin')),foreignWorkspace:'Relay-bound registries run only from their owning agent workspace',scope:home});
|
|
604
600
|
if(group==='plugins'&&(!action||args.includes('--help'))) return emit({commands:['available','list','inspect <id>','install <id>','start <id>','stop <id>','status <id>','logs <id>','uninstall <id>','catalog-add <id> --source PATH --revision HASH','export <id> <artifact> --output PATH','folder-bind <id> --service NAME --source PATH --target PATH [--writable]','folder-unbind <id> --service NAME --target PATH','folders <id>','shared-enable <id> <service>','shared-disable <id> <service>','shared-status <id> <service>'],uninstall:'Stops and removes containers/network and unregisters aliases; retains all volumes and secrets. No data deletion flag.',scope:home});
|
|
605
601
|
if(group==='plugins'||group==='tools') {
|
|
606
602
|
args=rest;args=args.filter(a=>a!=='--json');
|
|
607
603
|
if(action==='available'&&group==='plugins') return emit(config.catalog);
|
|
608
604
|
const r=await registry(home);
|
|
605
|
+
if(group==='tools' && action==='skill') {
|
|
606
|
+
if(args.length!==2)throw Error('Use tools skill <plugin> <declared-path>');
|
|
607
|
+
const [name,skill]=args,record=r.plugins[id(name)];
|
|
608
|
+
if(!record || !record.manifest.skills.includes(skill))throw Error('Unknown installed plugin skill');
|
|
609
|
+
if(path.isAbsolute(skill) || skill.split('/').some(part=>!part || part==='.' || part==='..') || /[\0\r\n]/.test(skill))throw Error('Unsafe skill path');
|
|
610
|
+
const source=await fs.realpath(record.source);
|
|
611
|
+
if(source!==record.source || !childOf(source,home))throw Error('Skill source must stay inside installed packages');
|
|
612
|
+
let file=source;
|
|
613
|
+
for(const part of skill.split('/')) {
|
|
614
|
+
file=path.join(file,part);
|
|
615
|
+
if((await fs.lstat(file)).isSymbolicLink())throw Error('Skill symlinks are not supported');
|
|
616
|
+
}
|
|
617
|
+
const stat=await fs.stat(file);
|
|
618
|
+
if(!stat.isFile() || stat.size>64*1024)throw Error('Skill must be a regular file of at most 64 KiB');
|
|
619
|
+
return emit({plugin:name,revision:record.revision,path:skill,content:await fs.readFile(file,'utf8')});
|
|
620
|
+
}
|
|
609
621
|
if(action==='list') {
|
|
610
622
|
if(group==='tools' && args.length===1 && args[0]==='--details')return emit(Object.fromEntries(Object.entries(r.plugins).map(([name,p])=>[name,{
|
|
611
623
|
description:typeof p.manifest.description==='string'?p.manifest.description.replace(/\s+/g,' ').trim().slice(0,200):'',
|
|
612
624
|
commands:Object.keys(p.manifest.commands).map(alias=>`ez ${alias} --help`),
|
|
613
625
|
skills:p.manifest.skills.map(skill=>path.join(p.source,skill)),
|
|
626
|
+
skillReads:p.manifest.skills.map(skill=>['ez','tools','skill',name,skill]),
|
|
614
627
|
}])));
|
|
615
628
|
if(args.length)throw Error('Use tools list [--details] or plugins list');
|
|
616
629
|
return emit(group==='tools'?r.commands:r.plugins);
|
|
@@ -23,7 +23,7 @@ export async function recoverNativeLease(home) {
|
|
|
23
23
|
export async function workspaceLease(home,owner={kind:'plugin'}) {
|
|
24
24
|
const file=path.join(home,'workspace-writer.lock'),temp=file+'.'+randomUUID()+'.tmp';
|
|
25
25
|
await fs.writeFile(temp,JSON.stringify({...owner,pid:process.pid}),{flag:'wx',mode:0o600});
|
|
26
|
-
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;
|
|
26
|
+
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
|
|
27
27
|
finally {await fs.rm(temp);}
|
|
28
28
|
return async()=>{await fs.rm(file);};
|
|
29
29
|
}
|
package/src/runs.ts
CHANGED
|
@@ -235,7 +235,7 @@ export class RunStore {
|
|
|
235
235
|
|
|
236
236
|
async nextQueued(background?: boolean): Promise<RunRecord | undefined> {
|
|
237
237
|
const queued = [...runsFor(this.controlDir).values()].sort((a,b)=>a.createdAt.localeCompare(b.createdAt)).filter((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
|
|
238
|
-
return queued.find(run => !run.taskId) ?? queued[0]
|
|
238
|
+
return queued.find(run => !run.taskId && !run.scheduled) ?? queued.find(run => !run.taskId) ?? queued[0]
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
async deliveryStatus(): Promise<{ failed: number; unknown: number }> {
|
package/src/updates/binding.mjs
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { atomic
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { atomic } from '../plugins/manager.mjs';
|
|
5
5
|
import { read } from './control.mjs';
|
|
6
6
|
|
|
7
|
+
async function packageToolDiscovery(packageRoot,home,workspace) {
|
|
8
|
+
const entry=path.join(packageRoot,'src/plugins/manager.mjs');
|
|
9
|
+
const manager=await import(pathToFileURL(entry).href);
|
|
10
|
+
if(typeof manager.bindToolDiscovery!=='function')throw Error('Selected package has no tool discovery binding');
|
|
11
|
+
await manager.bindToolDiscovery(home,workspace);
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new URL('../../',import.meta.url))) {
|
|
8
15
|
home=await fs.realpath(home);hostConfig=await fs.realpath(hostConfig);
|
|
9
|
-
const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig);
|
|
10
|
-
if(path.basename(hostConfig)!=='host-executor.json'||host.agents.length!==1||
|
|
11
|
-
if(await fs.realpath(
|
|
16
|
+
const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig),agent=host.agents[0];
|
|
17
|
+
if(path.basename(hostConfig)!=='host-executor.json'||host.agents.length!==1||agent.toolsHome!==home||agent.workspace!==config.workspace)throw Error('Updates require this agent registry and its own single-deployment host config');
|
|
18
|
+
if(await fs.realpath(agent.workspace)!==agent.workspace||await fs.realpath(agent.controlDir)!==agent.controlDir)throw Error('Noncanonical deployment state paths');
|
|
12
19
|
packageRoot=await fs.realpath(packageRoot);
|
|
13
20
|
await fs.mkdir(path.join(home,'updates'),{recursive:true,mode:0o700});
|
|
14
21
|
await atomic(path.join(home,'config.json'),{...config,hostConfig,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
|
|
@@ -19,8 +26,8 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
|
|
|
19
26
|
await fs.writeFile(path.join(bin,'ez'),`#!${process.execPath}\nimport fs from 'node:fs';import {pathToFileURL} from 'node:url';const c=JSON.parse(fs.readFileSync(${configFile}));const m=await import(pathToFileURL(c.packageRoot+'/src/plugins/manager.mjs'));m.main(['--home',${JSON.stringify(home)},...process.argv.slice(2)]).catch(e=>{console.error(e.message);process.exitCode=1});\n`,{mode:0o700});
|
|
20
27
|
for(const [name,entry] of Object.entries(pkg.bin)) {
|
|
21
28
|
const dest=path.join(bin,name);await fs.rm(dest,{force:true});
|
|
22
|
-
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR
|
|
29
|
+
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR:${JSON.stringify(agent.controlDir)}}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
|
|
23
30
|
}
|
|
24
|
-
await
|
|
31
|
+
await packageToolDiscovery(packageRoot,home,config.workspace);
|
|
25
32
|
return {ok:true,home,deploymentDir,packageRoot,policy:'Automatic compatible beta-channel updates by default; saved stable-only or manual policies take precedence. Local candidates require an explicit owner request.'};
|
|
26
33
|
}
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -90,7 +90,7 @@ async function backupVolume(run,record,name,directory) {
|
|
|
90
90
|
// Interruptible work is journaled before any running installation is touched.
|
|
91
91
|
// On restart, recovery restores code/config only, never provider journals.
|
|
92
92
|
export async function perform(home,job,hooks) {
|
|
93
|
-
const run=hooks.execute||execute,{config}=await state(home),dir=jobPath(home,job.id),file=path.join(dir,'job.json');
|
|
93
|
+
const run=hooks.execute||execute,{config,agent}=await state(home),dir=jobPath(home,job.id),file=path.join(dir,'job.json');
|
|
94
94
|
const save=()=>atomic(file,job);
|
|
95
95
|
if(job.status==='applying'||job.recoveryRequested)return recover(home,job,hooks);
|
|
96
96
|
if(job.status!=='queued')throw Error('Job is not queued');
|
|
@@ -129,7 +129,8 @@ export async function perform(home,job,hooks) {
|
|
|
129
129
|
job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
|
|
130
130
|
await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
|
|
131
131
|
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
132
|
-
|
|
132
|
+
await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
|
|
133
|
+
await backupStateDirectory(agent.controlDir,path.join(backup,'control'));
|
|
133
134
|
for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
|
|
134
135
|
let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
|
|
135
136
|
await textAtomic(envFile,env);
|
|
@@ -20,6 +20,16 @@ export function providerEnvironment(host, environment=process.env) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
23
|
+
export async function waitForHostHeartbeat(controlDir,child,failure,{attempts=300,intervalMs=100,readHeartbeat=read,wait=sleep}={}) {
|
|
24
|
+
for(let n=0;n<attempts;n++) {
|
|
25
|
+
const error=failure();
|
|
26
|
+
if(error||child.exitCode!==null)throw error||Error('Host transport failed to start');
|
|
27
|
+
const beat=await readHeartbeat(path.join(controlDir,'host-executor/heartbeat.json')).catch(missing);
|
|
28
|
+
if(beat?.pid===child.pid&&Date.now()-beat.at<10000)return;
|
|
29
|
+
await wait(intervalMs);
|
|
30
|
+
}
|
|
31
|
+
throw Error('Host transport heartbeat timeout');
|
|
32
|
+
}
|
|
23
33
|
export async function idle(control) {
|
|
24
34
|
const host=await fs.readdir(path.join(control,'host-executor')).catch(e=>{if(e.code==='ENOENT')return [];throw e;});
|
|
25
35
|
if(host.some(f=>f.endsWith('.running.json')||f.endsWith('.request.json')))return false;
|
|
@@ -67,13 +77,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
67
77
|
child=spawn(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'src/host-executor.ts'),path.join(deployment,'host-executor.json')],
|
|
68
78
|
{env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
|
|
69
79
|
let error;child.once('error',e=>{error=e;});
|
|
70
|
-
|
|
71
|
-
if(error||child.exitCode!==null)throw error||Error('Host transport failed to start');
|
|
72
|
-
const beat=await read(path.join(agent.controlDir,'host-executor/heartbeat.json')).catch(missing);
|
|
73
|
-
if(beat?.pid===child.pid&&Date.now()-beat.at<10000)return;
|
|
74
|
-
await sleep(100);
|
|
75
|
-
}
|
|
76
|
-
throw Error('Host transport heartbeat timeout');
|
|
80
|
+
await waitForHostHeartbeat(agent.controlDir,child,()=>error);
|
|
77
81
|
};
|
|
78
82
|
const pause=path.join(agent.controlDir,'upgrade-pause.json');
|
|
79
83
|
const beat=setInterval(()=>{void atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()}).catch(()=>{});},1000);
|
|
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
4
|
import { runCodexSession } from '../src/codex-session.js'
|
|
5
5
|
|
|
6
|
-
for(const mode of ['external','goal','long-goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal'])test(`native Codex session: ${mode}`,async()=>{
|
|
6
|
+
for(const mode of ['external','goal','long-goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal','failed','failed-redacted','failed-no-error','interrupted'])test(`native Codex session: ${mode}`,async(t)=>{
|
|
7
|
+
const diagnostics:string[]=[]
|
|
8
|
+
t.mock.method(console,'error',(...args:unknown[])=>diagnostics.push(args.join(' ')))
|
|
7
9
|
const prompt=mode==='long-goal'?'/goal Complete the research.\n'+'Full workflow context.\n'.repeat(400)+'FINAL_COMPLETION_CRITERION':'test'
|
|
8
10
|
const requests:string[]=[],output:string[]=[]
|
|
9
11
|
const program=`
|
|
@@ -24,6 +26,10 @@ if(q.method==='turn/start'){
|
|
|
24
26
|
send({method:'turn/completed',params:{threadId:'unrelated',turn:{id:'unrelated',status:'completed'}}});start('one');
|
|
25
27
|
if(${JSON.stringify(mode)}==='disconnect')return process.exit(0);
|
|
26
28
|
if(${JSON.stringify(mode)}==='approval')return send({id:999,method:'item/commandExecution/requestApproval',params:{threadId:'native-test'}});
|
|
29
|
+
if(['failed','failed-redacted','failed-no-error','interrupted'].includes(${JSON.stringify(mode)})){
|
|
30
|
+
const message=${JSON.stringify(mode)}==='failed-redacted'?'Native provider request failed: Bearer synthetic-private-token https://user:password@example.test/response?api_key=synthetic-private-key':'Native provider request failed';
|
|
31
|
+
return event('turn/completed',{turn:{id:'one',status:${JSON.stringify(mode)}==='interrupted'?'interrupted':'failed',error:${JSON.stringify(mode)}==='failed-no-error'?null:{message}}});
|
|
32
|
+
}
|
|
27
33
|
if(${JSON.stringify(mode)}==='missing-goal')event('thread/goal/updated',{goal:{status:'active'}});
|
|
28
34
|
end('one');return;
|
|
29
35
|
}
|
|
@@ -57,9 +63,16 @@ if(q.method==='thread/goal/get'){
|
|
|
57
63
|
assert.equal(threadConfig.model_provider,'openrouter')
|
|
58
64
|
assert.deepEqual(threadConfig['model_providers.openrouter'],{name:'OpenRouter',base_url:'https://openrouter.ai/api/v1',env_key:'OPENROUTER_API_KEY',wire_api:'responses',supports_websockets:false})
|
|
59
65
|
}
|
|
60
|
-
assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
|
|
66
|
+
assert.equal(result,mode==='interrupted'?130:['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
|
|
61
67
|
assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
|
|
62
68
|
assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
|
|
63
69
|
if(mode==='goal')assert.equal(requests.filter(x=>x==='thread/goal/get').length,2,'must wait for the second turn to complete')
|
|
70
|
+
if(mode==='failed' || mode==='failed-redacted')assert.ok(diagnostics.some(line=>line.includes('Native provider request failed')),'terminal native error must reach existing stderr diagnostics')
|
|
71
|
+
if(mode==='failed-redacted'){
|
|
72
|
+
assert.ok(diagnostics.some(line=>line.includes('https://example.test/response')))
|
|
73
|
+
assert.ok(diagnostics.every(line=>!line.includes('synthetic-private') && !line.includes('user:password')))
|
|
74
|
+
}
|
|
75
|
+
if(mode==='failed-no-error')assert.ok(diagnostics.some(line=>line.includes('Native turn failed without error details')))
|
|
76
|
+
if(mode==='interrupted')assert.equal(diagnostics.length,0,'owner interruption is not a provider failure')
|
|
64
77
|
assert.equal(JSON.parse(output[0]).thread_id,'native-test')
|
|
65
78
|
})
|
|
@@ -167,7 +167,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
167
167
|
let server:Promise<void>|undefined
|
|
168
168
|
try {
|
|
169
169
|
const binary=path.join(root,'cli')
|
|
170
|
-
await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID
|
|
170
|
+
await writeFile(binary,`#!${process.execPath}\nif(['r_hold','tg_43'].includes(process.env.EZ_RUN_ID))setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
|
|
171
171
|
await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
|
|
172
172
|
await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
|
|
173
173
|
await writeFile(path.join(root,'opencode'),'#!/bin/sh\nexit 0\n',{mode:0o700})
|
|
@@ -284,18 +284,32 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
284
284
|
assert.match(output, /"stream":"exit","code":0/)
|
|
285
285
|
return output.trim().split('\n').map(line=>JSON.parse(line))
|
|
286
286
|
}
|
|
287
|
-
//
|
|
288
|
-
//
|
|
287
|
+
// Chat completes while this agent's scheduled turn stays active. A second
|
|
288
|
+
// scheduled turn still waits, and the shared lifecycle lease stays held.
|
|
289
289
|
await completed(otherDirectory,'r_other_shared')
|
|
290
|
+
const chatEvents=await completed(directory,'tg_42')
|
|
290
291
|
assert.ok(await readFile(path.join(directory,'r_schedule_queued.request.json')))
|
|
291
|
-
assert.
|
|
292
|
+
assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
|
|
293
|
+
await ownerRun(agents[0].controlDir,'tg_43')
|
|
294
|
+
await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
|
|
295
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
296
|
+
await readFile(path.join(directory,'tg_43.running.json'))
|
|
292
297
|
assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
|
|
293
298
|
await readFile(path.join(directory,'r_hold.running.json'))
|
|
294
299
|
await writeFile(path.join(directory,'r_hold.cancel'),'')
|
|
295
300
|
let heldOutput=''
|
|
296
301
|
for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
|
|
297
302
|
assert.match(heldOutput, /"stream":"exit","code":1/)
|
|
298
|
-
const
|
|
303
|
+
const scheduleEvents=await completed(directory,'r_schedule_queued')
|
|
304
|
+
// When the scheduled lane finishes first, chat still owns the lease.
|
|
305
|
+
assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
|
|
306
|
+
await writeFile(path.join(directory,'tg_43.cancel'),'')
|
|
307
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.running.json'))}catch{break}await new Promise(r=>setTimeout(r,20))}
|
|
308
|
+
// The running marker is removed before the lifecycle lease is released.
|
|
309
|
+
let unlock
|
|
310
|
+
for(let n=0;n<100;n++){unlock=await workspaceLease(agents[0].toolsHome);if(unlock)break;await new Promise(r=>setTimeout(r,20))}
|
|
311
|
+
assert.ok(unlock)
|
|
312
|
+
await unlock()
|
|
299
313
|
const scheduled=JSON.parse(scheduleEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
300
314
|
const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
301
315
|
assert.equal(scheduled.cwd,await realpath(agents[0].workspace))
|
package/test/local-qa.test.mjs
CHANGED
|
@@ -20,7 +20,7 @@ test('local QA staging preserves source and rejects label replacement and dirty
|
|
|
20
20
|
git(['init']);git(['add','package.json','feature.txt','run.sh']);
|
|
21
21
|
git(['-c','user.name=QA','-c','user.email=qa@example.invalid','-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false','commit','-m','fixture']);
|
|
22
22
|
const script=fileURLToPath(new URL('../scripts/stage-qa.mjs',import.meta.url));
|
|
23
|
-
const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.
|
|
23
|
+
const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.rc.1','--flow',flow];
|
|
24
24
|
const stage=()=>{const prior=process.umask(0o027);try{return execFileSync(process.execPath,args,{stdio:'pipe'}).toString();}finally{process.umask(prior);}};
|
|
25
25
|
try {
|
|
26
26
|
const result=JSON.parse(stage()),data=await fs.readFile(path.join(result.directory,result.file));
|
|
@@ -28,7 +28,7 @@ test('local QA staging preserves source and rejects label replacement and dirty
|
|
|
28
28
|
assert.equal(git(['status','--porcelain']),'');
|
|
29
29
|
await extract(data,path.join(root,'unpacked'));
|
|
30
30
|
const built=JSON.parse(await fs.readFile(path.join(root,'unpacked/package.json'),'utf8'));
|
|
31
|
-
assert.equal(built.version,'0.1.0-beta.12.
|
|
31
|
+
assert.equal(built.version,'0.1.0-beta.12.rc.1');assert.equal(built.ezQa.commit,result.commit);
|
|
32
32
|
assert.equal(await fs.readFile(path.join(root,'unpacked/feature.txt'),'utf8'),'feature source');
|
|
33
33
|
assert.equal((await fs.stat(path.join(root,'unpacked'))).mode&0o777,0o755);
|
|
34
34
|
assert.equal((await fs.stat(path.join(root,'unpacked/feature.txt'))).mode&0o777,0o644);
|
|
@@ -42,11 +42,12 @@ async function fixture(t) {
|
|
|
42
42
|
revision,
|
|
43
43
|
source,
|
|
44
44
|
project: `ezp-${hash(realHome).slice(0, 16)}-sample`,
|
|
45
|
-
manifest: { schemaVersion: 1, id: 'sample', version: '1.0.0', commands: { sample: { executable: 'client.mjs', args: [] } }, skills: [] },
|
|
45
|
+
manifest: { schemaVersion: 1, id: 'sample', version: '1.0.0', commands: { sample: { executable: 'client.mjs', args: [] } }, skills: ['SKILL.md'] },
|
|
46
46
|
deployment: { schemaVersion: 1, services: { sample: { buildTarget: 'runtime', volumes: {}, workspace: false, healthcheck: ['node', '--version'] } }, commands: { sample: { service: 'sample', argv: ['node', '/app/client.mjs'], suffix: [] } }, exports: {} },
|
|
47
47
|
compose: path.join(home, 'packages', 'sample', 'compose.json'),
|
|
48
48
|
};
|
|
49
49
|
await fs.writeFile(path.join(home, 'registry.json'), JSON.stringify({ schemaVersion: 1, owner: realHome, plugins: { sample: record }, commands: { sample: 'sample' } }) + '\n', { mode: 0o600 });
|
|
50
|
+
await fs.writeFile(path.join(source, 'SKILL.md'), '# Synthetic authoring format\n');
|
|
50
51
|
const fake = path.join(root, 'fake');
|
|
51
52
|
await fs.mkdir(fake, { mode: 0o700 });
|
|
52
53
|
await fs.writeFile(path.join(fake, 'docker'), `#!${process.execPath}
|
|
@@ -103,6 +104,13 @@ test('isolated broker authenticates an owned run, pins plugin revision, and writ
|
|
|
103
104
|
const detailed = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'list', '--details'] });
|
|
104
105
|
assert.equal(detailed.ok, true);
|
|
105
106
|
assert.deepEqual(JSON.parse(detailed.stdout).sample.commands, ['ez sample --help']);
|
|
107
|
+
assert.deepEqual(JSON.parse(detailed.stdout).sample.skillReads, [['ez', 'tools', 'skill', 'sample', 'SKILL.md']]);
|
|
108
|
+
const skill = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', 'SKILL.md'] });
|
|
109
|
+
assert.equal(skill.code, 0);
|
|
110
|
+
assert.deepEqual(JSON.parse(skill.stdout), { plugin: 'sample', revision, path: 'SKILL.md', content: '# Synthetic authoring format\n' });
|
|
111
|
+
const privateFile = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', '../../config.json'] });
|
|
112
|
+
assert.notEqual(privateFile.code, 0);
|
|
113
|
+
assert.equal(privateFile.stdout, '');
|
|
106
114
|
const resolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
107
115
|
assert.equal(resolved.ok, true);
|
|
108
116
|
assert.equal(resolved.revision, revision);
|
|
@@ -162,6 +170,9 @@ test('broker rejects resolve and invoke for a revoked run', async t => {
|
|
|
162
170
|
try {
|
|
163
171
|
await waitForSocket(f.socket);
|
|
164
172
|
await new RunStore(f.controlDir).patch(f.runId, { status: 'cancelled' });
|
|
173
|
+
const skill = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', 'SKILL.md'] });
|
|
174
|
+
assert.equal(skill.ok, false);
|
|
175
|
+
assert.equal(skill.stdout, undefined);
|
|
165
176
|
const resolved = await request(f.socket, { version: 1, id: randomUUID(), operation: 'resolve', runId: f.runId, alias: 'sample' });
|
|
166
177
|
assert.equal(resolved.ok, false);
|
|
167
178
|
const invoked = await request(f.socket, { version: 1, id: randomUUID(), operation: 'invoke', runId: f.runId, alias: 'sample', revision, capability: 'a'.repeat(64), args: [] });
|
|
@@ -66,6 +66,17 @@ test('shared workspace lease excludes concurrent writers and refuses queued nati
|
|
|
66
66
|
await fs.mkdir(path.join(dir,'host-executor'));await fs.writeFile(path.join(dir,'host.json'),JSON.stringify({agents:[{toolsHome:dir,workspace:dir,controlDir:dir}]}));await fs.writeFile(path.join(dir,'config.json'),JSON.stringify({hostConfig:path.join(dir,'host.json'),workspace:dir}));await fs.writeFile(path.join(dir,'host-executor','r.request.json'),'{}');await assert.rejects(invokeLease(dir),/pending/);await fs.rm(path.join(dir,'host-executor','r.request.json'));const unlock=await invokeLease(dir);await unlock();
|
|
67
67
|
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
68
68
|
});
|
|
69
|
+
test('workspace lease contention tolerates a concurrent owner release',async()=>{
|
|
70
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-lease-release-'));
|
|
71
|
+
try {
|
|
72
|
+
for(let n=0;n<100;n++) {
|
|
73
|
+
const release=await workspaceLease(dir);assert.ok(release);
|
|
74
|
+
const [,contender]=await Promise.all([release(),workspaceLease(dir)]);
|
|
75
|
+
if(contender)await contender();
|
|
76
|
+
const next=await workspaceLease(dir);assert.ok(next);await next();
|
|
77
|
+
}
|
|
78
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
79
|
+
});
|
|
69
80
|
test('command output bound and timeout remove exact containers without leaking daemon secrets',async()=>{
|
|
70
81
|
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-bound-'));
|
|
71
82
|
try {
|
|
@@ -68,13 +68,15 @@ test('catalog paths resolve relative to the catalog and pin each new agent indep
|
|
|
68
68
|
assert.notEqual(first.sample.revision,next.catalog.sample.revision);
|
|
69
69
|
assert.deepEqual(JSON.parse((await f.call('plugins','available')).stdout),first);
|
|
70
70
|
});
|
|
71
|
-
test('native discovery binding preserves notes and
|
|
71
|
+
test('native discovery binding preserves notes and adds bounded nonblocking routing guidance',async t=>{
|
|
72
72
|
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),notes=path.join(f.workspace,'TOOLS.md');
|
|
73
73
|
await fs.writeFile(instructions,'Owner mandate\n');
|
|
74
74
|
await init(f.home,f.workspace);
|
|
75
75
|
await assert.rejects(fs.access(notes),{code:'ENOENT'});
|
|
76
76
|
const first=await fs.readFile(instructions,'utf8');assert(first.startsWith('Owner mandate\n'));assert(first.includes(f.home+'/bin/ez'));
|
|
77
|
-
assert.match(first,/tools list --details/);assert.match(first,/ --
|
|
77
|
+
assert.match(first,/tools list --details/);assert.match(first,/`--help`/);assert.match(first,/during a foreground owner turn, prefer `ezenciel-agents-schedule create --now \.\.\.`/);assert.match(first,/A `\[schedule \.\.\.\]` turn executes its assigned work directly and must not schedule it again/);assert.doesNotMatch(first,/ezenciel-agents-message|KISS/);
|
|
78
|
+
const managed=first.slice(first.indexOf('<!-- ez tools: begin -->'),first.indexOf('<!-- ez tools: end -->')+'<!-- ez tools: end -->'.length);
|
|
79
|
+
assert.ok(Buffer.byteLength(managed)<=600);
|
|
78
80
|
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(instructions,'utf8'),first);
|
|
79
81
|
await fs.writeFile(notes,'Legacy policy');
|
|
80
82
|
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(notes,'utf8'),'Legacy policy');
|
|
@@ -91,7 +93,20 @@ test('isolated discovery uses the relay-bound ez client without exposing toolsHo
|
|
|
91
93
|
const f=await fixture(t);await fs.mkdir(f.home);const workspace=await fs.realpath(f.workspace),controlDir=await fs.realpath(f.control),toolsHome=await fs.realpath(f.home),host={cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir,binDir:path.join(f.root,'bin'),toolsHome}]};
|
|
92
94
|
await fs.mkdir(path.join(f.root,'bin'));await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
93
95
|
await init(f.home,f.workspace,undefined,f.hostConfig);
|
|
94
|
-
const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/`
|
|
96
|
+
const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/Use `ez`/);assert.match(instructions,/`tools list --details`/);assert.doesNotMatch(instructions,new RegExp(f.home.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')));
|
|
97
|
+
});
|
|
98
|
+
test('discovery rejects an over-budget launcher before rewriting instructions',async t=>{
|
|
99
|
+
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'h'.repeat(200),'n'.repeat(200));
|
|
100
|
+
await fs.mkdir(longHome,{recursive:true});await fs.writeFile(instructions,'Owner mandate\n');
|
|
101
|
+
await assert.rejects(bindToolDiscovery(longHome,f.workspace),/600-byte core budget/);
|
|
102
|
+
assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
|
|
103
|
+
});
|
|
104
|
+
test('standalone init rejects over-budget guidance before creating activation state',async t=>{
|
|
105
|
+
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'i'.repeat(200),'j'.repeat(200)),catalog=path.join(f.root,'empty-catalog.json');
|
|
106
|
+
await fs.writeFile(catalog,'{}');await fs.writeFile(instructions,'Owner mandate\n');
|
|
107
|
+
await assert.rejects(initManager(longHome,f.workspace,catalog,undefined,true),/600-byte core budget/);
|
|
108
|
+
assert.deepEqual(await fs.readdir(longHome),[]);
|
|
109
|
+
assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
|
|
95
110
|
});
|
|
96
111
|
test('installed snippets follow install, upgrade and uninstall without files or Docker reads',async t=>{
|
|
97
112
|
const f=await fixture(t);await init(f.home,f.workspace);
|
|
@@ -104,12 +119,26 @@ test('installed snippets follow install, upgrade and uninstall without files or
|
|
|
104
119
|
const before=await fs.readFile(f.log,'utf8'),index=await details();
|
|
105
120
|
assert.equal(index.sample.description,description);assert.deepEqual(index.sample.commands,['ez sample --help']);
|
|
106
121
|
assert.equal(await fs.readFile(index.sample.skills[0],'utf8'),'Synthetic');
|
|
122
|
+
assert.deepEqual(index.sample.skillReads,[['ez','tools','skill','sample','SKILL.md']]);
|
|
123
|
+
assert.deepEqual(JSON.parse((await f.call('tools','skill','sample','SKILL.md')).stdout),{plugin:'sample',revision:p.revision,path:'SKILL.md',content:'Synthetic'});
|
|
107
124
|
assert.equal(await fs.readFile(f.log,'utf8'),before);
|
|
108
125
|
assert.deepEqual(JSON.parse((await f.call('tools','list')).stdout),{sample:'sample'});
|
|
109
126
|
await f.call('plugins','uninstall','sample');assert.deepEqual(await details(),{});
|
|
110
127
|
}
|
|
111
128
|
await assert.rejects(fs.access(path.join(f.workspace,'TOOLS.md')),{code:'ENOENT'});
|
|
112
129
|
});
|
|
130
|
+
test('skill reads reject undeclared paths, traversal, symlinks and oversized files',async t=>{
|
|
131
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
132
|
+
for(const requested of ['../config.json','/etc/passwd','client.mjs'])await assert.rejects(f.call('tools','skill','sample',requested),/Unknown installed plugin skill/);
|
|
133
|
+
await assert.rejects(f.call('tools','skill','foreign','SKILL.md'),/Unknown installed plugin skill/);
|
|
134
|
+
const r=JSON.parse(await fs.readFile(path.join(f.home,'registry.json'),'utf8')),file=path.join(r.plugins.sample.source,'SKILL.md');
|
|
135
|
+
await fs.rm(file);await fs.symlink(path.join(f.home,'config.json'),file);
|
|
136
|
+
await assert.rejects(f.call('tools','skill','sample','SKILL.md'),/symlink/);
|
|
137
|
+
await fs.rm(file);await fs.writeFile(file,'x'.repeat(64*1024+1));
|
|
138
|
+
await assert.rejects(f.call('tools','skill','sample','SKILL.md'),/64 KiB/);
|
|
139
|
+
r.plugins.sample.manifest.skills=['../config.json'];await fs.writeFile(path.join(f.home,'registry.json'),JSON.stringify(r));
|
|
140
|
+
await assert.rejects(f.call('tools','skill','sample','../config.json'),/Unsafe skill path/);
|
|
141
|
+
});
|
|
113
142
|
test('application plugin context is namespaced, authorized, and absent from ordinary commands',async t=>{
|
|
114
143
|
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
115
144
|
const home=await fs.realpath(f.home);
|
|
@@ -276,6 +305,19 @@ test('isolated broker host binding validates exact mounts without deployment sym
|
|
|
276
305
|
if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
|
|
277
306
|
}
|
|
278
307
|
});
|
|
308
|
+
test('host-capable binding validates an external real workspace without deployment symlinks',async t=>{
|
|
309
|
+
const f=await fixture(t),p=await snapshot(f.source),workspace=path.join(f.root,'external-workspace');
|
|
310
|
+
await fs.rm(f.workspace,{recursive:true});await fs.mkdir(workspace);await fs.mkdir(f.home);
|
|
311
|
+
const binding={service:'sample',network:'sample_default'};
|
|
312
|
+
await fs.writeFile(f.hostConfig,JSON.stringify({cli:'synthetic',agents:[{name:'sample',workspace,controlDir:f.control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[binding]}}}]}));
|
|
313
|
+
const realWorkspace=await fs.realpath(workspace),config={workspace:realWorkspace,hostConfig:await fs.realpath(f.hostConfig),folders:{sample:[{service:'sample',source:realWorkspace,target:'/data/files'}]}};
|
|
314
|
+
const record={source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment};
|
|
315
|
+
const c=await compose(config,record,{},f.home);
|
|
316
|
+
assert.equal(c.services.sample.volumes.find(volume=>volume.target===realWorkspace).source,realWorkspace);
|
|
317
|
+
assert.equal(c.services.sample.volumes.find(volume=>volume.target==='/data/files').source,realWorkspace);
|
|
318
|
+
const network=Object.values(c.networks).find(candidate=>candidate.name==='sample_default');
|
|
319
|
+
assert.deepEqual(network,{external:true,name:'sample_default'});
|
|
320
|
+
});
|
|
279
321
|
test('isolated broker allows unbound plugins when another plugin has host binding',async t=>{
|
|
280
322
|
const f=await fixture(t),p=await snapshot(f.source),isolated=path.join(f.root,'isolated-other'),workspace=path.join(isolated,'workspace'),control=path.join(isolated,'control'),hostConfig=path.join(isolated,'host-executor.json'),previous=process.env.EZ_DOCKER_COMPOSE;
|
|
281
323
|
await fs.mkdir(f.home,{recursive:true});await fs.mkdir(workspace,{recursive:true});await fs.mkdir(control,{recursive:true});
|
|
@@ -11,7 +11,7 @@ import { RunStore } from '../src/runs.js'
|
|
|
11
11
|
|
|
12
12
|
const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
|
|
13
13
|
const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
|
|
14
|
-
test('host transport
|
|
14
|
+
test('host transport keeps chat responsive beside a schedule in the bound mind',async()=>{
|
|
15
15
|
const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
|
|
16
16
|
await mkdir(workspace);await mkdir(controlDir)
|
|
17
17
|
await writeFile(join(workspace,'AGENTS.md'),'bound-owner-instructions')
|
|
@@ -32,9 +32,7 @@ test('host transport serializes owner schedules and chat in the bound mind',asyn
|
|
|
32
32
|
await submit(id)
|
|
33
33
|
await until(async()=>Boolean(await exists(id+'.process.json')))
|
|
34
34
|
await submit('tg_1')
|
|
35
|
-
await
|
|
36
|
-
assert.ok(await exists('tg_1.request.json'))
|
|
37
|
-
assert.equal(await exists('tg_1.events'),'')
|
|
35
|
+
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
38
36
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
39
37
|
await until(async()=>(await exists(id+'.events')).includes('"stream":"stdout"'))
|
|
40
38
|
const scheduled=JSON.parse(stdout(await exists(id+'.events')))
|
|
@@ -47,7 +45,8 @@ test('host transport serializes owner schedules and chat in the bound mind',asyn
|
|
|
47
45
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
48
46
|
await ownerRun(controlDir,'tg_2')
|
|
49
47
|
await submit('tg_2')
|
|
50
|
-
|
|
48
|
+
await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
|
|
49
|
+
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
51
50
|
await writeFile(join(dir,id+'.cancel'),'')
|
|
52
51
|
await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
53
52
|
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
@@ -44,7 +44,50 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
|
|
|
44
44
|
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test('
|
|
47
|
+
test('Slack application replies beside a schedule and keeps both streams serial',async()=>{
|
|
48
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-slack-scheduled-')),runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
|
|
49
|
+
const children=new Map<string,ReturnType<typeof spawn>>()
|
|
50
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
51
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
|
|
52
|
+
children.set(options.runId,child);await once(child,'spawn')
|
|
53
|
+
if(!options.runId.startsWith('r_schedule_'))await runs.enqueueMessage(options.runId,'Slack reply')
|
|
54
|
+
return {child,cleanup:async()=>{},stdout:''}
|
|
55
|
+
})
|
|
56
|
+
try{
|
|
57
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
58
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
|
|
59
|
+
const due=Date.now()+10000
|
|
60
|
+
for(const id of ['first','second'])await scheduler.save({id,name:id,text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
|
|
61
|
+
await scheduler.tick(owner,runs,due);await relay.drainSources()
|
|
62
|
+
const scheduled=(await runs.list()).filter(run=>run.scheduled)
|
|
63
|
+
assert.deepEqual(scheduled.map(run=>run.status),['running','queued'])
|
|
64
|
+
const binding=(await relay.applicationChannel.bindings.register('slack','s'.repeat(48),owner))!
|
|
65
|
+
const scope='slack:T_FIXTURE:C_FIXTURE'
|
|
66
|
+
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'event-1',scope,text:'Top twenty'})
|
|
67
|
+
await until(async()=>children.has(first.id));await relay.drainOutbox()
|
|
68
|
+
assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,first.id)).messages[0].text,'Slack reply')
|
|
69
|
+
assert.equal((await runs.get(scheduled[0].id))?.status,'running')
|
|
70
|
+
const second=await relay.applicationChannel.submit(binding.bindingId,{requestId:'event-2',scope,text:'Next question'})
|
|
71
|
+
await relay.drainSources()
|
|
72
|
+
assert.equal((await runs.get(second.id))?.status,'queued')
|
|
73
|
+
assert.equal(children.size,2)
|
|
74
|
+
// Revoking the channel stops only its foreground work; background authority
|
|
75
|
+
// and the original one-occurrence-per-schedule limit remain intact.
|
|
76
|
+
await relay.applicationChannel.bindings.register('slack',null,owner)
|
|
77
|
+
await relay.drainSources()
|
|
78
|
+
await until(async()=> (await runs.get(first.id))?.status==='cancelled')
|
|
79
|
+
await relay.drainSources()
|
|
80
|
+
assert.equal((await runs.get(second.id))?.status,'cancelled')
|
|
81
|
+
assert.equal((await runs.get(scheduled[0].id))?.status,'running')
|
|
82
|
+
assert.equal((await runs.get(scheduled[1].id))?.status,'queued')
|
|
83
|
+
}finally{
|
|
84
|
+
await relay.stop()
|
|
85
|
+
await until(async()=>!(await runs.list()).some(run=>run.status==='running'))
|
|
86
|
+
await rm(dir,{recursive:true,force:true})
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('owner chat replies while a scheduled CLI keeps running; targeted cancellation',async()=>{
|
|
48
91
|
const dir=await mkdtemp(join(tmpdir(),'ez-scheduler-relay-')), children:ReturnType<typeof spawn>[]=[]
|
|
49
92
|
const runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
|
|
50
93
|
const replies:string[]=[], workspaces:string[]=[]
|
|
@@ -67,19 +110,18 @@ test('owner chat queues behind a scheduled CLI in the same workspace; targeted c
|
|
|
67
110
|
const [background]=await runs.list()
|
|
68
111
|
assert.equal(background.status,'running')
|
|
69
112
|
const update:Update={update_id:123,message:{message_id:123,date:0,text:'What is 17 × 19?',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
|
|
70
|
-
await relay.bot.handleUpdate(update);await relay.drainInbox(true)
|
|
71
|
-
|
|
113
|
+
await relay.bot.handleUpdate(update);await relay.drainInbox(true)
|
|
114
|
+
await until(async()=>children.length===2)
|
|
115
|
+
await relay.drainOutbox()
|
|
116
|
+
assert.ok(replies.includes('323'))
|
|
72
117
|
assert.equal((await runs.get(background.id))?.status,'running')
|
|
73
118
|
assert.equal(children[0].exitCode,null)
|
|
74
|
-
assert.deepEqual(workspaces,[dir])
|
|
119
|
+
assert.deepEqual(workspaces,[dir,dir])
|
|
75
120
|
// Pausing future dispatch doesn't kill active work; cancelling this run does.
|
|
76
121
|
await scheduler.enable('slow',false);await relay.drainSources()
|
|
77
122
|
assert.equal(children[0].exitCode,null)
|
|
78
123
|
await scheduler.cancel(background.id);await relay.drainSources()
|
|
79
124
|
await until(async()=> (await runs.get(background.id))?.status==='cancelled')
|
|
80
|
-
await until(async()=>children.length===2)
|
|
81
|
-
await relay.drainOutbox()
|
|
82
|
-
assert.ok(replies.includes('323'))
|
|
83
125
|
assert.deepEqual(workspaces,[dir,dir])
|
|
84
126
|
assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
|
|
85
127
|
}finally{
|
package/test/updates.test.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { perform, environment, packageManager, backupStateDirectory, execute } f
|
|
|
13
13
|
import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
|
|
14
14
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
15
15
|
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
16
|
-
import { providerEnvironment, queueAutomatic } from '../src/updates/supervisor.mjs';
|
|
16
|
+
import { providerEnvironment, queueAutomatic, waitForHostHeartbeat } from '../src/updates/supervisor.mjs';
|
|
17
17
|
const exec=promisify(execFile);
|
|
18
18
|
|
|
19
19
|
test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
|
|
@@ -42,6 +42,14 @@ test('supervisor forwards only provider keys declared by the agent installation'
|
|
|
42
42
|
for(const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
|
|
43
43
|
assert.throws(()=>providerEnvironment({agents:[{codexProviders:[{envKey}]}]},{}),/Reserved Codex provider environment key/);
|
|
44
44
|
});
|
|
45
|
+
test('host startup gate tolerates initialization beyond the former ten-second window',async()=>{
|
|
46
|
+
let reads=0;
|
|
47
|
+
await waitForHostHeartbeat('/control',{pid:42,exitCode:null},()=>undefined,{
|
|
48
|
+
attempts:120,intervalMs:0,wait:async()=>{},
|
|
49
|
+
readHeartbeat:async()=>++reads===111?{pid:42,at:Date.now()}:null,
|
|
50
|
+
});
|
|
51
|
+
assert.equal(reads,111);
|
|
52
|
+
});
|
|
45
53
|
const contract=kind=>({protocol:1,kind,stateSchema:1,mainProtocol:1});
|
|
46
54
|
function tar(entries) {
|
|
47
55
|
const chunks=[];
|
|
@@ -56,7 +64,8 @@ async function fixture(t,kind='main') {
|
|
|
56
64
|
const deployment=path.join(root,'agent'),home=path.join(deployment,'tools'),old=path.join(root,'old'),source=path.join(root,'candidate');
|
|
57
65
|
for(const d of [home,old,source,path.join(home,'bin'),path.join(home,'updates'),path.join(deployment,'mind'),path.join(deployment,'control')])await fs.mkdir(d,{recursive:true});
|
|
58
66
|
const pkg={name:'@ez-test/example',version:'0.1.0',packageManager:'pnpm@10.30.3',type:'module',files:['bin','src','docker','compose.yaml','compose.whatsapp.yaml','.dockerignore','Dockerfile'],bin:{example:'bin/example.mjs'},ezRelease:contract(kind)};
|
|
59
|
-
const
|
|
67
|
+
const guidance=text=>`export async function bindToolDiscovery(_home,workspace){const fs=await import('node:fs/promises');await fs.writeFile(workspace+'/AGENTS.md',${JSON.stringify(text+'\n')});}\n`;
|
|
68
|
+
const files={'package.json':JSON.stringify(pkg),'compose.yaml':'services: {}\n','compose.whatsapp.yaml':'services: {}\n','.dockerignore':'','Dockerfile':'FROM scratch AS runtime','docker/pnpm-lock.yaml':'lockfileVersion: 9.0\n','src/host-executor.ts':'','src/plugins/manager.mjs':guidance('old guidance'),'bin/example.mjs':'#!/usr/bin/env node\nconsole.log("example")','bin/ezenciel-agents.mjs':'console.log("0.1.1")'};
|
|
60
69
|
let record,target='main';
|
|
61
70
|
if(kind==='plugin') {
|
|
62
71
|
target='sample';files['ez-plugin.json']=JSON.stringify({schemaVersion:1,id:'sample',version:pkg.version,commands:{sample:{executable:'bin/example.mjs',args:[]}},skills:[]});
|
|
@@ -76,11 +85,19 @@ async function fixture(t,kind='main') {
|
|
|
76
85
|
await atomic(record.compose,await compose(config,record,{},home));
|
|
77
86
|
}
|
|
78
87
|
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:record?{sample:record}:{},commands:record?{sample:'sample'}:{}});
|
|
79
|
-
await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);
|
|
88
|
+
await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);await fs.writeFile(path.join(source,'src/plugins/manager.mjs'),guidance('candidate guidance'));
|
|
80
89
|
if(kind==='plugin'){const m=await read(path.join(source,'ez-plugin.json'));m.version=pkg.version;await atomic(path.join(source,'ez-plugin.json'),m);}
|
|
81
90
|
const pack=async()=>{const entries=[];async function walk(dir,prefix=''){for(const e of await fs.readdir(dir,{withFileTypes:true})){const rel=prefix+e.name;if(e.isDirectory())await walk(path.join(dir,e.name),rel+'/');else entries.push(['package/'+rel,await fs.readFile(path.join(dir,e.name))]);}}await walk(source);const file=path.join(root,'candidate.tgz');await fs.writeFile(file,tar(entries));return file;};
|
|
82
91
|
return {root,home,old,source,agent,config,record,target,pack};
|
|
83
92
|
}
|
|
93
|
+
async function externalState(f) {
|
|
94
|
+
const workspace=path.join(f.root,'workspace'),controlDir=path.join(f.root,'control');
|
|
95
|
+
await fs.rename(f.agent.workspace,workspace);await fs.rename(f.agent.controlDir,controlDir);
|
|
96
|
+
f.agent.workspace=workspace;f.agent.controlDir=controlDir;f.config.workspace=workspace;
|
|
97
|
+
await atomic(path.join(f.config.deploymentDir,'host-executor.json'),{cli:'grok',agents:[f.agent]});
|
|
98
|
+
await atomic(path.join(f.home,'config.json'),f.config);
|
|
99
|
+
return f;
|
|
100
|
+
}
|
|
84
101
|
test('root preparation leaves private plugin updates readable by the installer', {
|
|
85
102
|
skip:process.platform!=='linux'||process.geteuid?.()!==0,
|
|
86
103
|
},async t=>{
|
|
@@ -267,12 +284,14 @@ test('reviewed Library beta14 command migration is exact and fail-closed',()=>{
|
|
|
267
284
|
const unsafe=structuredClone(next);unsafe.deployment.services.library.cpus=4;assert.equal(reviewedPluginDeploymentMigration('library',old,unsafe),undefined);
|
|
268
285
|
});
|
|
269
286
|
test('main transaction stages before stopping, pins rollback image, preserves state and rebinds root',async t=>{
|
|
270
|
-
const f=await fixture(t),job=await queued(f),r=runtime(f);
|
|
287
|
+
const f=await externalState(await fixture(t)),job=await queued(f),r=runtime(f);
|
|
271
288
|
await fs.writeFile(path.join(f.agent.workspace,'memory.md'),'retain me');
|
|
272
289
|
// Mutable preparation files cannot alter the verified archive executed later.
|
|
273
290
|
await fs.writeFile(path.join(jobPath(f.home,job.id),'package/bin/example.mjs'),'tampered');
|
|
274
291
|
const result=await perform(f.home,job,r);assert.equal(result.status,'completed');
|
|
275
292
|
assert.equal(await fs.readFile(path.join(f.agent.workspace,'memory.md'),'utf8'),'retain me');
|
|
293
|
+
assert.equal(await fs.readFile(path.join(jobPath(f.home,job.id),'backup/workspace/memory.md'),'utf8'),'retain me');
|
|
294
|
+
await assert.rejects(fs.access(path.join(f.config.deploymentDir,'mind')));
|
|
276
295
|
const active=(await read(path.join(f.home,'config.json'))).packageRoot;assert(active.endsWith('/runtime'));
|
|
277
296
|
assert.notEqual(await fs.readFile(path.join(active,'bin/example.mjs'),'utf8'),'tampered');
|
|
278
297
|
assert(r.calls.findIndex(c=>c.includes('build'))<r.calls.findIndex(c=>c[0]==='stopHost'));
|
|
@@ -281,6 +300,16 @@ test('main transaction stages before stopping, pins rollback image, preserves st
|
|
|
281
300
|
const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
|
|
282
301
|
assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
|
|
283
302
|
});
|
|
303
|
+
test('main activation and rollback bind guidance from the selected package root',async t=>{
|
|
304
|
+
const success=await fixture(t);await bindUpdates(success.home,path.join(success.config.deploymentDir,'host-executor.json'),success.old);
|
|
305
|
+
assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
|
|
306
|
+
assert.equal((await perform(success.home,await queued(success),runtime(success))).status,'completed');
|
|
307
|
+
assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'candidate guidance\n');
|
|
308
|
+
|
|
309
|
+
const failed=await fixture(t);await bindUpdates(failed.home,path.join(failed.config.deploymentDir,'host-executor.json'),failed.old);
|
|
310
|
+
assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
|
|
311
|
+
assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
|
|
312
|
+
});
|
|
284
313
|
test('the same archive with different deployment CLI pins gets distinct image bindings',async t=>{
|
|
285
314
|
const tags=[],hashes=[];
|
|
286
315
|
for(const pin of ['0.153.4','0.156.1']) {
|
|
@@ -366,7 +395,7 @@ test('interrupted activation recovers previous code; rollback failure is explici
|
|
|
366
395
|
const retried=await read(path.join(jobPath(f.home,interrupted.id),'job.json'));assert.equal((await perform(f.home,retried,r)).status,'rolled-back');
|
|
367
396
|
});
|
|
368
397
|
test('bound dispatch follows active package root and retains private scope',async t=>{
|
|
369
|
-
const f=await fixture(t);
|
|
398
|
+
const f=await externalState(await fixture(t));
|
|
370
399
|
const prior=await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8');
|
|
371
400
|
const bound=await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
|
|
372
401
|
assert.match(bound.policy,/beta-channel/);
|