@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +28 -0
- package/Dockerfile +1 -0
- package/README.md +79 -8
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/docker-runtime.md +20 -0
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +40 -4
- package/docs/upgrades.md +11 -1
- package/package.json +7 -2
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +5 -3
- package/src/config.ts +20 -2
- package/src/control-state.ts +256 -15
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +10 -4
- package/src/host-executor.ts +7 -1
- package/src/identity.ts +11 -3
- package/src/index.ts +149 -54
- package/src/menu.ts +26 -9
- package/src/message-history.ts +52 -0
- package/src/message.ts +48 -7
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +63 -18
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +11 -6
- package/src/scheduler.ts +17 -7
- package/src/updates/control.mjs +4 -0
- package/src/web-launcher.ts +19 -0
- package/templates/agent-guidance.md +58 -2
- package/templates/deployments.md +24 -0
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/codex-session.test.ts +8 -5
- package/test/config.test.ts +15 -0
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/executor.test.ts +56 -0
- package/test/host-executor.test.ts +28 -0
- package/test/intake-relay.test.ts +126 -5
- package/test/message-history.test.ts +127 -0
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +34 -0
- package/test/runtime-identity.test.mjs +18 -0
- 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('
|
|
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
|
|
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.
|
|
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,
|
|
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
|
|
107
|
-
const state = await control.
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
|
77
|
+
item = await sendRunVoice(store, runId!, args.voice, { replyTo: args.replyTo })
|
|
39
78
|
} else if (textContent) {
|
|
40
|
-
item = await sendRunText(store, runId
|
|
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
|
-
|
|
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
|
+
}
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -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?['
|
|
253
|
-
let stdout='',stderr='',cancelled=false,killTimer;
|
|
254
|
-
if(capture) {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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==='
|
|
366
|
-
|
|
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}>;
|