@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.29

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.
Files changed (75) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +29 -0
  4. package/CONTRIBUTING.md +28 -0
  5. package/Dockerfile +1 -0
  6. package/README.md +79 -8
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/docker-runtime.md +20 -0
  16. package/docs/managed-applications.md +68 -0
  17. package/docs/plugin-catalog.md +1 -0
  18. package/docs/plugin-connection.md +76 -0
  19. package/docs/plugins.md +40 -4
  20. package/docs/upgrades.md +11 -1
  21. package/package.json +7 -2
  22. package/src/application-channel.ts +308 -0
  23. package/src/application-cli.ts +41 -0
  24. package/src/application-client.mjs +87 -0
  25. package/src/application-origin.ts +15 -0
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +20 -2
  28. package/src/control-state.ts +256 -15
  29. package/src/conversation-menu.ts +89 -0
  30. package/src/delivery-context.d.mts +5 -0
  31. package/src/delivery-context.mjs +25 -0
  32. package/src/event-sources.ts +2 -2
  33. package/src/execution-authority.ts +2 -0
  34. package/src/executor.ts +10 -4
  35. package/src/host-executor.ts +7 -1
  36. package/src/identity.ts +11 -3
  37. package/src/index.ts +149 -54
  38. package/src/menu.ts +26 -9
  39. package/src/message-history.ts +52 -0
  40. package/src/message.ts +48 -7
  41. package/src/owner.ts +7 -1
  42. package/src/plugins/connection-artifacts.mjs +31 -0
  43. package/src/plugins/connection.mjs +124 -0
  44. package/src/plugins/manager.mjs +63 -18
  45. package/src/plugins/native-tasks.d.mts +4 -0
  46. package/src/plugins/native-tasks.mjs +66 -0
  47. package/src/plugins/workspace-lease.d.mts +3 -0
  48. package/src/plugins/workspace-lease.mjs +44 -0
  49. package/src/runs.ts +67 -9
  50. package/src/schedule-cli.ts +11 -6
  51. package/src/scheduler.ts +17 -7
  52. package/src/updates/control.mjs +4 -0
  53. package/src/web-launcher.ts +19 -0
  54. package/templates/agent-guidance.md +58 -2
  55. package/templates/deployments.md +24 -0
  56. package/test/application-channel.test.ts +283 -0
  57. package/test/application-client.test.mjs +84 -0
  58. package/test/application-controls.test.ts +224 -0
  59. package/test/application-only.test.ts +100 -0
  60. package/test/channel-delivery.test.ts +63 -0
  61. package/test/channel-owner.test.ts +161 -0
  62. package/test/codex-session.test.ts +8 -5
  63. package/test/config.test.ts +15 -0
  64. package/test/connection-artifacts.test.mjs +32 -0
  65. package/test/conversation-menu.test.ts +67 -0
  66. package/test/conversations.test.ts +84 -0
  67. package/test/executor.test.ts +56 -0
  68. package/test/host-executor.test.ts +28 -0
  69. package/test/intake-relay.test.ts +126 -5
  70. package/test/message-history.test.ts +127 -0
  71. package/test/native-tasks.test.ts +36 -0
  72. package/test/plugin-connection.test.mjs +124 -0
  73. package/test/plugin-manager.test.mjs +34 -0
  74. package/test/runtime-identity.test.mjs +18 -0
  75. package/test/updates.test.mjs +39 -0
package/src/menu.ts CHANGED
@@ -3,18 +3,21 @@ import { readFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { randomBytes } from 'node:crypto'
5
5
  import { InlineKeyboard, type Context } from 'grammy'
6
- import { ControlStore } from './control-state.js'
6
+ import { ControlStore, type ControlGuard } from './control-state.js'
7
7
  import { chatPreset, installed, persistedPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
8
8
  import { discoverDefaults } from './client-defaults.js'
9
9
 
10
10
  export const mainCommands = [
11
11
  { command: 'new', description: 'New conversation' },
12
+ { command: 'chats', description: 'Conversations' },
13
+ { command: 'rename', description: 'Rename current conversation' },
12
14
  { command: 'ai', description: 'Choose AI' },
13
15
  { command: 'status', description: 'Work status' },
14
16
  ]
15
17
 
16
18
  export const mainKeyboard = () => new InlineKeyboard()
17
- .text('New conversation', 'menu:new').text('Choose AI', 'menu:ai').row()
19
+ .text('New conversation', 'menu:new').text('Conversations', 'menu:chats').row()
20
+ .text('Choose AI', 'menu:ai')
18
21
  .text('Work status', 'menu:status')
19
22
 
20
23
  const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
@@ -47,13 +50,19 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
47
50
  buttons.set(id, { expires: Date.now() + 15 * 60_000, action })
48
51
  keyboard.text(label.slice(0, 64), `ai:${id}`).row()
49
52
  }
50
- const choose = async (ctx: Context, preset: AiPreset) => {
53
+ const select = async (preset: AiPreset, expectedSession: string | null, guard?: ControlGuard) => {
51
54
  await validate(preset)
52
55
  const session = await control.getActiveSession()
53
- const state = await control.aiState(initial)
56
+ const state = (await control.status()).ai
57
+ if (!state) throw new Error('AI settings not initialized')
54
58
  const current = state.presets.find((p) => p.id === state.selectedId)!
55
59
  const fresh = current.cli !== preset.cli || Boolean(session && !session.cli)
56
- await control.selectPreset(preset.id, session?.sessionId ?? null, fresh)
60
+ if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
61
+ return { preset, fresh }
62
+ }
63
+ const choose = async (ctx: Context, preset: AiPreset) => {
64
+ const session = await control.getActiveSession()
65
+ const { fresh } = await select(preset, session?.sessionId ?? null)
57
66
  await ctx.reply(`${preset.name}\n${presetLabel(preset)}\n${fresh
58
67
  ? 'CLI changed: fresh conversation. Files kept; queued work unchanged.'
59
68
  : 'Selected for this conversation. Queued work unchanged.'}`)
@@ -103,20 +112,28 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
103
112
  button(keyboard, 'Back to clients', (next) => list(next))
104
113
  await ctx.reply(`${clientLabel(cli)}\nChoose a model`, { reply_markup: keyboard })
105
114
  }
106
- const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
107
- const state = await control.aiState(initial)
115
+ const saveSelection = async (model: ModelChoice, effort?: string, guard?: ControlGuard) => {
116
+ const state = (await control.status()).ai
117
+ if (!state) throw new Error('AI settings not initialized')
108
118
  const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
109
119
  name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
110
120
  const stored = persistedPreset(candidate)
111
121
  const existing = state.presets.find((preset) => preset.cli === stored.cli && preset.model === stored.model && preset.effort === stored.effort)
112
122
  const preset = existing ?? candidate
113
123
  await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
114
- await control.savePreset(preset)
115
- await choose(ctx, preset)
124
+ await control.savePreset(preset, guard)
125
+ return preset
126
+ }
127
+ const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
128
+ await choose(ctx, await saveSelection(model, effort))
116
129
  }
117
130
  return {
118
131
  initial,
119
132
  refresh,
133
+ validate,
134
+ catalog: () => catalog(),
135
+ select,
136
+ saveSelection,
120
137
  list,
121
138
  async handle(ctx: Context): Promise<boolean> {
122
139
  const data = ctx.callbackQuery?.data
@@ -0,0 +1,52 @@
1
+ import { readFile, readdir } from 'node:fs/promises'
2
+ import { join, basename } from 'node:path'
3
+ import { requireOwnerExecution } from './execution-authority.js'
4
+ import { ControlStore } from './control-state.js'
5
+ import { ownsRun } from './identity.js'
6
+ import { RunStore } from './runs.js'
7
+
8
+ // Read existing delivery receipts; native sessions still own conversation history.
9
+ export async function deliveredMessages(controlDir: string, runId: string, options: { limit?: number; messageId?: number } = {}) {
10
+ const limit = options.limit ?? 8
11
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new Error('Limit must be 1..50')
12
+ if (options.messageId !== undefined && (!Number.isSafeInteger(options.messageId) || options.messageId < 1))
13
+ throw new Error('Message ID must be a positive integer')
14
+ const caller = await requireOwnerExecution(controlDir, runId)
15
+ if (caller.application || caller.delivery) throw new Error('History requires a Telegram owner run')
16
+ const owner = (await new ControlStore(controlDir, 900_000).status()).owner
17
+ if (!ownsRun(owner, caller)) throw new Error('Owner binding changed')
18
+ const pairedAt = Date.parse(owner!.pairedAt)
19
+ const runs = new Map((await new RunStore(controlDir).list()).filter(run =>
20
+ ownsRun(owner, run) && !run.external && !run.taskId && !run.application &&
21
+ Date.parse(run.createdAt) >= pairedAt && (!run.scheduled || run.scheduled.pairedAt === owner!.pairedAt)
22
+ ).map(run => [run.id, run]))
23
+ const directory = join(controlDir, 'outbox')
24
+ const files = await readdir(directory).catch((error: NodeJS.ErrnoException) => {
25
+ if (error.code === 'ENOENT') return []
26
+ throw error
27
+ })
28
+ const messages = []
29
+ for (const file of files.filter(file => file.endsWith('.sent.json'))) {
30
+ // Corrupt or incomplete receipts are not delivery evidence.
31
+ let item
32
+ try { item = JSON.parse(await readFile(join(directory, file), 'utf8')) } catch { continue }
33
+ if (!item || typeof item !== 'object') continue
34
+ const run = runs.get(item.runId)
35
+ const deliveredAt = Date.parse(item.receipt?.deliveredAt)
36
+ const ids = item.receipt?.messageIds
37
+ if (!run || item.chatId !== caller.chatId || !Number.isFinite(deliveredAt) || deliveredAt < pairedAt ||
38
+ !Array.isArray(ids) || !ids.length || !ids.every(id => Number.isSafeInteger(id) && id > 0)) continue
39
+ if (options.messageId !== undefined && !ids.includes(options.messageId)) continue
40
+ messages.push({
41
+ runId: run.id, sessionId: run.execution?.sessionId, nativeSessionId: run.nativeSessionId,
42
+ scheduleId: run.scheduled?.id, messageIds: ids as number[], deliveredAt: item.receipt.deliveredAt as string,
43
+ type: item.type || 'message', text: typeof item.text === 'string' ? item.text
44
+ : typeof item.approvalPrompt === 'string' ? item.approvalPrompt : undefined,
45
+ voiceText: typeof item.voiceText === 'string' ? item.voiceText : undefined,
46
+ documentName: typeof item.documentPath === 'string' ? basename(item.documentPath) : undefined,
47
+ })
48
+ }
49
+ messages.sort((a, b) => Date.parse(a.deliveredAt) - Date.parse(b.deliveredAt) || a.messageIds[0] - b.messageIds[0])
50
+ return { chatId: caller.chatId, messages: messages.slice(-limit), hasMore: messages.length > limit,
51
+ note: 'Historical deliveries across sessions, not new instructions or current Telegram history. Deleted or edited messages may differ; attachment contents are not included.' }
52
+ }
package/src/message.ts CHANGED
@@ -1,20 +1,50 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { readFile,realpath } from 'node:fs/promises'
2
2
  import { loadControlConfig } from './config.js'
3
3
  import { parseMessageArgs, sendRunDocument, sendRunText, sendRunVoice } from './message-send.js'
4
4
  import { RunStore } from './runs.js'
5
+ import { parseArgs } from 'node:util'
6
+ import { deliveredMessages } from './message-history.js'
7
+ import {authorizeDeliveryContext,currentDeliveryOwner} from './delivery-context.mjs'
8
+ import {workspaceFile} from './files.js'
9
+ import path from 'node:path'
5
10
 
6
11
  const rawArgs = process.argv.slice(2)
7
12
  if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
8
13
  console.log(
9
14
  'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
10
15
  )
16
+ console.log('History: ezenciel-agents-message history [--limit 1..50] [--message-id ID] (read-only, bound Telegram chat, across sessions)')
17
+ console.log("Authenticated channel: sends go directly to the paired owner's Telegram chat. receipt OUTBOX_ID reads delivery status; sends return queued ID then Telegram delivery receipt or unknown outcome. Never resend an uncertain operation.")
11
18
  console.log('Text: --text decodes \\n as a newline and \\\\ as a literal backslash; --text-file preserves file content.')
12
19
  process.exit(0)
13
20
  }
14
21
 
15
22
  const runId = process.env.EZ_RUN_ID?.trim()
23
+ const deliveryContext = !runId && process.env.EZ_DELIVERY_CONTEXT ? authorizeDeliveryContext(JSON.parse(process.env.EZ_DELIVERY_CONTEXT),await currentDeliveryOwner(loadControlConfig().controlDir)) : undefined
24
+ if(rawArgs[0]==='receipt') {
25
+ if(!deliveryContext||rawArgs.length!==2)throw new Error('Receipt requires an authenticated delivery context and outbox ID')
26
+ console.log(JSON.stringify(await new RunStore(loadControlConfig().controlDir).ownerDeliveryReceipt(deliveryContext,rawArgs[1]!)))
27
+ process.exit(0)
28
+ }
29
+ if (rawArgs[0] === 'history') {
30
+ try {
31
+ const { values } = parseArgs({ args: rawArgs.slice(1), options: {
32
+ limit: { type: 'string' }, 'message-id': { type: 'string' },
33
+ } })
34
+ if (!runId) throw new Error('EZ_RUN_ID is required')
35
+ const result = await deliveredMessages(loadControlConfig().controlDir, runId, {
36
+ limit: values.limit === undefined ? undefined : Number(values.limit),
37
+ messageId: values['message-id'] === undefined ? undefined : Number(values['message-id']),
38
+ })
39
+ await new Promise<void>((resolve, reject) => process.stdout.write(JSON.stringify(result) + '\n', error => error ? reject(error) : resolve()))
40
+ } catch (error) {
41
+ console.error(error instanceof Error ? error.message : String(error))
42
+ process.exit(1)
43
+ }
44
+ process.exit(0)
45
+ }
16
46
  const args = parseMessageArgs(rawArgs)
17
- if (!runId) {
47
+ if (!runId && !deliveryContext) {
18
48
  console.error('EZ_RUN_ID is required')
19
49
  process.exit(1)
20
50
  }
@@ -23,6 +53,7 @@ const store = new RunStore(loadControlConfig().controlDir)
23
53
 
24
54
  let textContent = args.text?.trim()
25
55
  if (args.textFile) {
56
+ if(deliveryContext)throw new Error('Channel delivery requires inline text, not host file input')
26
57
  try {
27
58
  textContent = (await readFile(args.textFile, 'utf8')).trim()
28
59
  } catch (err: any) {
@@ -32,12 +63,20 @@ if (args.textFile) {
32
63
  }
33
64
 
34
65
  let item
35
- if (args.document) {
36
- item = await sendRunDocument(store, runId, args.document, textContent, { replyTo: args.replyTo })
66
+ if(deliveryContext) {
67
+ if(args.document) {
68
+ if(!process.env.EZ_AGENT_WORKSPACE)throw new Error('Channel delivery requires owning workspace')
69
+ const documentPath=await workspaceFile(process.env.EZ_AGENT_WORKSPACE,args.document)
70
+ item=await store.enqueueOwnerDelivery(deliveryContext,{type:'document',documentPath:path.relative(await realpath(process.env.EZ_AGENT_WORKSPACE),documentPath),text:textContent,replyToMessageId:args.replyTo})
71
+ } else if(args.voice) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'voice',voiceText:args.voice,replyToMessageId:args.replyTo})
72
+ else if(textContent) item=await store.enqueueOwnerDelivery(deliveryContext,{type:'message',text:textContent,replyToMessageId:args.replyTo})
73
+ else throw new Error('Message content is required')
74
+ } else if (args.document) {
75
+ item = await sendRunDocument(store, runId!, args.document, textContent, { replyTo: args.replyTo })
37
76
  } else if (args.voice) {
38
- item = await sendRunVoice(store, runId, args.voice, { replyTo: args.replyTo })
77
+ item = await sendRunVoice(store, runId!, args.voice, { replyTo: args.replyTo })
39
78
  } else if (textContent) {
40
- item = await sendRunText(store, runId, textContent, { replyTo: args.replyTo })
79
+ item = await sendRunText(store, runId!, textContent, { replyTo: args.replyTo })
41
80
  } else {
42
81
  console.error(
43
82
  'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
@@ -46,12 +85,14 @@ if (args.document) {
46
85
  }
47
86
 
48
87
  try {
49
- const receipt = await store.waitForDelivery(item.id)
88
+ if(deliveryContext)console.log(JSON.stringify({ok:true,status:'queued',outbox_id:item.id}))
89
+ const receipt = await store.waitForDelivery(item.id,deliveryContext?20000:undefined)
50
90
  console.log(
51
91
  JSON.stringify({
52
92
  ok: true,
53
93
  status: 'delivered',
54
94
  run: runId,
95
+ ...(deliveryContext?{connection:deliveryContext.connectionId}:{}),
55
96
  outbox_id: item.id,
56
97
  type: item.type,
57
98
  receipt,
package/src/owner.ts CHANGED
@@ -2,13 +2,14 @@ import { loadControlConfig } from './config.js'
2
2
  import { ControlStore } from './control-state.js'
3
3
  import { parseOwnerArgs } from './owner-args.js'
4
4
 
5
- const help = 'Usage: ezenciel-agents-owner status | approve <telegram-user-id> | approve-group <negative-chat-id> | revoke'
5
+ const help = 'Usage: ezenciel-agents-owner status | register <verified-owner-id> | approve <telegram-user-id> | approve-group <negative-chat-id> | unlink-telegram | revoke'
6
6
  if (process.argv.slice(2).some(arg => arg === '--help' || arg === '-h')) {
7
7
  console.log(help)
8
8
  process.exit(0)
9
9
  }
10
10
 
11
11
  const { command, value } = parseOwnerArgs(process.argv.slice(2))
12
+ if (process.env.EZ_RUN_ID && command !== 'status') throw new Error('Owner registration and channel linking require the installing administrator')
12
13
  const config = loadControlConfig()
13
14
  const store = new ControlStore(config.controlDir, config.pairingTtlMs)
14
15
 
@@ -20,6 +21,11 @@ const usage = (): never => {
20
21
  if (command === 'status' && !value) {
21
22
  const state = await store.status()
22
23
  console.log(JSON.stringify({ owner: state.owner, pending: state.pending, control_dir: config.controlDir }, null, 2))
24
+ } else if (command === 'register' && value) {
25
+ console.log(JSON.stringify(await store.registerOwner(value)))
26
+ } else if (command === 'unlink-telegram' && !value) {
27
+ await store.unlinkTelegram()
28
+ console.log('Telegram unlinked; installation owner and sessions retained.')
23
29
  } else if ((command === 'approve' || command === 'approve-group') && value) {
24
30
  const owner = await store.approveOwner(Number(value), command === 'approve-group')
25
31
  console.log(`Paired Telegram owner ${owner.telegramUserId}.`)
@@ -0,0 +1,31 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {createHash,randomUUID} from 'node:crypto';
4
+
5
+ // Preserve CLI stdout as bytes without putting attachments into model context.
6
+ export async function commandArtifact(workspace,name,execute,{signal}={}) {
7
+ if(typeof name!=='string'||!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/.test(name))throw Error('Expected a simple output filename');
8
+ const root=await fs.realpath(workspace),directory=path.join(root,'artifacts');
9
+ await fs.mkdir(directory,{recursive:true,mode:0o700});
10
+ if(await fs.realpath(directory)!==directory)throw Error('Artifact directory must not be a symlink');
11
+ const controller=new AbortController(),cancel=()=>controller.abort();
12
+ signal?.addEventListener('abort',cancel,{once:true});
13
+ if(signal?.aborted)controller.abort();
14
+ let bytes=0,overflow=false;const chunks=[];
15
+ try {
16
+ controller.signal.throwIfAborted();
17
+ const result=await execute({signal:controller.signal,onStdout:chunk=>{
18
+ bytes+=chunk.length;
19
+ if(bytes>20*1024*1024){overflow=true;controller.abort();return;}
20
+ if(!overflow)chunks.push(Buffer.from(chunk));
21
+ }});
22
+ if(overflow)throw Error('Artifact exceeds 20 MiB limit');
23
+ controller.signal.throwIfAborted();
24
+ if(result.code!==0)return result;
25
+ const content=Buffer.concat(chunks),relative=path.join('artifacts',`${randomUUID()}_${name}`);
26
+ // Check the parent again after the command, before writing its result.
27
+ if(await fs.realpath(directory)!==directory)throw Error('Artifact directory changed');
28
+ await fs.writeFile(path.join(root,relative),content,{mode:0o600,flag:'wx'});
29
+ return {...result,stdout:'',artifact:{path:relative,bytes:content.length,sha256:createHash('sha256').update(content).digest('hex')}};
30
+ } finally {signal?.removeEventListener('abort',cancel);}
31
+ }
@@ -0,0 +1,124 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import { registry, prepareCommand, run } from './manager.mjs';
5
+ import { invokeLease } from './workspace-lease.mjs';
6
+ import { nativeTasks,nativeTaskBinding,nativeCommands } from './native-tasks.mjs';
7
+ import {currentDeliveryOwner,captureDeliveryContext} from '../delivery-context.mjs';
8
+ import {commandArtifact} from './connection-artifacts.mjs';
9
+
10
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
11
+ const reserved = ['coreRequest','coreResponse','coreApprove','coreApproval','coreApprovalResolved','coreCancel'];
12
+ const maxFrame = 1048576;
13
+ function requestId(value) {if(typeof value!=='string'||!/^[a-zA-Z0-9_-]{1,100}$/.test(value))throw Error('Invalid request id');return value;}
14
+
15
+ // The local owning connection uses the same installed authority as its bound CLI.
16
+ export function connectionProtocol({readRegistry,execute,executeNative,listNative=nativeCommands,readOwner,sendClient,sendPlugin,excludedPlugin}) {
17
+ const active=new Map(),consumed=new Set();let closed=false;
18
+ async function record(alias) {
19
+ const r=await readRegistry(),plugin=r.commands[alias],value=r.plugins[plugin];
20
+ if(!value||plugin===excludedPlugin)throw Error('Unknown or unavailable registered CLI');
21
+ return value;
22
+ }
23
+ async function perform(req,state) {
24
+ const p=req.params??{};
25
+ if(!object(p))throw Error('Invalid request params');
26
+ if(req.method==='tools.owner') {
27
+ if(Object.keys(p).length||!readOwner)throw Error('Owner identity unavailable');
28
+ await readRegistry();return readOwner();
29
+ }
30
+ if(req.method==='tools.native.list') {await readRegistry();return listNative();}
31
+ if(req.method==='tools.native') {
32
+ if(Object.keys(p).some(k=>!['command','args'].includes(k)))throw Error('Unknown native request parameter');
33
+ await readRegistry();
34
+ if(closed||state.abort.signal.aborted)throw Error('Request cancelled');
35
+ if(!executeNative)throw Error('Native task access is unavailable');
36
+ return executeNative(p.args,{signal:state.abort.signal,command:p.command??'schedule'});
37
+ }
38
+ if(req.method==='tools.list') {
39
+ const r=await readRegistry();return Object.entries(r.commands).filter(([,owner])=>owner!==excludedPlugin).map(([alias,owner])=>{
40
+ const v=r.plugins[owner];return {alias,plugin:owner,description:String(v.manifest.description??'').slice(0,200),skillCount:v.manifest.skills.length,revision:v.revision};
41
+ });
42
+ }
43
+ const v=await record(p.alias);
44
+ if(req.method==='tools.skill') {
45
+ const index=p.index,line=p.line??1;
46
+ if(!Number.isInteger(index)||index<0||index>=v.manifest.skills.length||!Number.isInteger(line)||line<1)throw Error('Invalid skill index or line');
47
+ const root=await fs.realpath(v.source),file=await fs.realpath(path.resolve(root,v.manifest.skills[index]));
48
+ if(!file.startsWith(root+path.sep))throw Error('Skill escapes plugin source');
49
+ const handle=await fs.open(file,'r');let text;
50
+ try {const stat=await handle.stat();if(!stat.isFile()||stat.size>maxFrame)throw Error('Skill exceeds read limit');text=await handle.readFile('utf8');}finally{await handle.close();}
51
+ const lines=text.split('\n'),selected=lines.slice(line-1,line+99).join('\n');
52
+ if(Buffer.byteLength(selected)>32768)throw Error('Skill page exceeds read limit');
53
+ return {text:selected,nextLine:line+100<=lines.length?line+100:null};
54
+ }
55
+ let args;
56
+ if(req.method==='tools.help')args=['--help'];
57
+ else if(req.method==='tools.invoke') {
58
+ args=p.args;
59
+ if(!Array.isArray(args)||args.length>100||args.some(a=>typeof a!=='string'||a.includes('\0')||a.length>8192)||p.stdin!==undefined&&(typeof p.stdin!=='string'||Buffer.byteLength(p.stdin)>65536))throw Error('Invalid literal command arguments');
60
+ } else throw Error('Unknown core method');
61
+ if(closed||state.abort.signal.aborted)throw Error('Request cancelled');
62
+ const current=await record(p.alias);
63
+ if(current.revision!==v.revision)throw Error('Plugin changed; discover again');
64
+ return execute(p.alias,args,{revision:v.revision,stdin:req.method==='tools.invoke'?p.stdin:undefined,output:req.method==='tools.invoke'?p.output:undefined,signal:state.abort.signal,invocation:req.method==='tools.invoke'});
65
+ }
66
+ return {
67
+ plugin(frame) {
68
+ if(closed)return;
69
+ if(!object(frame))throw Error('Expected JSON object');
70
+ if(frame.coreCancel){if(reserved.some(k=>k!=='coreCancel'&&k in frame))throw Error('Plugin forged core control frame');const id=requestId(frame.coreCancel.id),state=active.get(id);state?.abort.abort();return;}
71
+ if(reserved.some(k=>k!=='coreRequest'&&k in frame))throw Error('Plugin forged core control frame');
72
+ if(!('coreRequest' in frame)){sendClient(frame);return;}
73
+ const req=frame.coreRequest;if(!object(req))throw Error('Invalid core request');requestId(req.id);
74
+ if(active.has(req.id)||consumed.has(req.id)||active.size>=8||(req.method!=='tools.owner'&&consumed.size>=10000))throw Error('Duplicate or excessive core request');
75
+ if(req.method!=='tools.owner')consumed.add(req.id);
76
+ const state={abort:new AbortController()};active.set(req.id,state);
77
+ return perform(req,state).then(result=>{if(!closed)sendPlugin({coreResponse:{id:req.id,result}});},error=>{if(!closed)sendPlugin({coreResponse:{id:req.id,error:error.message}});}).finally(()=>{active.delete(req.id);});
78
+ },
79
+ client(frame) {
80
+ if(closed)return;
81
+ if(!object(frame))throw Error('Expected JSON object');
82
+ if(reserved.some(k=>k in frame))throw Error('Client forged core control frame');
83
+ sendPlugin(frame);
84
+ },
85
+ close() {closed=true;for(const state of active.values())state.abort.abort();active.clear();},
86
+ };
87
+ }
88
+
89
+ export function jsonLines(onFrame,onError) {
90
+ let buffer='';const decoder=new StringDecoder('utf8');
91
+ return chunk=>{try {buffer+=decoder.write(chunk);if(Buffer.byteLength(buffer)>maxFrame)throw Error('Connection frame limit exceeded');let index;while((index=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,index);buffer=buffer.slice(index+1);if(line)onFrame(JSON.parse(line));}}catch(error){onError(error);}};
92
+ }
93
+
94
+ export function loopbackPublish(value) {
95
+ if(typeof value!=='string'||!/^\d{1,5}:\d{1,5}$/.test(value)||value.split(':').some(p=>Number(p)<1024||Number(p)>65535))throw Error('Expected host-port:container-port (1024-65535)');
96
+ return `127.0.0.1:${value}`;
97
+ }
98
+
99
+ export async function connect(home,alias,args,{input=process.stdin,output=process.stdout,publish,serve=false}={}) {
100
+ const command=await prepareCommand(home,alias,args,{...(serve?{publish:loopbackPublish(publish)}:{})}),abort=new AbortController();let child,protocol,failure;
101
+ const config=JSON.parse(await fs.readFile(path.join(home,'config.json'),'utf8'));
102
+ const nativeBinding=config.hostConfig?await nativeTaskBinding(home):undefined;
103
+ const deliveryContext=nativeBinding?await captureDeliveryContext(nativeBinding.env.EZ_CONTROL_DIR,command.plugin,command.revision):undefined;
104
+ const send=(stream,frame)=>{try {const text=JSON.stringify(frame)+'\n';if(stream?.writableLength>maxFrame||Buffer.byteLength(text)>maxFrame)throw Error('Connection backpressure limit exceeded');stream?.write(text);}catch(error){fail(error);}};
105
+ const fail=error=>{failure??=error;abort.abort();protocol?.close();};
106
+ protocol=connectionProtocol({excludedPlugin:command.plugin,readRegistry:async()=>{const r=await registry(home);if(r.commands[alias]!==command.plugin||r.plugins[command.plugin]?.revision!==command.revision)throw Error('Connected plugin changed; reconnect');return r;},
107
+ readOwner:async()=>{
108
+ if(!nativeBinding)return null;
109
+ const owner=await currentDeliveryOwner(nativeBinding.env.EZ_CONTROL_DIR);
110
+ if(!owner||owner.kind!==undefined||!Number.isSafeInteger(owner.telegramUserId)||owner.telegramUserId<=0||!Number.isSafeInteger(owner.telegramChatId)||owner.telegramChatId<=0||typeof owner.pairedAt!=='string'||!Number.isFinite(Date.parse(owner.pairedAt)))return null;
111
+ return {telegramUserId:owner.telegramUserId,pairedAt:JSON.stringify([owner.id,owner.generation,owner.pairedAt,owner.telegramLinkedAt,owner.telegramChatId])};
112
+ },
113
+ listNative:()=>nativeCommands().map(item=>({...item,available:!!nativeBinding&&(item.command!=='message'||!!deliveryContext)})),
114
+ executeNative:(args,options)=>nativeTasks(home,args,{...options,deliveryContext}),
115
+ execute:async(a,argv,options)=>{const release=options.invocation?await invokeLease(home):undefined;try {const c=await prepareCommand(home,a,argv,{revision:options.revision,exclude:command.plugin});if(options.signal.aborted)throw Error('Request cancelled');const execute=overrides=>run(c.argv,{...options,...overrides,container:c.container,capture:true,timeoutMs:30000,maxBytes:262144});return options.output===undefined?await execute({}):await commandArtifact(config.workspace,options.output,execute,{signal:options.signal});}finally{await release?.();}},
116
+ sendClient:frame=>send(output,frame),sendPlugin:frame=>send(child?.stdin,frame)});
117
+ const onInput=jsonLines(frame=>protocol.client(frame),fail),onEnd=()=>{protocol.close();abort.abort();};
118
+ const onSignal=()=>{protocol.close();abort.abort();};
119
+ if(serve)for(const signal of ['SIGINT','SIGTERM'])process.on(signal,onSignal);
120
+ try {
121
+ const result=await run(command.argv,{container:command.container,capture:true,maxBytes:262144,signal:abort.signal,onStdout:jsonLines(frame=>protocol.plugin(frame),fail),onStart:c=>{child=c;if(!serve){input.on('data',onInput);input.once('end',onEnd);input.resume();}}});
122
+ if(failure)throw failure;process.exitCode=result.code;
123
+ } finally {if(serve)for(const signal of ['SIGINT','SIGTERM'])process.off(signal,onSignal);input.off('data',onInput);input.off('end',onEnd);input.pause();protocol.close();}
124
+ }
@@ -141,7 +141,8 @@ export function folderMounts(config, record) {
141
141
  const mounts = config.folders?.[record.manifest.id] || [];
142
142
  if (!Array.isArray(mounts)) throw Error('Invalid folder bindings');
143
143
  for (const mount of mounts) {
144
- keys(mount, ['service', 'source', 'target']);
144
+ keys(mount, ['service', 'source', 'target', 'writable']);
145
+ if (mount.writable !== undefined && typeof mount.writable !== 'boolean') throw Error('Folder writable must be boolean');
145
146
  const service = record.deployment.services[mount.service];
146
147
  containerPath(mount.target);
147
148
  if (!service || typeof mount.source !== 'string' || !path.isAbsolute(mount.source) || /[\0\r\n$]/.test(mount.source) ||
@@ -227,7 +228,7 @@ export async function compose(config, record, secrets={}, home) {
227
228
  for(const [name,s] of Object.entries(record.deployment.services)) {
228
229
  const mounts=[];
229
230
  for(const [volume,target] of Object.entries(s.volumes||{})) { volumes[volume]={};mounts.push({type:'volume',source:volume,target}); }
230
- for (const folder of folders.filter(f => f.service === name)) mounts.push({type:'bind',source:folder.source,target:folder.target,read_only:true,bind:{create_host_path:false}});
231
+ for (const folder of folders.filter(f => f.service === name)) mounts.push({type:'bind',source:folder.source,target:folder.target,read_only:folder.writable !== true,bind:{create_host_path:false}});
231
232
  if(s.workspace) mounts.push({type:'bind',source:config.workspace,target:config.workspace,read_only:true});
232
233
  services[name]={...(s.image?{image:s.image}:{image:`${record.project}-${name}:${record.revision.slice(7,23)}`,build:{context:record.source,target:s.buildTarget}}),
233
234
  init:true,user:s.user||'1000:1000',restart:'unless-stopped',cap_drop:['ALL'],security_opt:['no-new-privileges:true'],tmpfs:['/tmp'],volumes:mounts,
@@ -247,33 +248,55 @@ export async function compose(config, record, secrets={}, home) {
247
248
  function dockerEnv() {
248
249
  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]]));
249
250
  }
250
- export function run(argv,{capture=false,container}={}) {
251
+ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,timeoutMs=0,maxBytes=Infinity}={}) {
251
252
  return new Promise((resolve,reject)=>{
252
- const child=spawn('docker',argv,{env:dockerEnv(),stdio:capture?['ignore','pipe','pipe']:['inherit','inherit','inherit']});
253
- let stdout='',stderr='',cancelled=false,killTimer;
254
- if(capture) {child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b);}
253
+ const child=spawn('docker',argv,{env:dockerEnv(),stdio:capture?['pipe','pipe','pipe']:['inherit','inherit','inherit']});
254
+ let stdout='',stderr='',cancelled=false,killTimer,bytes=0,failure;
255
+ if(capture) {
256
+ const collect=(b,err)=>{bytes+=b.length;if(bytes>maxBytes){failure=Error('Command output limit exceeded');cancel('SIGTERM');return;}if(err)stderr+=b;else stdout+=b;};
257
+ child.stdout.on('data',b=>onStdout?onStdout(b):collect(b,false));child.stderr.on('data',b=>collect(b,true));
258
+ child.stdin.on('error',()=>{});
259
+ }
255
260
  const cancel=signal=>{cancelled=true;child.kill(signal);killTimer??=setTimeout(()=>child.kill('SIGKILL'),2000);};
256
261
  const term=()=>cancel('SIGTERM'),int=()=>cancel('SIGINT');
262
+ const timeout=timeoutMs?setTimeout(()=>{failure=Error('Command timed out');term();},timeoutMs):undefined;
263
+ signal?.addEventListener('abort',term,{once:true});if(signal?.aborted)term();
264
+ if(onStart)onStart(child);else if(capture)child.stdin.end(stdin);
257
265
  process.on('SIGTERM',term);process.on('SIGINT',int);
258
- child.once('error',error=>{clearTimeout(killTimer);process.off('SIGTERM',term);process.off('SIGINT',int);reject(error);});
259
- child.once('close',async(code,signal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
266
+ child.once('error',error=>{clearTimeout(timeout);signal?.removeEventListener('abort',term);clearTimeout(killTimer);process.off('SIGTERM',term);process.off('SIGINT',int);reject(error);});
267
+ child.once('close',async(code,childSignal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
260
268
  clearTimeout(killTimer);
269
+ clearTimeout(timeout);signal?.removeEventListener('abort',term);
261
270
  if(cancelled&&container) {
262
271
  try {
263
- const cleanup=await run(['container','rm','--force',container],{capture:true});
264
- // Compose --rm may already have removed this exact command container.
265
- if(cleanup.code!==0&&!cleanup.stderr.includes(`No such container: ${container}`))
266
- return reject(Error(`Cancelled command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`));
272
+ await removeCommandContainer(container);
267
273
  } catch(error) {return reject(error);}
268
274
  }
269
- resolve({code:cancelled?130:code??(signal?130:1),stdout,stderr});});
275
+ if(failure)return reject(failure);
276
+ resolve({code:cancelled?130:code??(childSignal?130:1),stdout,stderr});});
270
277
  });
271
278
  }
279
+ // Compose --rm can race cancellation. Confirm disappearance instead of treating
280
+ // Docker's in-progress removal as either failure or completed cleanup.
281
+ export async function removeCommandContainer(container,execute=run) {
282
+ const cleanup=await execute(['container','rm','--force',container],{capture:true});
283
+ if(cleanup.code===0||cleanup.stderr.includes(`No such container: ${container}`))return;
284
+ if(cleanup.stderr.includes(`removal of container ${container} is already in progress`)) {
285
+ for(let attempt=0;attempt<20;attempt++) {
286
+ const state=await execute(['container','inspect','--format','{{.Id}}',container],{capture:true});
287
+ if(state.code!==0&&(state.stderr.includes(`No such object: ${container}`)||state.stderr.includes(`No such container: ${container}`)))return;
288
+ if(state.code!==0)break;
289
+ await new Promise(resolve=>setTimeout(resolve,100));
290
+ }
291
+ }
292
+ throw Error(`Cancelled command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`);
293
+ }
294
+
272
295
  async function checked(args) {
273
296
  const r=await run(args,{capture:true});if(r.code) throw Error(r.stderr||r.stdout||`Docker failed (${r.code})`);return r.stdout;
274
297
  }
275
298
  const composeArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
276
- async function registry(home) {
299
+ export async function registry(home) {
277
300
  const r=await json(path.join(home,'registry.json'));
278
301
  if(r.schemaVersion!==1 || r.owner!==home || !r.plugins || !r.commands) throw Error('Corrupt registry');
279
302
  for(const [name,record] of Object.entries(r.plugins)) {
@@ -282,6 +305,21 @@ async function registry(home) {
282
305
  for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
283
306
  return r;
284
307
  }
308
+ // The lock protects admission and compose refresh, never a persistent connection.
309
+ export async function prepareCommand(home,alias,args,{revision,exclude,publish}={}) {
310
+ strings(args);
311
+ return locked(home,async()=>{
312
+ const config=await json(path.join(home,'config.json')),r=await registry(home);
313
+ const record=r.plugins[r.commands[alias]],binding=record?.deployment.commands[alias];
314
+ if(!binding||r.commands[alias]===exclude)throw Error('Unknown or unavailable registered CLI');
315
+ if(revision!==undefined&&revision!==record.revision)throw Error('Plugin changed; discover again');
316
+ await checkFolders(config,record);
317
+ const secrets=await json(path.join(home,'packages',record.manifest.id,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
318
+ await atomic(record.compose,await compose(config,record,secrets,home));
319
+ const container=`${record.project}-call-${randomUUID()}`;
320
+ return {container,plugin:record.manifest.id,revision:record.revision,argv:[...composeArgs(record),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])]};
321
+ });
322
+ }
285
323
  export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
286
324
  if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
287
325
  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');
@@ -362,8 +400,13 @@ export async function main(args) {
362
400
  const [group,action,...rest]=args;
363
401
  if(group==='status'){if(args.length!==1)throw Error('Use status without arguments');await registry(home);return emit(await (await import('../updates/status.mjs')).status(home));}
364
402
  if(group==='updates')return emit(await (await import('../updates/control.mjs')).command(home,args.slice(1)));
365
- 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','<registered CLI> ...'],scope:home});
366
- 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','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});
403
+ if(group==='tools'&&action==='serve') {
404
+ const port=rest.shift();
405
+ return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1),{publish:port,serve:true});
406
+ }
407
+ if(group==='tools'&&action==='connect')return (await import('./connection.mjs')).connect(home,rest[0],rest.slice(1));
408
+ 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> ...'],scope:home});
409
+ 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});
367
410
  if(group==='plugins'||group==='tools') {
368
411
  args=rest;args=args.filter(a=>a!=='--json');
369
412
  if(action==='available'&&group==='plugins') return emit(config.catalog);
@@ -397,6 +440,8 @@ export async function main(args) {
397
440
  if (action === 'folders') { if(args.length) throw Error('Unexpected arguments'); return emit(folderMounts(config, record)); }
398
441
  if (['folder-bind','folder-unbind'].includes(action)) {
399
442
  const service=take('--service'), target=take('--target'), source=take('--source');
443
+ const writable=action === 'folder-bind' && args.includes('--writable');
444
+ if(writable) args.splice(args.indexOf('--writable'),1);
400
445
  if(args.length || !service || !target || (action === 'folder-bind' ? !source : source !== undefined))
401
446
  throw Error('Supply --service, --target and, for folder-bind, --source');
402
447
  if(source && (!path.isAbsolute(source) || await fs.realpath(source) !== source || !(await fs.stat(source)).isDirectory()))
@@ -409,14 +454,14 @@ export async function main(args) {
409
454
  if((await checked(['ps','--filter',`label=com.docker.compose.project=${latest.project}`,'--quiet'])).trim())
410
455
  throw Error('Stop the plugin before changing folder bindings');
411
456
  const folders=(settings.folders?.[name] || []).filter(f => f.service !== service || f.target !== target);
412
- if(action === 'folder-bind') folders.push({service,source,target});
457
+ if(action === 'folder-bind') folders.push({service,source,target,...(writable ? {writable:true} : {})});
413
458
  settings.folders={...settings.folders,[name]:folders};
414
459
  await checkFolders(settings,latest);
415
460
  const secrets=await json(path.join(home,'packages',name,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
416
461
  const generated=await compose(settings,latest,secrets,home);
417
462
  await atomic(path.join(home,'config.json'),settings);
418
463
  await atomic(latest.compose,generated);
419
- return emit({ok:true,plugin:name,folders,readOnly:true,started:false});
464
+ return emit({ok:true,plugin:name,folders,readOnly:folders.every(folder=>folder.writable !== true),started:false});
420
465
  });
421
466
  }
422
467
  if (['shared-enable','shared-disable','shared-status'].includes(action)) {
@@ -0,0 +1,4 @@
1
+ export function nativeTaskBinding(home:string, environment?:NodeJS.ProcessEnv):Promise<{cwd:string;env:NodeJS.ProcessEnv}>;
2
+ import type {DeliveryContext} from '../delivery-context.mjs';
3
+ export function nativeCommands():{command:string;description:string;limitations?:string[]}[];
4
+ export function nativeTasks(home:string,args:string[],options?:{signal?:AbortSignal;command?:string;deliveryContext?:DeliveryContext}):Promise<{code:number;stdout:string;stderr:string}>;