@jc_stack/ez-agents 0.1.0-beta.23 → 0.1.0-beta.25
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 +18 -0
- package/docs/architecture/ai-selection.md +1 -1
- package/docs/plugins.md +24 -3
- package/docs/responsive-channels.md +3 -3
- package/docs/scheduling.md +3 -3
- package/docs/setup.md +5 -5
- package/docs/upgrades.md +6 -0
- package/package.json +1 -1
- package/src/ai.ts +11 -1
- package/src/control-state.ts +9 -8
- package/src/host-executor.ts +2 -1
- package/src/index.ts +4 -3
- package/src/model-policy.ts +21 -6
- package/src/plugins/manager.mjs +71 -10
- package/src/reply-context.ts +2 -3
- package/src/reply-mcp.ts +1 -1
- package/src/schedule-cli.ts +4 -3
- package/src/scheduler.ts +5 -4
- package/src/updates/binding.mjs +1 -1
- package/src/updates/runtime.mjs +3 -3
- package/test/ai.test.ts +8 -6
- package/test/client-defaults.test.ts +1 -1
- package/test/model-policy.test.ts +22 -12
- package/test/plugin-manager.test.mjs +42 -10
- package/test/reply.test.ts +9 -3
- package/test/schedule-cli.test.ts +6 -4
- package/test/updates.test.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.25
|
|
4
|
+
|
|
5
|
+
- Enable Codex Luna `max` reasoning for durable work, schedules and deferred
|
|
6
|
+
owner handoffs. New durable work defaults to Luna/max while responsive chat
|
|
7
|
+
remains Sol/medium.
|
|
8
|
+
- Preserve the strict cap for every other model and expose Luna/max from the
|
|
9
|
+
installed model catalog.
|
|
10
|
+
- Persist Luna/max defaults in rollback-readable form; launch-time policy still
|
|
11
|
+
resolves Luna without an explicit effort to `max`.
|
|
12
|
+
|
|
13
|
+
## 0.1.0-beta.24
|
|
14
|
+
|
|
15
|
+
- Preserve host-owned private plugin networks across generated Compose services,
|
|
16
|
+
one-shot commands and compatible updates. Bind routes to reviewed plugin
|
|
17
|
+
revisions and retain plugin-local connectivity.
|
|
18
|
+
- Support existing deployments on their first upgrade. Network authorization
|
|
19
|
+
remains outside the agent-writable registry. Existing beta acceptance limits remain.
|
|
20
|
+
|
|
3
21
|
## 0.1.0-beta.23
|
|
4
22
|
|
|
5
23
|
- Identify failed publisher API reads and missing release tags without exposing
|
|
@@ -39,7 +39,7 @@ opaque wrappers remain explicitly “client default”. No credentials are store
|
|
|
39
39
|
no inference runs, no new dependency, and no cross-CLI session transfer.
|
|
40
40
|
|
|
41
41
|
New Codex CLI and desktop agents seed **Responsive chat** (`gpt-5.6-sol`,
|
|
42
|
-
`medium`). Durable work keeps its independent `gpt-5.6-
|
|
42
|
+
`medium`). Durable work keeps its independent `gpt-5.6-luna` / `max` default;
|
|
43
43
|
workers can explicitly select another model and effort for the job. Native
|
|
44
44
|
choices for other executors remain unchanged. No fallback is selected when a
|
|
45
45
|
model is unavailable.
|
package/docs/plugins.md
CHANGED
|
@@ -198,12 +198,33 @@ Secrets persist privately across reinstall and are never included in registry
|
|
|
198
198
|
responses. Twenty provides a complete v2 backend example.
|
|
199
199
|
|
|
200
200
|
Compose JSON is generated, not accepted from untrusted arbitrary Compose input.
|
|
201
|
-
No ports, host
|
|
202
|
-
Docker socket or raw Compose args can be supplied.
|
|
203
|
-
of the canonical registry path;
|
|
201
|
+
No ports, host-network mode, privileged services, host env inheritance, arbitrary bind mounts,
|
|
202
|
+
Docker socket or raw Compose args can be supplied. Package descriptors cannot select
|
|
203
|
+
Docker networks. Project names include a hash of the canonical registry path;
|
|
204
|
+
networks and volumes inherit that namespace.
|
|
204
205
|
Images use release-specific names. All containers drop capabilities and run as
|
|
205
206
|
UID 1000 by default; v2 can declare another non-root UID:GID. Plugins sharing a profile remain in the same owning deployment.
|
|
206
207
|
|
|
208
|
+
### Host-owned private networks
|
|
209
|
+
|
|
210
|
+
Some private deployments need a reviewed plugin service to reach another private
|
|
211
|
+
Compose project. The installation host may save `pluginNetworkBindings` on that
|
|
212
|
+
agent's entry in its host-owned `host-executor.json`, keyed by plugin id. Each
|
|
213
|
+
route records the explicitly approved reviewed plugin `revisions` and literal
|
|
214
|
+
`{service, network}` entries. The manager re-hashes the installed snapshot
|
|
215
|
+
before accepting a route, then emits it for both persistent services and
|
|
216
|
+
one-shot command containers. Bound services keep their plugin's default network
|
|
217
|
+
as well, so they retain access to plugin-local dependencies. Routes therefore
|
|
218
|
+
survive command-time Compose regeneration and restarts. Before a routed plugin
|
|
219
|
+
update, the host adds the inspected candidate revision to that route; both the
|
|
220
|
+
current and candidate revisions may remain authorized during the transition.
|
|
221
|
+
|
|
222
|
+
This is deployment configuration, not a package-descriptor field or an `ez`
|
|
223
|
+
command: agents and plugins cannot request or alter host networks. The host
|
|
224
|
+
installer owns the binding and must ensure the selected service is stopped
|
|
225
|
+
before changing its network topology. The executor's writable tool registry is
|
|
226
|
+
never a source of network attachment authority.
|
|
227
|
+
|
|
207
228
|
Commands run in one-shot client containers against their own service's volumes;
|
|
208
229
|
stdin, stdout, stderr, literal arguments and exit codes are preserved. SIGINT/
|
|
209
230
|
SIGTERM cancel the Docker call and remove its unique client container. The manager
|
|
@@ -8,12 +8,12 @@ the handoff is saved, instead of waiting for the worker. This is agent guidance,
|
|
|
8
8
|
not a keyword classifier, automatic acknowledgement, or latency guarantee.
|
|
9
9
|
|
|
10
10
|
New Codex agents use Sol / medium for chat. Scheduled work defaults independently
|
|
11
|
-
to
|
|
12
|
-
`ezenciel-agents-schedule create --now --text-file FILE --model MODEL --effort
|
|
11
|
+
to Luna / max; the agent can choose another model and effort for complex work.
|
|
12
|
+
`ezenciel-agents-schedule create --now --text-file FILE --model MODEL --effort max`
|
|
13
13
|
uses the existing scheduler (include `--name` for a useful task label). Busy owner
|
|
14
14
|
reply sessions expose the same independent model/effort choice through `defer`.
|
|
15
15
|
Its retry returns the first saved schedule; changing arguments does not revise
|
|
16
|
-
an accepted job. `xhigh`
|
|
16
|
+
an accepted job. `xhigh` and `max` are available only for Luna; other models remain capped at high.
|
|
17
17
|
|
|
18
18
|
A handoff includes the objective, relevant context and paths, constraints,
|
|
19
19
|
authorized actions, acceptance checks and delivery destination. Background
|
package/docs/scheduling.md
CHANGED
|
@@ -33,11 +33,11 @@ times fire once, at the earlier instant. Search is bounded to eight years.
|
|
|
33
33
|
Public-holiday calendars and arbitrary RRULE syntax are not implemented.
|
|
34
34
|
|
|
35
35
|
New tasks, including work deferred by a busy reply session, default to Codex
|
|
36
|
-
`gpt-5.6-
|
|
36
|
+
`gpt-5.6-luna` with `max` reasoning independently of the creating chat.
|
|
37
37
|
New chats use the separate Sol/medium preset. Busy reply `defer` accepts optional
|
|
38
38
|
`model` and `effort` fields; retries preserve the first saved task choice.
|
|
39
|
-
Use `--cli`, `--model`, and `--effort` to specify another choice. `xhigh`
|
|
40
|
-
available only with Codex `gpt-5.6-luna`; every other model remains capped at
|
|
39
|
+
Use `--cli`, `--model`, and `--effort` to specify another choice. `xhigh` and
|
|
40
|
+
`max` are available only with Codex `gpt-5.6-luna`; every other model remains capped at
|
|
41
41
|
`high`. Non-Codex adapters inherit native effort when unset. Editing preserves the existing AI
|
|
42
42
|
choice unless those flags override it. Stored choices are checked again at
|
|
43
43
|
launch, including schedules saved before a policy change.
|
package/docs/setup.md
CHANGED
|
@@ -56,17 +56,17 @@ New agents using `codex` or `codex-gui` start with `gpt-5.6-sol` and `medium`
|
|
|
56
56
|
reasoning, including when initialized with `ezenciel-agents-setup init`. This
|
|
57
57
|
Ez default takes precedence over discovered host client defaults. Saved agent
|
|
58
58
|
selections permitted by the current policy are preserved; use the AI settings to choose another model or effort.
|
|
59
|
-
Ez accepts `xhigh` only for `gpt-5.6-luna`; every other model rejects explicit
|
|
59
|
+
Ez accepts `xhigh` and `max` only for `gpt-5.6-luna`; every other model rejects explicit
|
|
60
60
|
reasoning above `high` at selection and execution, including old saved or queued choices.
|
|
61
61
|
Unset Codex model/effort resolves to
|
|
62
|
-
|
|
62
|
+
Luna/max at launch. This governs Ez-managed launches; it is not an account-wide
|
|
63
63
|
limit on independently launched native clients or executor-created native subagents.
|
|
64
64
|
Other adapters inherit their native effort when none is selected in Ez. That
|
|
65
65
|
inherited configuration is not capped by Ez; explicit above-high Ez selections
|
|
66
|
-
are rejected unless they are Codex Luna/xhigh. Conversation presets pin Sol/medium; the lower-level
|
|
67
|
-
Codex fallback
|
|
66
|
+
are rejected unless they are Codex Luna/xhigh or Luna/max. Conversation presets pin Sol/medium; the lower-level
|
|
67
|
+
Codex fallback is Luna/max for work without an explicit choice.
|
|
68
68
|
|
|
69
|
-
New scheduled and one-off background tasks default to Codex
|
|
69
|
+
New scheduled and one-off background tasks default to Codex Luna/max independently
|
|
70
70
|
of the creating chat. Use scheduler `--cli`, `--model`, and `--effort` flags for
|
|
71
71
|
explicit overrides. Editing a schedule preserves its settings unless overridden.
|
|
72
72
|
Restricted messaging tasks use Sol/medium while retaining their approved tool
|
package/docs/upgrades.md
CHANGED
|
@@ -185,6 +185,12 @@ written by the new one. Increment it for incompatible writes; this updater will
|
|
|
185
185
|
refuse that migration. `mainProtocol` identifies the supported updater/registry
|
|
186
186
|
contract, currently 1. Plugin package and manifest versions must match.
|
|
187
187
|
|
|
188
|
+
The Luna/max durable default keeps this contract: persisted Codex Luna presets
|
|
189
|
+
omit the effort field so older runtimes can read and fail over to their native
|
|
190
|
+
safe default, while the current launcher resolves an omitted Luna effort to
|
|
191
|
+
`max`. The execution choice, rather than the storage encoding, is the policy
|
|
192
|
+
surface.
|
|
193
|
+
|
|
188
194
|
Verify upgrade from the previous supported artifact, retained identity/state,
|
|
189
195
|
failed-health rollback, and rejection of incompatible candidates. Main runtime
|
|
190
196
|
changes also need supervisor restart and requesting-process-exit tests. Run
|
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.25",
|
|
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.ts
CHANGED
|
@@ -21,7 +21,9 @@ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
|
|
|
21
21
|
const c = v as ExecutionChoice | undefined
|
|
22
22
|
return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
|
|
23
23
|
}
|
|
24
|
-
export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort ||
|
|
24
|
+
export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || (
|
|
25
|
+
['codex', 'codex-gui'].includes(p.cli) && p.model === 'gpt-5.6-luna' ? DEFAULT_EFFORT : 'default effort'
|
|
26
|
+
)}`
|
|
25
27
|
// The seed delegates model selection to the native client. Project its resolved
|
|
26
28
|
// settings for status without pinning future conversations to that snapshot.
|
|
27
29
|
export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
|
|
@@ -39,6 +41,14 @@ export const initialPreset = (cli: string): AiPreset => {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
// Keep persisted state readable by older releases. Luna/max is an execution
|
|
45
|
+
// default; the launcher resolves an omitted Luna effort back to max.
|
|
46
|
+
export const persistedPreset = (preset: AiPreset): AiPreset => {
|
|
47
|
+
if (!['codex', 'codex-gui'].includes(preset.cli) || preset.model !== 'gpt-5.6-luna' || preset.effort !== 'max') return preset
|
|
48
|
+
const { effort: _effort, ...rollbackReadable } = preset
|
|
49
|
+
return rollbackReadable
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
// Conversation defaults are independent of durable work and explicit saved choices.
|
|
43
53
|
export const chatPreset = (cli: string): AiPreset => {
|
|
44
54
|
const preset = initialPreset(cli)
|
package/src/control-state.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assertEffort } from './model-policy.js'
|
|
2
2
|
import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import { isPreset, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
4
|
+
import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
5
5
|
|
|
6
6
|
export type Owner = {
|
|
7
7
|
kind?: 'group'
|
|
@@ -94,6 +94,7 @@ export class ControlStore {
|
|
|
94
94
|
try {
|
|
95
95
|
const parsed: unknown = JSON.parse(await readFile(this.statePath, 'utf8'))
|
|
96
96
|
if (!isState(parsed)) throw new Error('Control state has an unsupported shape')
|
|
97
|
+
if (parsed.ai) parsed.ai.presets = parsed.ai.presets.map(persistedPreset)
|
|
97
98
|
return parsed
|
|
98
99
|
} catch (error: unknown) {
|
|
99
100
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState()
|
|
@@ -261,7 +262,7 @@ export class ControlStore {
|
|
|
261
262
|
async aiState(initial: AiPreset) {
|
|
262
263
|
return this.withLock(async () => {
|
|
263
264
|
const state = await this.readState()
|
|
264
|
-
state.ai ??= { presets: [initial], defaultId: initial.id, selectedId: initial.id }
|
|
265
|
+
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
|
|
265
266
|
await this.writeState(state)
|
|
266
267
|
return state.ai
|
|
267
268
|
})
|
|
@@ -273,13 +274,13 @@ export class ControlStore {
|
|
|
273
274
|
const state = await this.readState()
|
|
274
275
|
const first = initial.cli === 'codex' || initial.cli === 'codex-gui'
|
|
275
276
|
? initial : discovered.find((p) => p.cli === initial.cli) ?? initial
|
|
276
|
-
state.ai ??= { presets: [first], defaultId: first.id, selectedId: first.id }
|
|
277
|
+
state.ai ??= { presets: [persistedPreset(first)], defaultId: first.id, selectedId: first.id }
|
|
277
278
|
const ai = state.ai
|
|
278
279
|
// Refresh discovery entries, but never rewrite an active/default or user-saved choice.
|
|
279
280
|
const preserved = ai.presets.filter((p) => !p.id.startsWith('detected_') ||
|
|
280
|
-
p.id === ai.selectedId || p.id === ai.defaultId)
|
|
281
|
-
ai.presets = [...preserved, ...discovered.filter((p) => !preserved.some((old) => old.id === p.id))]
|
|
282
|
-
if (initial.id === 'chat-default' && !ai.presets.some(p => p.id === initial.id)) ai.presets.push(initial)
|
|
281
|
+
p.id === ai.selectedId || p.id === ai.defaultId).map(persistedPreset)
|
|
282
|
+
ai.presets = [...preserved, ...discovered.map(persistedPreset).filter((p) => !preserved.some((old) => old.id === p.id))]
|
|
283
|
+
if (initial.id === 'chat-default' && !ai.presets.some(p => p.id === initial.id)) ai.presets.push(persistedPreset(initial))
|
|
283
284
|
await this.writeState(state)
|
|
284
285
|
})
|
|
285
286
|
}
|
|
@@ -287,7 +288,7 @@ export class ControlStore {
|
|
|
287
288
|
async captureChoice(initial: AiPreset): Promise<ExecutionChoice> {
|
|
288
289
|
return this.withLock(async () => {
|
|
289
290
|
const state = await this.readState()
|
|
290
|
-
state.ai ??= { presets: [initial], defaultId: initial.id, selectedId: initial.id }
|
|
291
|
+
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
|
|
291
292
|
const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
|
|
292
293
|
state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
|
|
293
294
|
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
|
|
@@ -331,7 +332,7 @@ export class ControlStore {
|
|
|
331
332
|
if (!state.ai) throw new Error('AI settings not initialized')
|
|
332
333
|
if (state.ai.presets.length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
|
|
333
334
|
throw new Error('Keep it small: at most 12 saved AIs.')
|
|
334
|
-
state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), preset]
|
|
335
|
+
state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
|
|
335
336
|
await this.writeState(state)
|
|
336
337
|
})
|
|
337
338
|
}
|
package/src/host-executor.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { taskWorkspace } from './task-workspace.js'
|
|
|
13
13
|
import { packageVersion } from './version.js'
|
|
14
14
|
import { installedPluginVersions } from './software-status.js'
|
|
15
15
|
|
|
16
|
-
export type
|
|
16
|
+
export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
|
|
17
|
+
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; pluginNetworkBindings?: Record<string, PluginNetworkRoute> }
|
|
17
18
|
export type HostInstallation = { cli: string; agents: HostBinding[] }
|
|
18
19
|
|
|
19
20
|
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob) => {
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { transcribeAudio, synthesizeSpeech } from './audio.js'
|
|
|
31
31
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
32
32
|
import { downloadTelegramFile } from './read-request.js'
|
|
33
33
|
import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
|
|
34
|
-
import { chatPreset, presetLabel, statusPreset } from './ai.js'
|
|
34
|
+
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
|
|
35
35
|
import { discoverDefaults } from './client-defaults.js'
|
|
36
36
|
import { initializeWorkspace } from './workspace.js'
|
|
37
37
|
import { softwareStatus } from './software-status.js'
|
|
@@ -65,6 +65,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
65
65
|
let drainTimer: ReturnType<typeof setInterval> | undefined
|
|
66
66
|
const codexHome = join(config.controlDir, 'cli', 'codex')
|
|
67
67
|
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
|
|
68
|
+
const durableWorkerChoice = () => ({ sessionId: randomUUID(), preset: persistedPreset(initialPreset('codex')) })
|
|
68
69
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
69
70
|
const pagerDuty = config.pagerDutyRoutingKey && config.pagerDutyStocksHealthUrl
|
|
70
71
|
? new PagerDutyStocksMonitor({
|
|
@@ -310,7 +311,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
310
311
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
311
312
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
312
313
|
}
|
|
313
|
-
await queueUpdateAttention(config.controlDir,owner,runs,
|
|
314
|
+
await queueUpdateAttention(config.controlDir,owner,runs,durableWorkerChoice())
|
|
314
315
|
}
|
|
315
316
|
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
316
317
|
try {
|
|
@@ -327,7 +328,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
327
328
|
await runs.create({
|
|
328
329
|
taskId: task?.id,
|
|
329
330
|
id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
|
|
330
|
-
texts: [], execution:
|
|
331
|
+
texts: [], execution: durableWorkerChoice(),
|
|
331
332
|
external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
|
|
332
333
|
})
|
|
333
334
|
}
|
package/src/model-policy.ts
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
export const CODEX_CHAT_MODEL = 'gpt-5.6-sol'
|
|
2
2
|
export const CHAT_EFFORT = 'medium'
|
|
3
|
-
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-
|
|
4
|
-
export const DEFAULT_EFFORT = '
|
|
3
|
+
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-luna'
|
|
4
|
+
export const DEFAULT_EFFORT = 'max'
|
|
5
5
|
export const allowedEffort = (effort?: string, model?: string, cli?: string) => effort === undefined ||
|
|
6
6
|
['none', 'minimal', 'low', 'medium', 'high'].includes(effort) ||
|
|
7
|
-
(
|
|
7
|
+
(['xhigh', 'max'].includes(effort) && model === 'gpt-5.6-luna' && ['codex', 'codex-gui'].includes(cli || ''))
|
|
8
8
|
export function assertEffort(effort?: string, model?: string, cli?: string) {
|
|
9
|
-
if (!allowedEffort(effort, model, cli)) throw new Error('Reasoning effort is capped at high, except Codex Luna/xhigh; choose none, minimal, low, medium, high, or
|
|
9
|
+
if (!allowedEffort(effort, model, cli)) throw new Error('Reasoning effort is capped at high, except Codex Luna/xhigh/max; choose none, minimal, low, medium, high, xhigh, or max with gpt-5.6-luna.')
|
|
10
10
|
}
|
|
11
11
|
export function executionDefaults<T extends { model?: string; effort?: string }>(cli: string, options: T): T {
|
|
12
12
|
assertEffort(options.effort, options.model, cli)
|
|
13
|
+
const model = options.model || CODEX_DEFAULT_MODEL
|
|
13
14
|
return { ...options,
|
|
14
|
-
...(['codex', 'codex-gui'].includes(cli) ? { model
|
|
15
|
+
...(['codex', 'codex-gui'].includes(cli) ? { model } : {}),
|
|
15
16
|
...(['codex', 'codex-gui'].includes(cli)
|
|
16
|
-
? { effort: options.effort || DEFAULT_EFFORT } : {}),
|
|
17
|
+
? { effort: options.effort || (model === 'gpt-5.6-luna' ? DEFAULT_EFFORT : 'high') } : {}),
|
|
17
18
|
}
|
|
18
19
|
}
|
|
20
|
+
|
|
21
|
+
export function executionOverrides<T extends { model?: string; effort?: string }>(
|
|
22
|
+
cli: string,
|
|
23
|
+
base: T,
|
|
24
|
+
model?: string,
|
|
25
|
+
effort?: string,
|
|
26
|
+
): T {
|
|
27
|
+
const options = {
|
|
28
|
+
...base,
|
|
29
|
+
...(model !== undefined ? { model, ...(effort === undefined ? { effort: undefined } : {}) } : {}),
|
|
30
|
+
...(effort !== undefined ? { effort } : {}),
|
|
31
|
+
} as T
|
|
32
|
+
return executionDefaults(cli, options)
|
|
33
|
+
}
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -5,9 +5,11 @@ import path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
8
9
|
|
|
9
10
|
const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
|
|
10
11
|
const id = value => { if(typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(value)) throw Error('Invalid identifier'); return value; };
|
|
12
|
+
const dockerNetwork = value => { if(typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(value)) throw Error('Invalid Docker network'); return value; };
|
|
11
13
|
const hash = data => createHash('sha256').update(data).digest('hex');
|
|
12
14
|
const json = async file => JSON.parse(await fs.readFile(file,'utf8'));
|
|
13
15
|
const emit = value => console.log(JSON.stringify(value));
|
|
@@ -142,9 +144,67 @@ export async function checkFolders(config, record) {
|
|
|
142
144
|
throw Error('Folder source must remain an existing real directory');
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
const childOf = (candidate, parent) => candidate === parent || candidate.startsWith(parent + path.sep);
|
|
148
|
+
// Host-owned private network bindings remain separate from portable package descriptors.
|
|
149
|
+
// The executor may write toolsHome, so the binding source must stay in its host config.
|
|
150
|
+
export async function hostNetworkBindings(config, record, home) {
|
|
151
|
+
const configuredHost = config.hostConfig ?? (typeof config.deploymentDir === 'string' && path.isAbsolute(config.deploymentDir) ? path.join(config.deploymentDir, 'host-executor.json') : undefined);
|
|
152
|
+
if (configuredHost === undefined) return [];
|
|
153
|
+
if (!home || typeof configuredHost !== 'string' || !path.isAbsolute(configuredHost)) throw Error('Invalid host network binding');
|
|
154
|
+
home = realpathSync(home);
|
|
155
|
+
const hostConfig = realpathSync(configuredHost);
|
|
156
|
+
if (hostConfig !== configuredHost || path.basename(hostConfig) !== 'host-executor.json' || childOf(hostConfig, home))
|
|
157
|
+
throw Error('Host network bindings require an external host config');
|
|
158
|
+
const host = JSON.parse(readFileSync(hostConfig, 'utf8'));
|
|
159
|
+
if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host network bindings');
|
|
160
|
+
const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
|
|
161
|
+
if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
|
|
162
|
+
const agent = candidates[0], deployment = path.dirname(hostConfig);
|
|
163
|
+
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
164
|
+
realpathSync(agent.workspace) !== config.workspace || realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
165
|
+
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir) ||
|
|
166
|
+
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
167
|
+
throw Error('Invalid host network binding deployment');
|
|
168
|
+
const configured = agent.pluginNetworkBindings;
|
|
169
|
+
if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
|
|
170
|
+
throw Error('Invalid host network bindings');
|
|
171
|
+
const route = configured?.[record.manifest?.id];
|
|
172
|
+
if (route === undefined) return [];
|
|
173
|
+
keys(route, ['revisions', 'bindings']);
|
|
174
|
+
const trusted = await snapshot(record.source);
|
|
175
|
+
if (record.revision !== trusted.revision || record.source !== trusted.source ||
|
|
176
|
+
JSON.stringify(record.manifest) !== JSON.stringify(trusted.manifest) ||
|
|
177
|
+
JSON.stringify(record.deployment) !== JSON.stringify(trusted.deployment))
|
|
178
|
+
throw Error('Registry plugin identity is not the reviewed source');
|
|
179
|
+
if (record.manifest.id !== trusted.manifest.id) throw Error('Registry plugin identity is not the reviewed source');
|
|
180
|
+
if (!Array.isArray(route.revisions) || route.revisions.some(revision => typeof revision !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(revision)) ||
|
|
181
|
+
!route.revisions.includes(trusted.revision)) throw Error('Host network binding is not pinned to the reviewed plugin revision');
|
|
182
|
+
const bindings = route.bindings;
|
|
183
|
+
if (!Array.isArray(bindings)) throw Error('Invalid host network bindings');
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
for (const binding of bindings) {
|
|
186
|
+
keys(binding, ['service', 'network']);
|
|
187
|
+
id(binding.service); dockerNetwork(binding.network);
|
|
188
|
+
if (!record.deployment.services[binding.service]) throw Error('Unknown host network service');
|
|
189
|
+
const key = `${binding.service}\0${binding.network}`;
|
|
190
|
+
if (seen.has(key)) throw Error('Duplicate host network binding');
|
|
191
|
+
seen.add(key);
|
|
192
|
+
}
|
|
193
|
+
return bindings;
|
|
194
|
+
}
|
|
195
|
+
export async function compose(config, record, secrets={}, home) {
|
|
196
|
+
const services={}, volumes={}, networks={}, serviceNetworks=new Map();
|
|
147
197
|
const folders = folderMounts(config, record);
|
|
198
|
+
for (const binding of await hostNetworkBindings(config, record, home)) {
|
|
199
|
+
// A service with an explicit network list loses Compose's implicit default.
|
|
200
|
+
// Keep the plugin's own network so bound services retain sibling access.
|
|
201
|
+
networks.default = {};
|
|
202
|
+
const key = `host-${hash(binding.network).slice(0,16)}`;
|
|
203
|
+
networks[key] = { external: true, name: binding.network };
|
|
204
|
+
const attached = serviceNetworks.get(binding.service) || ['default'];
|
|
205
|
+
if (!attached.includes(key)) attached.push(key);
|
|
206
|
+
serviceNetworks.set(binding.service, attached);
|
|
207
|
+
}
|
|
148
208
|
for(const [name,s] of Object.entries(record.deployment.services)) {
|
|
149
209
|
const mounts=[];
|
|
150
210
|
for(const [volume,target] of Object.entries(s.volumes||{})) { volumes[volume]={};mounts.push({type:'volume',source:volume,target}); }
|
|
@@ -156,13 +216,14 @@ export function compose(config, record, secrets={}) {
|
|
|
156
216
|
...(s.dependsOn?{depends_on:Object.fromEntries(s.dependsOn.map(dep=>[dep,{condition:'service_healthy'}]))}:{}),
|
|
157
217
|
...(s.memoryMiB?{mem_limit:`${s.memoryMiB}m`}:{}),
|
|
158
218
|
...(s.cpus?{cpus:s.cpus}:{}),
|
|
219
|
+
...(serviceNetworks.get(name)?.length?{networks:serviceNetworks.get(name)}:{}),
|
|
159
220
|
...(s.environment?{environment:Object.fromEntries(Object.entries(s.environment).map(([key,value])=>{
|
|
160
221
|
if(typeof value==='string')return [key,value];
|
|
161
222
|
if(!/^[a-f0-9]{64}$/.test(secrets[value.secret]||''))throw Error('Missing or invalid private deployment secret');
|
|
162
223
|
return [key,(value.prefix||'')+secrets[value.secret]+(value.suffix||'')];
|
|
163
224
|
}))}:{}),...(s.command?{command:s.command}:{})};
|
|
164
225
|
}
|
|
165
|
-
return attachShared({name:record.project,services,volumes}, record);
|
|
226
|
+
return attachShared({name:record.project,services,volumes,...(Object.keys(networks).length?{networks}:{})}, record);
|
|
166
227
|
}
|
|
167
228
|
function dockerEnv() {
|
|
168
229
|
return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
|
|
@@ -205,14 +266,14 @@ async function registry(home) {
|
|
|
205
266
|
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
206
267
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
207
268
|
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');
|
|
208
|
-
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);
|
|
269
|
+
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
|
|
209
270
|
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
210
271
|
catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
|
|
211
272
|
const sources=await json(catalogFile);
|
|
212
273
|
const catalog={};
|
|
213
274
|
for(const [name,source] of Object.entries(sources)) {id(name);if(typeof source!=='string'||!source)throw Error('Catalog source must be a nonempty path');const resolved=path.resolve(path.dirname(catalogFile),source);const p=await snapshot(resolved).catch(error=>{throw Error(`Cannot load reviewed plugin ${name} from ${resolved}: ${error.message}. Supply its checkout or an explicit --catalog file.`)});if(p.manifest.id!==name) throw Error('Catalog ID mismatch');catalog[name]={source:p.source,revision:p.revision};}
|
|
214
275
|
await locked(home,async()=>{
|
|
215
|
-
await atomic(path.join(home,'config.json'),{schemaVersion:1,workspace,catalog});
|
|
276
|
+
await atomic(path.join(home,'config.json'),{schemaVersion:1,workspace,catalog,...(hostConfig&&path.basename(hostConfig)==='host-executor.json'?{hostConfig}:{})});
|
|
216
277
|
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:{},commands:{}});
|
|
217
278
|
const bin=path.join(home,'bin');await privateDir(bin);
|
|
218
279
|
await fs.writeFile(path.join(bin,'ez'),`#!${process.execPath}\nimport(${JSON.stringify(new URL('./manager.mjs',import.meta.url).href)}).then(m=>m.main(['--home',${JSON.stringify(home)},...process.argv.slice(2)])).catch(e=>{console.error(e.message);process.exitCode=1});\n`,{mode:0o700,flag:'wx'});
|
|
@@ -262,7 +323,7 @@ export async function install(home,config,name,source,revision) {
|
|
|
262
323
|
let secrets;try{secrets=await json(secretsFile);}catch(error){if(error.code!=='ENOENT')throw error;secrets={};}
|
|
263
324
|
for(const name of p.deployment.secrets||[])if(secrets[name]===undefined)secrets[name]=randomBytes(32).toString('hex');
|
|
264
325
|
if(p.deployment.secrets?.length)await atomic(secretsFile,secrets);
|
|
265
|
-
await atomic(record.compose,compose(config,record,secrets));
|
|
326
|
+
await atomic(record.compose,await compose(config,record,secrets,home));
|
|
266
327
|
// Build/pull is explicit installation mechanics. No services start and no onboarding is executed.
|
|
267
328
|
for(const [service,s] of Object.entries(record.deployment.services)) await checked([...composeArgs(record),s.image?'pull':'build',service]);
|
|
268
329
|
r.plugins[name]=record;for(const alias of Object.keys(p.manifest.commands)) r.commands[alias]=name;
|
|
@@ -327,7 +388,7 @@ export async function main(args) {
|
|
|
327
388
|
settings.folders={...settings.folders,[name]:folders};
|
|
328
389
|
await checkFolders(settings,latest);
|
|
329
390
|
const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
330
|
-
const generated=compose(settings,latest,secrets);
|
|
391
|
+
const generated=await compose(settings,latest,secrets,home);
|
|
331
392
|
await atomic(path.join(home,'config.json'),settings);
|
|
332
393
|
await atomic(latest.compose,generated);
|
|
333
394
|
return emit({ok:true,plugin:name,folders,readOnly:true,started:false});
|
|
@@ -345,7 +406,7 @@ export async function main(args) {
|
|
|
345
406
|
const result = action === 'shared-enable' ? await sharedService(latest, key, 'enable', run) : { state: 'detached' };
|
|
346
407
|
latest.sharedEnabled = [...new Set([...(latest.sharedEnabled || []).filter(k => k !== key), ...(action === 'shared-enable' ? [key] : [])])];
|
|
347
408
|
const secrets = await json(path.join(home, 'packages', name, 'secrets.json')).catch(e => { if (e.code === 'ENOENT') return {}; throw e; });
|
|
348
|
-
await atomic(latest.compose, compose(currentConfig, latest, secrets));
|
|
409
|
+
await atomic(latest.compose, await compose(currentConfig, latest, secrets, home));
|
|
349
410
|
// Persist the binding before recreating clients; start can recover an interrupted recreation.
|
|
350
411
|
await atomic(path.join(home, 'registry.json'), current);
|
|
351
412
|
await checked([...composeArgs(latest), 'up', '-d', '--wait']);
|
|
@@ -371,7 +432,7 @@ export async function main(args) {
|
|
|
371
432
|
const currentConfig=await json(path.join(home,'config.json'));
|
|
372
433
|
await checkFolders(currentConfig,current.plugins[name]);
|
|
373
434
|
const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
374
|
-
await atomic(record.compose,compose(currentConfig,current.plugins[name],secrets));
|
|
435
|
+
await atomic(record.compose,await compose(currentConfig,current.plugins[name],secrets,home));
|
|
375
436
|
}
|
|
376
437
|
await checked([...composeArgs(record),...(action==='start'?['up','-d','--wait']:action==='stop'?['stop']:['down'])]);
|
|
377
438
|
if(action==='uninstall') {delete current.plugins[name];for(const [alias,owner] of Object.entries(current.commands))if(owner===name)delete current.commands[alias];await atomic(path.join(home,'registry.json'),current);}
|
|
@@ -384,7 +445,7 @@ export async function main(args) {
|
|
|
384
445
|
if(!binding)throw Error('Unknown registered CLI');
|
|
385
446
|
await checkFolders(config,record);
|
|
386
447
|
const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
387
|
-
await atomic(record.compose,compose(config,record,secrets));
|
|
448
|
+
await atomic(record.compose,await compose(config,record,secrets,home));
|
|
388
449
|
// Docker exec does not reliably forward cancellation to the in-container process.
|
|
389
450
|
// Run each client as a one-shot Compose container; docker compose run forwards signals.
|
|
390
451
|
const name=`${record.project}-call-${randomUUID()}`;
|
package/src/reply-context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { executionOverrides } from './model-policy.js'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
3
|
import { initialPreset, isPreset } from './ai.js'
|
|
4
4
|
import { readFile, readdir, lstat } from 'node:fs/promises'
|
|
@@ -40,9 +40,8 @@ export async function replyCall(controlDir: string, runId: string, workspace: st
|
|
|
40
40
|
if (name === 'send') return runs.enqueueMessage(runId, args.text, { id: `${runId}_busy_reply`, replyToMessageId: run.messageId })
|
|
41
41
|
if (name === 'defer') {
|
|
42
42
|
if (!run.execution) throw new Error('Missing execution choice')
|
|
43
|
-
const preset =
|
|
43
|
+
const preset = executionOverrides('codex', initialPreset('codex'), args.model as string | undefined, args.effort as string | undefined)
|
|
44
44
|
if (!isPreset(preset)) throw new Error('Invalid worker model or effort')
|
|
45
|
-
assertEffort(preset.effort, preset.model, preset.cli)
|
|
46
45
|
const owner = (await new ControlStore(controlDir, 900000).status()).owner!
|
|
47
46
|
const scheduler = new Scheduler(controlDir), id = `s_reply_${runId}`
|
|
48
47
|
try { return { id: (await scheduler.get(id)).id } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
package/src/reply-mcp.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { replyCall } from './reply-context.js'
|
|
|
3
3
|
const [controlDir, runId, workspace] = process.argv.slice(2)
|
|
4
4
|
const tools = [
|
|
5
5
|
{ name: 'context', description: 'Read this owner request, recent conversation, active and historical runs, and task progress.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
6
|
-
...['send', 'defer'].map(name => ({ name, description: name === 'send' ? 'Send one answer to the paired owner. Repeated calls reuse the same receipt.' : 'Queue the current owner request for a writer session. Include needed context and acceptance checks in text. Optional model and effort select the worker independently; defaults are gpt-5.6-
|
|
6
|
+
...['send', 'defer'].map(name => ({ name, description: name === 'send' ? 'Send one answer to the paired owner. Repeated calls reuse the same receipt.' : 'Queue the current owner request for a writer session. Include needed context and acceptance checks in text. Optional model and effort select the worker independently; defaults are gpt-5.6-luna/max. Repeated calls return the same schedule.', inputSchema: { type: 'object', properties: { text: { type: 'string', maxLength: 8000 }, ...(name === 'defer' ? { model: { type: 'string', maxLength: 160 }, effort: { type: 'string', enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] } } : {}) }, required: ['text'], additionalProperties: false } })),
|
|
7
7
|
]
|
|
8
8
|
for await (const line of createInterface({ input: process.stdin })) {
|
|
9
9
|
let request: any
|
package/src/schedule-cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { loadControlConfig } from './config.js'
|
|
|
6
6
|
import { ControlStore } from './control-state.js'
|
|
7
7
|
import { RunStore } from './runs.js'
|
|
8
8
|
import { initialPreset, isPreset } from './ai.js'
|
|
9
|
+
import { executionOverrides } from './model-policy.js'
|
|
9
10
|
import { Scheduler } from './scheduler.js'
|
|
10
11
|
import { ownsRun } from './identity.js'
|
|
11
12
|
import { nextOccurrence, type Trigger } from './schedule-time.js'
|
|
@@ -22,11 +23,11 @@ async function main() {
|
|
|
22
23
|
review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
|
|
23
24
|
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE)
|
|
24
25
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
25
|
-
[--cli EXECUTOR] [--model MODEL] [--effort none|minimal|low|medium|high|xhigh (Luna only)]
|
|
26
|
+
[--cli EXECUTOR] [--model MODEL] [--effort none|minimal|low|medium|high|xhigh|max (Luna only)]
|
|
26
27
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
27
28
|
Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
|
|
28
29
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
29
|
-
New tasks default to Codex
|
|
30
|
+
New tasks default to Codex Luna/max, independently of the current chat. Explicit settings override these defaults; edit preserves existing settings unless overridden.
|
|
30
31
|
Creates a durable, asynchronous CLI task. Instructions are text, never shell commands.
|
|
31
32
|
Use --now to delegate long work and return to chat. Run completion is not delivery proof.
|
|
32
33
|
Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
|
|
@@ -73,7 +74,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
73
74
|
v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
|
|
74
75
|
const previous = action === 'edit' ? (await scheduler.get(id!)).execution : undefined
|
|
75
76
|
const base = v.cli ? initialPreset(v.cli) : previous?.preset || initialPreset('codex')
|
|
76
|
-
const preset =
|
|
77
|
+
const preset = executionOverrides(base.cli, base, v.model, v.effort)
|
|
77
78
|
if (!isPreset(preset)) throw new Error('Invalid task AI selection')
|
|
78
79
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
79
80
|
text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
|
package/src/scheduler.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { randomUUID, createHash } from 'node:crypto'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import type { Owner } from './control-state.js'
|
|
7
7
|
import { assertId, ownsRun } from './identity.js'
|
|
8
|
-
import { type ExecutionChoice, isExecutionChoice } from './ai.js'
|
|
8
|
+
import { type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
|
|
9
9
|
import { type Trigger, validateTrigger, nextOccurrence } from './schedule-time.js'
|
|
10
10
|
import { RunStore, type RunRecord } from './runs.js'
|
|
11
11
|
|
|
@@ -64,9 +64,10 @@ export class Scheduler {
|
|
|
64
64
|
async save(input: Omit<Schedule,'version'|'revision'>, exclusive = false): Promise<Schedule> {
|
|
65
65
|
await this.ensure(); assertId(input.id)
|
|
66
66
|
if (input.when !== undefined && input.when !== 'unreviewed-failures') throw new Error('Unknown schedule condition')
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
|
|
68
|
+
if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
|
|
69
|
+
assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
|
|
70
|
+
const s: Schedule = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
70
71
|
if (nextOccurrence(s.trigger,Date.now()-1) === null) throw new Error('Schedule has no future occurrence within eight years')
|
|
71
72
|
await atomic(join(this.dir,s.id+'.json'),s,exclusive)
|
|
72
73
|
return s
|
package/src/updates/binding.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
|
|
|
11
11
|
if(await fs.realpath(path.join(deploymentDir,'mind'))!==config.workspace||await fs.realpath(path.join(deploymentDir,'control'))!==host.agents[0].controlDir)throw Error('Noncanonical deployment state paths');
|
|
12
12
|
packageRoot=await fs.realpath(packageRoot);
|
|
13
13
|
await fs.mkdir(path.join(home,'updates'),{recursive:true,mode:0o700});
|
|
14
|
-
await atomic(path.join(home,'config.json'),{...config,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
|
|
14
|
+
await atomic(path.join(home,'config.json'),{...config,hostConfig,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
|
|
15
15
|
const pkg=await read(path.join(packageRoot,'package.json')),bin=path.join(home,'bin');
|
|
16
16
|
// Every invocation resolves the active root. Already-running workers retain
|
|
17
17
|
// their old code; the next worker receives the new package after activation.
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -58,7 +58,7 @@ function envValue(text,key,value) {
|
|
|
58
58
|
}
|
|
59
59
|
async function backupVolume(run,record,name,directory) {
|
|
60
60
|
const services=Object.entries(record.deployment.services),service=services.find(([,s])=>s.volumes?.[name])?.[0];
|
|
61
|
-
const c=compose({workspace:'/unused'},record),image=c.services[service].image;
|
|
61
|
+
const c=await compose({workspace:'/unused'},record),image=c.services[service].image;
|
|
62
62
|
// No writable profile mount, network, Docker socket, provider command or secrets.
|
|
63
63
|
const found=(await run('docker',['volume','ls','--format','{{.Name}}','--filter',`name=^${record.project}_${name}$`])).trim();
|
|
64
64
|
if(!found)return; // Installed but never started: no profile exists yet.
|
|
@@ -112,7 +112,7 @@ export async function perform(home,job,hooks) {
|
|
|
112
112
|
const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
|
|
113
113
|
for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
|
|
114
114
|
await checkFolders(config,candidate);
|
|
115
|
-
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,compose(config,stage,secrets));
|
|
115
|
+
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
|
|
116
116
|
for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
|
|
117
117
|
const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
|
|
118
118
|
job.rollback={record:old,registry:r,compose:await read(old.compose),running};await save();
|
|
@@ -120,7 +120,7 @@ export async function perform(home,job,hooks) {
|
|
|
120
120
|
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
121
121
|
const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
|
|
122
122
|
for(const name of volumes)await backupVolume(run,old,name,backup);
|
|
123
|
-
await atomic(old.compose,compose(config,candidate,secrets));
|
|
123
|
+
await atomic(old.compose,await compose(config,candidate,secrets,home));
|
|
124
124
|
if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
125
125
|
r.plugins[job.target]=candidate;await atomic(path.join(home,'registry.json'),r);
|
|
126
126
|
job.runtimeVerified=running;
|
package/test/ai.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
|
|
|
8
8
|
import { createAiMenu } from '../src/menu.js'
|
|
9
9
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
10
10
|
import { InboxStore } from '../src/inbox.js'
|
|
11
|
+
import { executionDefaults } from '../src/model-policy.js'
|
|
11
12
|
import type { Update } from 'grammy/types'
|
|
12
13
|
|
|
13
14
|
test('AI choices pin model, effort and session; defaults and CLI switches do not reroute old work', async () => {
|
|
@@ -87,7 +88,7 @@ test('model catalog projects native metadata only, excluding hidden entries and
|
|
|
87
88
|
] }))
|
|
88
89
|
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
|
|
89
90
|
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
90
|
-
{ cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh'] },
|
|
91
|
+
{ cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh', 'max'] },
|
|
91
92
|
])
|
|
92
93
|
assert.equal(isPreset({ id: 'x', name: 'x', cli: 'grok', model: '--shell escape' }), false)
|
|
93
94
|
} finally { await rm(home, { recursive: true, force: true }) }
|
|
@@ -165,7 +166,7 @@ test('native executor flags carry the exact model and effort; only structured me
|
|
|
165
166
|
})
|
|
166
167
|
|
|
167
168
|
for (const cli of ['codex', 'codex-gui']) {
|
|
168
|
-
test(`${cli} initializes
|
|
169
|
+
test(`${cli} initializes Luna max ahead of host defaults and preserves saved choices`, async () => {
|
|
169
170
|
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-default-'))
|
|
170
171
|
try {
|
|
171
172
|
const store = new ControlStore(dir, 1000)
|
|
@@ -174,8 +175,9 @@ for (const cli of ['codex', 'codex-gui']) {
|
|
|
174
175
|
model: 'host-model', effort: 'low' }]
|
|
175
176
|
await store.syncClientPresets(initial, discovered)
|
|
176
177
|
const first = await store.captureChoice(initial)
|
|
177
|
-
assert.equal(first.preset.model, 'gpt-5.6-
|
|
178
|
-
assert.equal(first.preset.effort,
|
|
178
|
+
assert.equal(first.preset.model, 'gpt-5.6-luna')
|
|
179
|
+
assert.equal(first.preset.effort, undefined)
|
|
180
|
+
assert.equal(executionDefaults(cli, first.preset).effort, 'max')
|
|
179
181
|
assert.equal(first.preset.cli, cli)
|
|
180
182
|
const saved = { id: 'custom', name: 'Custom', cli, model: 'custom-model', effort: 'medium' }
|
|
181
183
|
await store.savePreset(saved)
|
|
@@ -197,8 +199,8 @@ for (const cli of ['codex', 'codex-gui']) {
|
|
|
197
199
|
const chat = await store.captureChoice(chatPreset(cli))
|
|
198
200
|
assert.equal(chat.preset.model, 'gpt-5.6-sol')
|
|
199
201
|
assert.equal(chat.preset.effort, 'medium')
|
|
200
|
-
assert.equal(initialPreset(cli).model, 'gpt-5.6-
|
|
201
|
-
assert.equal(initialPreset(cli).effort, '
|
|
202
|
+
assert.equal(initialPreset(cli).model, 'gpt-5.6-luna')
|
|
203
|
+
assert.equal(initialPreset(cli).effort, 'max')
|
|
202
204
|
const old = initialPreset(cli)
|
|
203
205
|
await store.savePreset(old)
|
|
204
206
|
await store.defaultPreset(old.id)
|
|
@@ -70,7 +70,7 @@ test('status projects the native client default without pinning the seed', () =>
|
|
|
70
70
|
assert.deepEqual(statusPreset({ ...initial, id: 'detected_empty' }, [discovered]), discovered)
|
|
71
71
|
const explicit = { ...discovered, id: 'saved', model: 'chosen-codex', effort: 'high' }
|
|
72
72
|
assert.equal(statusPreset(explicit, [discovered]), explicit)
|
|
73
|
-
assert.equal(statusPreset(initialPreset('codex-gui'), [{ ...discovered, cli: 'codex-gui' }]).model, 'gpt-5.6-
|
|
73
|
+
assert.equal(statusPreset(initialPreset('codex-gui'), [{ ...discovered, cli: 'codex-gui' }]).model, 'gpt-5.6-luna')
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
test('seed uses the configured executor; repeated refresh preserves current/default and queued snapshots', async () => {
|
|
@@ -3,10 +3,10 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import { executionDefaults } from '../src/model-policy.js'
|
|
6
|
+
import { executionDefaults, executionOverrides } from '../src/model-policy.js'
|
|
7
7
|
import { startExecutorJob } from '../src/executor.js'
|
|
8
8
|
import { ControlStore } from '../src/control-state.js'
|
|
9
|
-
import { initialPreset, readModels, validateSelection } from '../src/ai.js'
|
|
9
|
+
import { initialPreset, persistedPreset, readModels, validateSelection } from '../src/ai.js'
|
|
10
10
|
import { taskArguments } from '../src/task-executor.js'
|
|
11
11
|
import { runCodexSession } from '../src/codex-session.js'
|
|
12
12
|
import { runDesktopTurn } from '../src/desktop-bridge.js'
|
|
@@ -23,24 +23,34 @@ test('all non-Luna model selections and launches reject effort above high before
|
|
|
23
23
|
await assert.rejects(runDesktopTurn({workspace:'/unused',controlDir:'/unused',binDir:'/unused',runId:'unused',prompt:'',effort:'ultra'}), /capped at high/)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
test('Codex Luna accepts xhigh while every other model and CLI remains capped', async () => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
test('Codex Luna accepts xhigh and max while every other model and CLI remains capped', async () => {
|
|
27
|
+
for (const effort of ['xhigh', 'max']) {
|
|
28
|
+
const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort }
|
|
29
|
+
await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh','max'] }], async () => true)
|
|
30
|
+
assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort }), { model:'gpt-5.6-luna', effort })
|
|
31
|
+
assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort }), /capped at high/)
|
|
32
|
+
assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort }), /capped at high/)
|
|
33
|
+
}
|
|
32
34
|
})
|
|
33
35
|
|
|
34
|
-
test('restricted tasks pin
|
|
36
|
+
test('restricted tasks pin Luna max, preserve explicit choices and reject higher effort', () => {
|
|
35
37
|
const args = taskArguments('/unused', ['broker'], 'prompt')
|
|
36
|
-
assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-
|
|
37
|
-
assert.ok(args.includes('model_reasoning_effort="
|
|
38
|
+
assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-luna')
|
|
39
|
+
assert.ok(args.includes('model_reasoning_effort="max"'))
|
|
38
40
|
const custom = taskArguments('/unused', ['broker'], 'prompt', undefined, {model:'custom-model',effort:'low'})
|
|
39
41
|
assert.equal(custom[custom.indexOf('--model')+1], 'custom-model')
|
|
40
42
|
assert.ok(custom.includes('model_reasoning_effort="low"'))
|
|
41
43
|
assert.throws(() => taskArguments('/unused', ['broker'], 'prompt', undefined, {effort:'xhigh'}), /capped at high/)
|
|
42
44
|
assert.deepEqual(executionDefaults('codex', {model:'custom-model',effort:'medium'}), {model:'custom-model',effort:'medium'})
|
|
43
|
-
assert.deepEqual(executionDefaults('codex', {}), {model:'gpt-
|
|
45
|
+
assert.deepEqual(executionDefaults('codex', {model:'gpt-6-astra'}), {model:'gpt-6-astra',effort:'high'})
|
|
46
|
+
assert.deepEqual(executionDefaults('codex', {model:'gpt-5.6-terra'}), {model:'gpt-5.6-terra',effort:'high'})
|
|
47
|
+
assert.deepEqual(executionDefaults('codex', {model:'gpt-5.6-luna'}), {model:'gpt-5.6-luna',effort:'max'})
|
|
48
|
+
assert.deepEqual(executionDefaults('codex', {}), {model:'gpt-5.6-luna',effort:'max'})
|
|
49
|
+
assert.deepEqual(executionOverrides('codex', {model:'gpt-5.6-luna',effort:'max'}, 'gpt-6-astra'), {model:'gpt-6-astra',effort:'high'})
|
|
50
|
+
assert.deepEqual(executionOverrides('codex', {model:'gpt-5.6-luna',effort:'max'}, 'gpt-5.6-luna'), {model:'gpt-5.6-luna',effort:'max'})
|
|
51
|
+
const stored = persistedPreset({id:'luna',name:'Luna',cli:'codex',model:'gpt-5.6-luna',effort:'max'})
|
|
52
|
+
assert.equal(stored.effort, undefined)
|
|
53
|
+
assert.deepEqual(executionDefaults('codex', stored), {id:'luna',name:'Luna',cli:'codex',model:'gpt-5.6-luna',effort:'max'})
|
|
44
54
|
})
|
|
45
55
|
|
|
46
56
|
test('preset persistence rejects above-high choices without changing current settings', async () => {
|
|
@@ -30,8 +30,8 @@ for(const cleanup of ['success','failure','already-removed']) test(`cancel remov
|
|
|
30
30
|
});
|
|
31
31
|
async function fixture(t) {
|
|
32
32
|
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
33
|
-
const source=path.join(root,'source'),home=path.join(root,'tools'),
|
|
34
|
-
for(const d of [source,workspace,fake])await fs.mkdir(d);
|
|
33
|
+
const source=path.join(root,'source'),home=path.join(root,'tools'),deploymentDir=path.join(root,'deployment'),workspace=path.join(deploymentDir,'mind'),control=path.join(deploymentDir,'control'),hostConfig=path.join(deploymentDir,'host-executor.json'),fake=path.join(root,'fake');
|
|
34
|
+
for(const d of [source,workspace,control,fake])await fs.mkdir(d,{recursive:true});
|
|
35
35
|
const manifest={schemaVersion:1,id:'sample',version:'0.1.0',description:'Synthetic',commands:{sample:{executable:'client.mjs',args:[]}},skills:['SKILL.md']};
|
|
36
36
|
const deployment={schemaVersion:1,services:{sample:{buildTarget:'runtime',volumes:{data:'/data'},workspace:true,healthcheck:['node','--version']}},commands:{sample:{service:'sample',argv:['node','/app/client.mjs'],suffix:[]}}};
|
|
37
37
|
for(const [name,value]of Object.entries({'package.json':JSON.stringify({files:['client.mjs','SKILL.md']}),'ez-plugin.json':JSON.stringify(manifest),'ez-deployment.json':JSON.stringify(deployment),'Dockerfile':'FROM scratch AS runtime','.dockerignore':'','client.mjs':'','SKILL.md':'Synthetic'}))await fs.writeFile(path.join(source,name),value);
|
|
@@ -39,7 +39,7 @@ async function fixture(t) {
|
|
|
39
39
|
await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nrequire('fs').appendFileSync(${JSON.stringify(log)},JSON.stringify({argv:process.argv.slice(2),secret:process.env.TELEGRAM_BOT_TOKEN})+'\\n');\nif(process.argv.includes('run')){console.log(JSON.stringify(process.argv.slice(process.argv.indexOf('/app/client.mjs')+1)));process.exit(process.argv.includes('fail')?17:0)}\n`,{mode:0o700});
|
|
40
40
|
const env={...process.env,PATH:fake+path.delimiter+process.env.PATH,TELEGRAM_BOT_TOKEN:'must-not-leak'};
|
|
41
41
|
const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
|
|
42
|
-
return {root,source,home,workspace,fake,log,manifest,deployment,env,call};
|
|
42
|
+
return {root,source,home,deploymentDir,workspace,control,hostConfig,fake,log,manifest,deployment,env,call};
|
|
43
43
|
}
|
|
44
44
|
test('catalog paths resolve relative to the catalog and pin each new agent independently',async t=>{
|
|
45
45
|
const f=await fixture(t),catalog=path.join(f.root,'defaults.json');
|
|
@@ -95,13 +95,45 @@ test('registry corruption, active writer lock and alias collision fail closed',a
|
|
|
95
95
|
});
|
|
96
96
|
test('Compose resources are namespaced, private, and restricted to owning workspace',async t=>{
|
|
97
97
|
const f=await fixture(t),p=await snapshot(f.source);
|
|
98
|
-
const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
|
|
98
|
+
const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:p.deployment});
|
|
99
99
|
assert.equal(c.name,'ezp-synthetic');assert.equal(c.services.sample.ports,undefined);assert.equal(c.services.sample.privileged,undefined);assert.equal(c.services.sample.volumes[1].read_only,true);assert.deepEqual(c.services.sample.cap_drop,['ALL']);
|
|
100
100
|
});
|
|
101
|
+
test('host-owned private networks survive registered-call Compose regeneration',async t=>{
|
|
102
|
+
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
103
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
104
|
+
const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
|
|
105
|
+
const binding={service:'sample',network:'stocks-vm_default'};
|
|
106
|
+
const host={cli:'synthetic',agents:[{name:'sample',workspace:f.workspace,controlDir:f.control,binDir:path.join(f.home,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[binding]}}}]};
|
|
107
|
+
await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
108
|
+
config.hostConfig=await fs.realpath(f.hostConfig);
|
|
109
|
+
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify(config));
|
|
110
|
+
const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
|
|
111
|
+
await f.call('sample','read');
|
|
112
|
+
const c=JSON.parse(await fs.readFile(file,'utf8'));
|
|
113
|
+
assert.deepEqual(c.networks.default,{});
|
|
114
|
+
const [key,network]=Object.entries(c.networks).find(([name])=>name!=='default');
|
|
115
|
+
assert.equal(network.name,'stocks-vm_default');assert.equal(network.external,true);
|
|
116
|
+
assert.deepEqual(c.services.sample.networks,['default',key]);
|
|
117
|
+
const compromised={...config,hostNetworkBindings:{sample:[{service:'sample',network:'untrusted_default'}]}};
|
|
118
|
+
assert.deepEqual((await compose(compromised,p,{},f.home)).services.sample.networks,['default',key]);
|
|
119
|
+
const upgraded={...config,hostConfig:undefined,deploymentDir:path.dirname(config.hostConfig)};
|
|
120
|
+
assert.deepEqual((await compose(upgraded,p,{},f.home)).services.sample.networks,['default',key]);
|
|
121
|
+
const unsafeHost=path.join(f.home,'host-executor.json');await fs.writeFile(unsafeHost,JSON.stringify(host));
|
|
122
|
+
await assert.rejects(compose({...config,hostConfig:unsafeHost},p,{},f.home),/external host config/);
|
|
123
|
+
const forged=structuredClone(p);forged.manifest.id='forged';
|
|
124
|
+
host.agents[0].pluginNetworkBindings={forged:{revisions:[p.revision],bindings:[binding]}};await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
125
|
+
await assert.rejects(compose(config,forged,{},f.home),/reviewed source/);
|
|
126
|
+
for(const bindings of [
|
|
127
|
+
{sample:[{service:'missing',network:'stocks-vm_default'}]},
|
|
128
|
+
{sample:[{service:'sample',network:'bad/network'}]},
|
|
129
|
+
{sample:[{service:'sample',network:'stocks-vm_default'},{service:'sample',network:'stocks-vm_default'}]},
|
|
130
|
+
'not-a-binding-map'
|
|
131
|
+
]) { host.agents[0].pluginNetworkBindings=bindings==='not-a-binding-map'?bindings:{sample:{revisions:[p.revision],bindings}};await fs.writeFile(f.hostConfig,JSON.stringify(host));await assert.rejects(compose(config,{source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment},{},f.home),/host network|Docker network/); }
|
|
132
|
+
});
|
|
101
133
|
test('host onboarding binds local ez without replacing global commands',async t=>{
|
|
102
134
|
const f=await fixture(t),native=path.join(f.root,'native'),config=path.join(f.root,'host.json');await fs.mkdir(native);await fs.writeFile(path.join(native,'native-tool'),'hello');
|
|
103
135
|
await fs.writeFile(config,JSON.stringify({cli:'synthetic',agents:[{name:'demo',workspace:f.workspace,binDir:native}]}));
|
|
104
|
-
await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
|
|
136
|
+
await init(f.home,f.workspace,undefined,config);const host=JSON.parse(await fs.readFile(config));assert.equal(host.agents[0].binDir,path.join(await fs.realpath(f.home),'bin'));assert.equal(host.agents[0].toolsHome,await fs.realpath(f.home));assert.equal((JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'))).hostConfig,undefined);assert.equal(await fs.readFile(path.join(host.agents[0].binDir,'native-tool'),'utf8'),'hello');
|
|
105
137
|
await assert.rejects(init(f.home,f.workspace),/exists/);
|
|
106
138
|
});
|
|
107
139
|
test('host install exposes runnable public commands without source aliases',async t=>{
|
|
@@ -128,10 +160,10 @@ test('v2 supports bounded dependency graphs and generated private secrets',async
|
|
|
128
160
|
d.services.database={image:'example/database@sha256:'+'a'.repeat(64),user:'999:999',healthcheck:['check'],memoryMiB:128,cpus:2,environment:{PASSWORD:{secret:'db-password'}}};
|
|
129
161
|
d.services.sample.dependsOn=['database'];d.services.sample.environment={URL:{secret:'db-password',prefix:'db://',suffix:'@database'}};
|
|
130
162
|
validate(f.manifest,d,p.files);
|
|
131
|
-
const c=compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
|
|
163
|
+
const c=await compose({workspace:f.workspace},{source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d},{'db-password':'b'.repeat(64)});
|
|
132
164
|
assert.equal(c.services.database.user,'999:999');assert.equal(c.services.sample.depends_on.database.condition,'service_healthy');assert.equal(c.services.sample.environment.URL,'db://'+'b'.repeat(64)+'@database');
|
|
133
|
-
assert.equal(c.services.database.mem_limit,'128m');assert.
|
|
134
|
-
assert.equal(c.services.database.cpus,2);assert.
|
|
165
|
+
assert.equal(c.services.database.mem_limit,'128m');await assert.rejects(compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
|
|
166
|
+
assert.equal(c.services.database.cpus,2);await assert.rejects(compose({workspace:f.workspace}, {source:f.source,project:'ezp-synthetic',revision:p.revision,deployment:d}),/Missing/);
|
|
135
167
|
for(const change of [x=>x.services.database.user='0:0',x=>x.services.database.cpus=0.01,x=>x.services.database.cpus=9,x=>x.services.database.environment.PASSWORD={secret:'undeclared'},x=>x.services.database.environment.PASSWORD='${HOST_SECRET}',x=>x.services.database.dependsOn=['sample'],x=>x.services.sample.dependsOn=['missing'],x=>x.services.sample.ports=['9999:9999']]){const bad=structuredClone(d);change(bad);assert.throws(()=>validate(f.manifest,bad,p.files));}
|
|
136
168
|
const old=structuredClone(d);old.schemaVersion=1;assert.throws(()=>validate(f.manifest,old,p.files));
|
|
137
169
|
});
|
|
@@ -252,9 +284,9 @@ test('folder bindings survive compatible descriptors and reject incompatible upd
|
|
|
252
284
|
const f=await fixture(t);const p=await snapshot(f.source);
|
|
253
285
|
const record={...p,project:'ezp-test-sample'};
|
|
254
286
|
const config={workspace:f.workspace,folders:{sample:[{service:'sample',source:f.workspace,target:'/data/files'}]}};
|
|
255
|
-
assert.equal(compose(config,record).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
287
|
+
assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
256
288
|
const next=structuredClone(record);next.deployment.services.sample.volumes={data:'/new-state'};
|
|
257
|
-
assert.
|
|
289
|
+
await assert.rejects(compose(config,next),/child of a declared volume/);
|
|
258
290
|
});
|
|
259
291
|
|
|
260
292
|
test('folder rebind rejects every live project container including one-shots',async t=>{
|
package/test/reply.test.ts
CHANGED
|
@@ -44,7 +44,7 @@ test('reply native adapter exposes only context send defer with shell and networ
|
|
|
44
44
|
assert.doesNotMatch(args,/--add-dir/)
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test('reply handoff deduplicates the owner request and defaults independently to
|
|
47
|
+
test('reply handoff deduplicates the owner request and defaults independently to Luna max', async () => {
|
|
48
48
|
const root=await mkdtemp(join(tmpdir(),'ez-reply-defer-')), runs=new RunStore(root)
|
|
49
49
|
try {
|
|
50
50
|
const control=new ControlStore(root,900000)
|
|
@@ -55,11 +55,17 @@ test('reply handoff deduplicates the owner request and defaults independently to
|
|
|
55
55
|
const first=await replyCall(root,'tg_4',root,'defer',{text:'Prepare the report using the canonical sources'})
|
|
56
56
|
assert.deepEqual(await replyCall(root,'tg_4',root,'defer',{text:'retry'}),first)
|
|
57
57
|
const saved=JSON.parse(await readFile(join(root,'schedules','s_reply_tg_4.json'),'utf8'))
|
|
58
|
-
assert.equal(saved.execution.preset.model,'gpt-5.6-
|
|
59
|
-
assert.equal(saved.execution.preset.effort,
|
|
58
|
+
assert.equal(saved.execution.preset.model,'gpt-5.6-luna')
|
|
59
|
+
assert.equal(saved.execution.preset.effort,undefined)
|
|
60
60
|
assert.notEqual(saved.execution.sessionId,execution.sessionId)
|
|
61
61
|
assert.match(saved.text,/Make the report/)
|
|
62
62
|
assert.equal(saved.owner.telegramChatId,101)
|
|
63
|
+
await runs.create({id:'tg_11',chatId:101,telegramUserId:101,texts:['Use Astra'],execution})
|
|
64
|
+
await runs.patch('tg_11',{status:'running',replyOnly:true})
|
|
65
|
+
await replyCall(root,'tg_11',root,'defer',{text:'Use Astra for this worker',model:'gpt-6-astra'})
|
|
66
|
+
const astra=JSON.parse(await readFile(join(root,'schedules','s_reply_tg_11.json'),'utf8'))
|
|
67
|
+
assert.equal(astra.execution.preset.model,'gpt-6-astra')
|
|
68
|
+
assert.equal(astra.execution.preset.effort,'high')
|
|
63
69
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
64
70
|
})
|
|
65
71
|
|
|
@@ -22,10 +22,12 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
22
22
|
const args=['create','test','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
|
|
23
23
|
const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
24
24
|
assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'codex')
|
|
25
|
-
assert.equal(saved.execution.preset.model,'gpt-5.6-
|
|
26
|
-
await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--effort','xhigh'],{env}),/capped at high/)
|
|
27
|
-
const
|
|
28
|
-
assert.equal(
|
|
25
|
+
assert.equal(saved.execution.preset.model,'gpt-5.6-luna');assert.equal(saved.execution.preset.effort,undefined)
|
|
26
|
+
await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--model','gpt-5.6-terra','--effort','xhigh'],{env}),/capped at high/)
|
|
27
|
+
const astra=JSON.parse((await exec(process.execPath,[bin,'create','astra','--at','2027-09-09T10:00:00+04:00','--text','Astra task','--model','gpt-6-astra'],{env})).stdout)
|
|
28
|
+
assert.equal(astra.execution.preset.model,'gpt-6-astra');assert.equal(astra.execution.preset.effort,'high')
|
|
29
|
+
const luna=JSON.parse((await exec(process.execPath,[bin,'create','luna','--at','2027-09-10T09:00:00+04:00','--text','Luna max task','--model','gpt-5.6-luna','--effort','max'],{env})).stdout)
|
|
30
|
+
assert.equal(luna.execution.preset.model,'gpt-5.6-luna');assert.equal(luna.execution.preset.effort,undefined)
|
|
29
31
|
assert.equal(saved.nextEligibleAt,'2027-09-09T05:00:00.000Z')
|
|
30
32
|
await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
|
|
31
33
|
assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
|
package/test/updates.test.mjs
CHANGED
|
@@ -44,7 +44,7 @@ async function fixture(t,kind='main') {
|
|
|
44
44
|
if(kind==='plugin') {
|
|
45
45
|
const s=await snapshot(old),base=path.join(home,'packages',target);await fs.mkdir(base,{recursive:true});
|
|
46
46
|
record={revision:s.revision,source:old,project:`ezp-${digest(home).slice(0,16)}-${target}`,manifest:s.manifest,deployment:s.deployment,compose:path.join(base,'compose.json')};
|
|
47
|
-
await atomic(record.compose,compose(config,record));
|
|
47
|
+
await atomic(record.compose,await compose(config,record,{},home));
|
|
48
48
|
}
|
|
49
49
|
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:record?{sample:record}:{},commands:record?{sample:'sample'}:{}});
|
|
50
50
|
await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);
|