@jc_stack/ez-agents 0.1.0-beta.31 → 0.1.0-beta.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +47 -32
- package/CHANGELOG.md +34 -1
- package/CONTRIBUTING.md +29 -26
- package/README.md +36 -25
- package/docs/application-channel.md +29 -2
- package/docs/architecture/ai-selection.md +10 -1
- package/docs/architecture/authority-boundaries.md +64 -14
- package/docs/architecture/event-sources.md +12 -5
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/host-service.md +3 -0
- package/docs/plugin-contributions.md +6 -1
- package/docs/plugins.md +23 -0
- package/docs/responsive-channels.md +3 -0
- package/docs/selective-monitoring.md +55 -3
- package/docs/setup.md +2 -1
- package/docs/upgrades.md +6 -4
- package/package.json +1 -1
- package/src/application-channel.ts +40 -9
- package/src/application-origin.ts +4 -0
- package/src/approval.ts +19 -3
- package/src/codex-session.ts +2 -2
- package/src/desktop-bridge.ts +70 -5
- package/src/event-sources.ts +13 -6
- package/src/executor.ts +4 -2
- package/src/files.ts +12 -0
- package/src/host-executor-protocol.ts +2 -2
- package/src/host-executor.ts +20 -4
- package/src/index.ts +81 -34
- package/src/plugins/manager.mjs +52 -20
- package/src/runs.ts +19 -2
- package/src/task-attachments.ts +56 -0
- package/src/task-capability.ts +45 -0
- package/src/task-cli.ts +7 -4
- package/src/task-executor.ts +8 -4
- package/src/task-mcp.ts +25 -8
- package/src/tasks.ts +132 -26
- package/src/telegram-source.ts +34 -13
- package/src/updates/artifact.mjs +2 -2
- package/src/updates/control.mjs +11 -1
- package/src/updates/runtime.mjs +3 -1
- package/templates/agent-guidance.md +14 -0
- package/test/application-channel.test.ts +52 -1
- package/test/codex-session.test.ts +2 -1
- package/test/desktop-bridge.test.ts +98 -1
- package/test/event-sources.test.ts +68 -8
- package/test/executor.test.ts +2 -2
- package/test/host-executor.test.ts +32 -3
- package/test/intake-relay.test.ts +79 -2
- package/test/local-qa.test.mjs +7 -3
- package/test/plugin-manager.test.mjs +31 -0
- package/test/runs.test.ts +19 -0
- package/test/task-attachments.test.ts +77 -0
- package/test/task-capability.test.ts +48 -0
- package/test/task-native.test.ts +4 -1
- package/test/tasks.test.ts +53 -1
- package/test/telegram-source.test.ts +28 -1
- package/test/updates.test.mjs +24 -1
package/src/task-cli.ts
CHANGED
|
@@ -3,14 +3,17 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { taskCall } from './task-rpc.js'
|
|
4
4
|
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
5
5
|
source: { type: 'string' }, contact: { type: 'string' }, purpose: { type: 'string' },
|
|
6
|
-
'context-file': { type: 'string' },
|
|
6
|
+
'context-file': { type: 'string' }, 'capability-file': { type: 'string' }, hours: { type: 'string' }, id: { type: 'string' }, help: { type: 'boolean' },
|
|
7
|
+
'incoming-only': { type: 'boolean' }, 'until-revoked': { type: 'boolean' }, 'any-conversation': { type: 'boolean' },
|
|
7
8
|
} })
|
|
8
|
-
if (values.help) { console.log('ezenciel-agents-task propose --source NAME --contact EXACT_ID --purpose TEXT --context-file FILE --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
|
|
9
|
+
if (values.help) { console.log('ezenciel-agents-task propose --source NAME (--contact EXACT_ID | --any-conversation) --purpose TEXT --context-file FILE [--capability-file FILE] --hours 24 [--incoming-only [--until-revoked]] | list | revoke --id TASK_ID'); console.log(await readFile(new URL('../docs/selective-monitoring.md', import.meta.url), 'utf8')) }
|
|
9
10
|
else {
|
|
10
11
|
if (!process.env.EZ_CONTROL_DIR || !process.env.EZ_RUN_ID) throw new Error('Run from the current owner turn')
|
|
12
|
+
const anyConversation = values['any-conversation'] === true
|
|
11
13
|
console.log(JSON.stringify(await taskCall(process.env.EZ_CONTROL_DIR, process.env.EZ_RUN_ID, 'owner', positionals[0], {
|
|
12
|
-
sourceId: values.source, conversationId: values.contact, purpose: values.purpose,
|
|
14
|
+
sourceId: values.source, conversationId: anyConversation ? '*' : values.contact, purpose: values.purpose,
|
|
13
15
|
context: values['context-file'] ? await readFile(values['context-file'], 'utf8') : undefined,
|
|
14
|
-
|
|
16
|
+
capabilities: values['capability-file'] ? JSON.parse(await readFile(values['capability-file'], 'utf8')) : undefined,
|
|
17
|
+
anyConversation, untilRevoked: anyConversation || values['until-revoked'], waitForIncoming: anyConversation || values['incoming-only'], hours: Number(values.hours || 24), taskId: values.id,
|
|
15
18
|
})))
|
|
16
19
|
}
|
package/src/task-executor.ts
CHANGED
|
@@ -32,14 +32,18 @@ export function taskArguments(directory: string, broker: string[], prompt: strin
|
|
|
32
32
|
'-c', 'default_permissions="ez-task"',
|
|
33
33
|
'-c', `permissions.ez-task.filesystem={":root"="deny",":minimal"="read",${JSON.stringify(directory)}="write"}`,
|
|
34
34
|
'-c', 'permissions.ez-task.network.enabled=false',
|
|
35
|
-
'
|
|
35
|
+
// The trusted broker needs the host's CLI configuration; the model keeps its isolated HOME.
|
|
36
|
+
'-c', `mcp_servers.ez={command=${JSON.stringify(broker[0])},args=${JSON.stringify(broker.slice(1))},env={HOME=${JSON.stringify(homedir())}},required=true,enabled_tools=${JSON.stringify(toolNames)}}`,
|
|
36
37
|
...toolNames.flatMap(name => ['-c', `mcp_servers.ez.tools.${name}.approval_mode="approve"`]),
|
|
37
38
|
'-'] // Literal input travels on stdin, including slash commands and leading options.
|
|
38
39
|
}
|
|
39
40
|
export async function startTaskExecutor(options: ExecutorOptions) {
|
|
40
41
|
const run = await new RunStore(options.controlDir).get(options.runId)
|
|
41
42
|
if (!run || run.status !== 'running') throw new Error('No active task run')
|
|
42
|
-
await new Tasks(options.controlDir).authorize(run, false)
|
|
43
|
+
const task = await new Tasks(options.controlDir).authorize(run, false)
|
|
44
|
+
const capabilityNames = (task.capabilities ?? []).map(item => `capability_${item.id}`)
|
|
45
|
+
const toolNames = [...(task.anyConversation ? ['context','send'] : ['context','send','note','report','complete']),...capabilityNames]
|
|
46
|
+
if (capabilityNames.length && !options.toolsHome) throw new Error('Channel capabilities require an installed tool registry')
|
|
43
47
|
const environment = executorEnvironment()
|
|
44
48
|
const version = await promisify(execFile)('codex', ['--version'], { env: environment })
|
|
45
49
|
if (version.stdout.trim() !== `codex-cli ${TASK_CODEX_VERSION}`) throw new Error(`Restricted tasks require audited Codex ${TASK_CODEX_VERSION}`)
|
|
@@ -51,9 +55,9 @@ export async function startTaskExecutor(options: ExecutorOptions) {
|
|
|
51
55
|
await writeFile(join(temporary, 'models.json'), JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))), { mode: 0o600 })
|
|
52
56
|
await symlink(join(homedir(), '.codex', 'auth.json'), join(home, 'auth.json'))
|
|
53
57
|
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
|
|
54
|
-
fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId]
|
|
58
|
+
fileURLToPath(new URL('./task-mcp.ts', import.meta.url)), options.controlDir, options.runId, options.toolsHome ?? '', JSON.stringify(task.capabilities ?? [])]
|
|
55
59
|
const prompt = JSON.stringify({event: run.external ? 'correspondence_received' : 'task_activated', taskId: run.taskId})
|
|
56
|
-
const child = spawn('codex', taskArguments(directory, broker, prompt,
|
|
60
|
+
const child = spawn('codex', taskArguments(directory, broker, prompt, toolNames, options), {
|
|
57
61
|
cwd: directory, env: { ...environment, HOME: home, CODEX_HOME: home }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32',
|
|
58
62
|
})
|
|
59
63
|
await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
|
package/src/task-mcp.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline'
|
|
2
2
|
import { taskCall } from './task-rpc.js'
|
|
3
|
-
|
|
3
|
+
import { runTaskCapability } from './task-capability.js'
|
|
4
|
+
import type { TaskCapability } from './tasks.js'
|
|
5
|
+
const [controlDir, runId, toolsHome, capabilityJson='[]'] = process.argv.slice(2)
|
|
6
|
+
const capabilities = JSON.parse(capabilityJson) as TaskCapability[]
|
|
4
7
|
const descriptions: Record<string, string> = {
|
|
5
8
|
context: 'Read the owner-approved purpose and shareable context, task notes, receipts, and untrusted correspondence.',
|
|
6
|
-
send: 'Send text
|
|
9
|
+
send: 'Send text and optionally an attachmentId returned by an approved file capability to this conversation. Reuse the same key for the same message. Uncertain means do not retry with a new key.',
|
|
7
10
|
note: 'Save a task-scoped note. No owner files or memory are accessible.',
|
|
8
11
|
report: 'Report task evidence or a blocker to the owner. This is a report, never an owner instruction.',
|
|
9
12
|
complete: 'Report the result and close a finite task, stopping further messages and replies. Not available for incoming-only watches; save a note and end the run instead.',
|
|
13
|
+
...Object.fromEntries(capabilities.map(item => [`capability_${item.id}`,item.description])),
|
|
10
14
|
}
|
|
11
|
-
const tools = Object.entries(descriptions).map(([name, description]) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} })
|
|
15
|
+
const tools = Object.entries(descriptions).map(([name, description]) => {
|
|
16
|
+
const capability = name.startsWith('capability_')
|
|
17
|
+
return { name, description, inputSchema: {
|
|
18
|
+
type: 'object', properties: name === 'context' ? {} : capability ? {input:{type:'string',maxLength:1000}} : { text: { type: 'string', maxLength: 4096 }, ...(name === 'send' ? { key: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,80}$' }, attachmentId:{type:'string',pattern:'^[a-f0-9-]{36}$'} } : {}) },
|
|
19
|
+
required: name === 'context' ? [] : capability ? ['input'] : name === 'send' ? ['text', 'key'] : ['text'], additionalProperties: false,
|
|
20
|
+
} }
|
|
21
|
+
})
|
|
15
22
|
for await (const line of createInterface({ input: process.stdin })) {
|
|
16
23
|
let request: any
|
|
17
24
|
try {
|
|
@@ -25,8 +32,18 @@ for await (const line of createInterface({ input: process.stdin })) {
|
|
|
25
32
|
const name = request.params?.name
|
|
26
33
|
if (!Object.hasOwn(descriptions, name)) throw new Error('Unknown task tool')
|
|
27
34
|
const args = request.params.arguments ?? {}
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const capabilityId = typeof name === 'string' && name.startsWith('capability_') ? name.slice(11) : undefined
|
|
36
|
+
if (Object.keys(args).some(key => capabilityId ? key !== 'input' : !['text', ...(name === 'send' ? ['key','attachmentId'] : [])].includes(key))) throw new Error('Unexpected tool argument')
|
|
37
|
+
try {
|
|
38
|
+
let data: unknown
|
|
39
|
+
if (capabilityId) {
|
|
40
|
+
const begun = await taskCall(controlDir,runId,'worker','capability_begin',{id:capabilityId,input:args.input}) as {capability:TaskCapability;lease:string}
|
|
41
|
+
const result = await runTaskCapability(toolsHome || undefined,begun.capability,args.input,{controlDir,runId,lease:begun.lease})
|
|
42
|
+
await taskCall(controlDir,runId,'worker','capability_result',{id:capabilityId,input:args.input,lease:begun.lease,...('attachment' in result?{attachment:result.attachment}:{})})
|
|
43
|
+
data = result.attachment ? {attachmentId:result.attachment.id,filename:result.attachment.filename,bytes:result.attachment.bytes} : result
|
|
44
|
+
} else data = await taskCall(controlDir, runId, 'worker', name, args)
|
|
45
|
+
result = { content: [{ type: 'text', text: JSON.stringify(data) }] }
|
|
46
|
+
}
|
|
30
47
|
catch (error) { result = { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Task tool failed' }] } }
|
|
31
48
|
} else throw new Error('Unsupported MCP method')
|
|
32
49
|
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n')
|
package/src/tasks.ts
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { randomUUID, createHash } from 'node:crypto'
|
|
4
|
-
import { ControlStore, type Owner } from './control-state.js'
|
|
4
|
+
import { ControlStore, sameOwner, ownerId, ownerEpoch, type Owner } from './control-state.js'
|
|
5
5
|
import { ApprovalStore } from './approval.js'
|
|
6
6
|
import { EventSources, sourceCall, type SourceEvent } from './event-sources.js'
|
|
7
7
|
import { RunStore, type RunRecord } from './runs.js'
|
|
8
8
|
import { requireOwnerExecution } from './execution-authority.js'
|
|
9
9
|
import { ownsRun } from './identity.js'
|
|
10
|
+
import {readTaskAttachment,type TaskAttachment} from './task-attachments.js'
|
|
10
11
|
|
|
12
|
+
export type TaskCapability = { id: string; description: string; command: string; args: string[]; output?:'file' }
|
|
13
|
+
type PublicBudget = {windowStartedAt:number;runs:number;responses:number;totalRuns:number;totalResponses:number}
|
|
14
|
+
const validPublicBudget = (value: unknown): value is PublicBudget => {
|
|
15
|
+
if (!value || typeof value !== 'object') return false
|
|
16
|
+
const budget = value as Record<string, unknown>
|
|
17
|
+
return Object.keys(budget).length === 5 && ['windowStartedAt','runs','responses','totalRuns','totalResponses']
|
|
18
|
+
.every(key => Number.isSafeInteger(budget[key]) && Number(budget[key]) >= 0)
|
|
19
|
+
}
|
|
11
20
|
export type Task = {
|
|
12
|
-
version: 1 | 2 | 3; waitForIncoming?: true; untilRevoked?: true; unwatchPending?: true; id: string; runId: string; owner: Owner
|
|
21
|
+
version: 1 | 2 | 3 | 4; waitForIncoming?: true; untilRevoked?: true; anyConversation?: true; capabilities?: TaskCapability[]; unwatchPending?: true; id: string; runId: string; owner: Owner
|
|
13
22
|
sourceId: string; bindingId: string; accountId: string; conversationId: string
|
|
14
23
|
purpose: string; context: string; createdAt: number; expiresAt: number
|
|
15
24
|
state: 'pending' | 'active' | 'revoked' | 'completed'
|
|
16
|
-
notes: string[]; operations: Record<string, { text: string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
|
|
25
|
+
notes: string[]; operations: Record<string, { text: string; attachmentId?:string; state: 'uncertain' | 'accepted'; receipt?: unknown }>
|
|
26
|
+
capabilityOperations?: Record<string, { capabilityId: string; inputHash: string; lease: string; attachment?:TaskAttachment; state: 'authorized' | 'completed' }>
|
|
27
|
+
publicBudget?: PublicBudget
|
|
17
28
|
}
|
|
18
29
|
const idOK = (v: unknown): v is string => typeof v === 'string' && /^task_[a-f0-9]{32}$/.test(v)
|
|
19
30
|
const bounded = (v: unknown, max: number): v is string => typeof v === 'string' && v.trim().length > 0 && v.length <= max
|
|
31
|
+
const capability = (value: unknown): value is TaskCapability => {
|
|
32
|
+
const item = value as TaskCapability | undefined
|
|
33
|
+
return !!item && typeof item === 'object' && Object.keys(item).every(key => ['id','description','command','args','output'].includes(key)) &&
|
|
34
|
+
(item.output===undefined||item.output==='file') &&
|
|
35
|
+
typeof item.id === 'string' && /^[a-z][a-z0-9_]{0,31}$/.test(item.id) && bounded(item.description,200) && !/[\x00-\x1f\x7f]/.test(item.description) &&
|
|
36
|
+
typeof item.command === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(item.command) && Array.isArray(item.args) && item.args.length >= 2 && item.args.length <= 20 &&
|
|
37
|
+
item.args.every(arg => typeof arg === 'string' && arg.length <= 200 && !/[\x00-\x1f\x7f]/.test(arg)) && item.args.at(-2) === '--' && item.args.at(-1) === '{input}' &&
|
|
38
|
+
item.args.filter(arg => arg === '{input}').length === 1
|
|
39
|
+
}
|
|
20
40
|
export async function atomicTaskFile(file: string, value: unknown) {
|
|
21
41
|
const temporary = `${file}.${randomUUID()}.tmp`
|
|
22
42
|
await writeFile(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' })
|
|
@@ -33,11 +53,14 @@ export class Tasks {
|
|
|
33
53
|
if (!idOK(id)) throw new Error('Invalid task ID')
|
|
34
54
|
try {
|
|
35
55
|
const task: Task = JSON.parse(await readFile(join(this.directory, `${id}.json`), 'utf8'))
|
|
36
|
-
if (!((task.version === 1 && task.waitForIncoming === undefined && task.untilRevoked === undefined) || (task.version === 2 && task.waitForIncoming === true && task.untilRevoked === undefined) || (task.version === 3 && task.waitForIncoming === true && task.untilRevoked === true && task.
|
|
56
|
+
if (!((task.version === 1 && task.waitForIncoming === undefined && task.untilRevoked === undefined && task.anyConversation === undefined) || (task.version === 2 && task.waitForIncoming === true && task.untilRevoked === undefined && task.anyConversation === undefined) || (task.version === 3 && task.waitForIncoming === true && task.untilRevoked === true && task.anyConversation === undefined && task.expiresAt === 8640000000000000) ||
|
|
57
|
+
(task.version === 4 && task.waitForIncoming === true && task.untilRevoked === true && task.anyConversation === true && task.conversationId === '*' && task.expiresAt === 8640000000000000)) ||
|
|
58
|
+
(task.capabilities !== undefined && (!Array.isArray(task.capabilities) || task.capabilities.length < 1 || task.capabilities.length > 8 || new Set(task.capabilities.map(item => item.id)).size !== task.capabilities.length || task.capabilities.some(item => !capability(item)))) || task.id !== id || !bounded(task.sourceId, 100) || !bounded(task.bindingId, 100) ||
|
|
37
59
|
!bounded(task.accountId, 200) || !bounded(task.conversationId, 200) || !bounded(task.purpose, 1000) ||
|
|
38
60
|
!bounded(task.context, 6000) || !Number.isFinite(task.createdAt) || !Number.isFinite(task.expiresAt) ||
|
|
39
61
|
!['pending', 'active', 'revoked', 'completed'].includes(task.state) || !Array.isArray(task.notes) ||
|
|
40
|
-
!task.operations || typeof task.operations !== 'object' ||
|
|
62
|
+
!task.operations || typeof task.operations !== 'object' || (task.capabilityOperations !== undefined && (!task.capabilityOperations || typeof task.capabilityOperations !== 'object')) ||
|
|
63
|
+
(task.anyConversation && !validPublicBudget(task.publicBudget)) || !task.owner) throw new Error('Invalid task record')
|
|
41
64
|
return task
|
|
42
65
|
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error }
|
|
43
66
|
}
|
|
@@ -55,8 +78,7 @@ export class Tasks {
|
|
|
55
78
|
}
|
|
56
79
|
private async source(task: Task) {
|
|
57
80
|
const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
|
|
58
|
-
if (!owner ||
|
|
59
|
-
throw new Error('Task owner is no longer paired')
|
|
81
|
+
if (!owner || !sameOwner(task.owner, owner)) throw new Error('Task owner is no longer paired')
|
|
60
82
|
const source = (await new EventSources(this.controlDir).available(owner)).find(s => s.id === task.sourceId && s.bindingId === task.bindingId)
|
|
61
83
|
if (!source) throw new Error('Task source was removed or replaced')
|
|
62
84
|
const head = await sourceCall(source.socketPath, 'events-head')
|
|
@@ -66,27 +88,43 @@ export class Tasks {
|
|
|
66
88
|
async authorize(run: RunRecord, checkProvider = true): Promise<Task> {
|
|
67
89
|
const task = run.taskId ? await this.get(run.taskId) : null
|
|
68
90
|
if (!task || (task.waitForIncoming && !run.external) || task.state !== 'active' || task.expiresAt <= Date.now() || run.version !== 2 ||
|
|
69
|
-
|
|
91
|
+
!ownsRun(task.owner, run))
|
|
70
92
|
throw new Error('Task is inactive or expired')
|
|
71
93
|
const owner = (await new ControlStore(this.controlDir, 900000).status()).owner
|
|
72
94
|
const approval = await new ApprovalStore(this.controlDir).getDecision(task.id)
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
const approvedByOwner = approval?.version === 2
|
|
96
|
+
? approval.decidedOwnerId === ownerId(task.owner) && approval.decidedOwnerEpoch === ownerEpoch(task.owner)
|
|
97
|
+
: !!owner && ownsRun(owner,{telegramUserId:approval?.decidedBy!,chatId:task.owner.telegramChatId})
|
|
98
|
+
if (!sameOwner(task.owner, owner) || approval?.decision !== 'approved' || !approvedByOwner || approval.runId !== task.runId || approval.prompt !== this.prompt(task))
|
|
75
99
|
throw new Error('Task approval is no longer valid')
|
|
76
100
|
if (checkProvider) await this.source(task)
|
|
77
101
|
if (run.external && checkProvider) {
|
|
78
102
|
if (run.external.sourceId !== task.sourceId || run.external.bindingId !== task.bindingId) throw new Error('Task origin mismatch')
|
|
79
103
|
const events = await new EventSources(this.controlDir).check(run.external, task.owner)
|
|
80
|
-
if (events.length !== run.external.eventIds.length || events.
|
|
104
|
+
if (events.length !== run.external.eventIds.length || new Set(events.map(event => event.conversationId)).size !== 1 || events.some(e =>
|
|
105
|
+
(!task.anyConversation && e.conversationId !== task.conversationId) || e.receivedAt < task.createdAt))
|
|
81
106
|
throw new Error('Task correspondence no longer matches')
|
|
82
107
|
}
|
|
83
108
|
return task
|
|
84
109
|
}
|
|
85
110
|
async match(sourceId: string, bindingId: string, events: SourceEvent[]) {
|
|
111
|
+
if (!events.length || new Set(events.map(event => event.conversationId)).size !== 1) return
|
|
86
112
|
const matches = (await this.list()).filter(t => t.state === 'active' && t.expiresAt > Date.now() &&
|
|
87
|
-
t.sourceId === sourceId && t.bindingId === bindingId && events.every(e =>
|
|
113
|
+
t.sourceId === sourceId && t.bindingId === bindingId && events.every(e =>
|
|
114
|
+
(t.anyConversation || e.conversationId === t.conversationId) && e.receivedAt >= t.createdAt))
|
|
88
115
|
return matches.length === 1 ? matches[0] : undefined
|
|
89
116
|
}
|
|
117
|
+
async admitPublic(id:string):Promise<'admitted'|'defer'|'revoked'> {
|
|
118
|
+
return this.serial(async()=>{
|
|
119
|
+
const task=await this.get(id)
|
|
120
|
+
if(!task?.anyConversation||task.state!=='active')return 'revoked'
|
|
121
|
+
const now=Date.now(),budget=task.publicBudget!
|
|
122
|
+
if(now-budget.windowStartedAt>=3600000){budget.windowStartedAt=now;budget.runs=0;budget.responses=0}
|
|
123
|
+
if(budget.totalRuns>=1000||budget.totalResponses>=1000){task.state='revoked';task.unwatchPending=true;await this.save(task);await this.unwatch(task);return 'revoked'}
|
|
124
|
+
if(budget.runs>=60)return 'defer'
|
|
125
|
+
budget.runs++;budget.totalRuns++;await this.save(task);return 'admitted'
|
|
126
|
+
})
|
|
127
|
+
}
|
|
90
128
|
private async unwatch(task: Task) {
|
|
91
129
|
const source=await this.source(task)
|
|
92
130
|
await sourceCall(source.socketPath,'task-unwatch',{accountId:task.accountId,conversationId:task.conversationId})
|
|
@@ -116,7 +154,9 @@ export class Tasks {
|
|
|
116
154
|
})
|
|
117
155
|
}
|
|
118
156
|
private prompt(task: Task) {
|
|
119
|
-
|
|
157
|
+
const audience = task.anyConversation ? `Any human conversation received by source ${task.sourceId}` : `Contact: ${task.conversationId}`
|
|
158
|
+
const capabilities = task.capabilities ? `\nApproved capabilities (their output may be disclosed):\n${task.capabilities.map(item => `- ${item.id}: ${item.description}; ez ${item.command} ${item.args.join(' ')}`).join('\n')}` : ''
|
|
159
|
+
return `Allow this messaging task?${task.waitForIncoming ? '\nWait for incoming messages; do not initiate contact.' : ''}\nSource: ${task.sourceId}\nAccount: ${task.accountId}\nAudience: ${audience}\nPurpose: ${task.purpose}\nShared context (all may be disclosed to this audience):\n${task.context}${capabilities}\n${task.anyConversation ? 'Budget: 60 conversations and 60 responses per hour; 1000 total, then owner review.' : ''}\n${task.untilRevoked ? 'Enabled until owner revocation.' : `Expires: ${new Date(task.expiresAt).toISOString()}`}\n${task.capabilities?.some(item=>item.output==='file') ? 'Text and files returned by the approved file capabilities only. No payments, owner memory, unlisted capabilities, or settings changes.' : 'Text messages only. No payments, files, owner memory, unlisted capabilities, or settings changes.'}`
|
|
120
160
|
}
|
|
121
161
|
async ownerCall(runId: string, command: string, args: Record<string, unknown>) {
|
|
122
162
|
return this.serial(async () => {
|
|
@@ -130,12 +170,28 @@ export class Tasks {
|
|
|
130
170
|
task.state = 'revoked'
|
|
131
171
|
if(task.untilRevoked) task.unwatchPending=true
|
|
132
172
|
await this.save(task)
|
|
173
|
+
const runStore = new RunStore(this.controlDir)
|
|
174
|
+
for (const candidate of await runStore.list()) if(candidate.taskId===task.id && candidate.status==='queued') {
|
|
175
|
+
await runStore.patch(candidate.id,{status:'cancelled',endedAt:new Date().toISOString()})
|
|
176
|
+
if(candidate.external)try {
|
|
177
|
+
await new EventSources(this.controlDir).release(candidate.external,task.owner)
|
|
178
|
+
await runStore.patch(candidate.id,{externalReleased:true})
|
|
179
|
+
} catch { /* The relay retries unreleased terminal events. */ }
|
|
180
|
+
}
|
|
181
|
+
await runStore.pruneTaskHistory(task.id)
|
|
133
182
|
if(task.unwatchPending) await this.unwatch(task)
|
|
134
183
|
return { id: task.id, state: task.state }
|
|
135
184
|
}
|
|
136
185
|
if (command !== 'propose') throw new Error('Unknown owner task command')
|
|
137
186
|
if (args.waitForIncoming !== undefined && typeof args.waitForIncoming !== 'boolean') throw new Error('Invalid incoming-only option')
|
|
138
187
|
if (args.untilRevoked !== undefined && (typeof args.untilRevoked !== 'boolean' || (args.untilRevoked && args.waitForIncoming !== true))) throw new Error('Persistent permission requires incoming-only mode')
|
|
188
|
+
if (args.anyConversation !== undefined && typeof args.anyConversation !== 'boolean') throw new Error('Invalid any-conversation option')
|
|
189
|
+
const capabilities = args.capabilities === undefined ? undefined : args.capabilities
|
|
190
|
+
if (capabilities !== undefined && (!Array.isArray(capabilities) || capabilities.length < 1 || capabilities.length > 8 || new Set(capabilities.map((item: any) => item?.id)).size !== capabilities.length || capabilities.some(item => !capability(item))))
|
|
191
|
+
throw new Error('Supply one to eight valid channel capabilities')
|
|
192
|
+
if (capabilities && args.waitForIncoming !== true) throw new Error('Channel capabilities require incoming-only mode')
|
|
193
|
+
if (args.anyConversation && (args.conversationId !== '*' || args.waitForIncoming !== true || args.untilRevoked !== true))
|
|
194
|
+
throw new Error('Any-conversation access requires contact *, incoming-only, and until-revoked')
|
|
139
195
|
if (!bounded(args.sourceId, 100) || !bounded(args.conversationId, 200) || !bounded(args.purpose, 1000) || !bounded(args.context, 6000) ||
|
|
140
196
|
typeof args.hours !== 'number' || !Number.isFinite(args.hours) || args.hours <= 0 || args.hours > 72) throw new Error('Invalid task proposal (maximum 72 hours)')
|
|
141
197
|
const owner = (await new ControlStore(this.controlDir, 900000).status()).owner!
|
|
@@ -143,10 +199,12 @@ export class Tasks {
|
|
|
143
199
|
if (!source) throw new Error('Unknown source')
|
|
144
200
|
const head = await sourceCall(source.socketPath, 'events-head')
|
|
145
201
|
if (args.untilRevoked && head.persistentWatch !== true) throw new Error('Source needs persistent-watch support before enabling an ongoing conversation')
|
|
202
|
+
if (args.anyConversation && head.wildcardWatch !== true) throw new Error('This channel source does not support any-conversation access')
|
|
146
203
|
if (head.taskProtocol !== 'message-v1' || !bounded(head.accountId, 200)) throw new Error('Source does not support task messaging')
|
|
147
|
-
if ((await this.list()).some(t => ((['active', 'pending'].includes(t.state) && t.expiresAt > Date.now()) || t.unwatchPending) && t.sourceId === source.id &&
|
|
204
|
+
if ((await this.list()).some(t => ((['active', 'pending'].includes(t.state) && t.expiresAt > Date.now()) || t.unwatchPending) && t.sourceId === source.id &&
|
|
205
|
+
(t.conversationId === args.conversationId || t.anyConversation || args.anyConversation)))
|
|
148
206
|
throw new Error('This contact already has a task; complete or revoke it first')
|
|
149
|
-
const task: Task = { version: args.untilRevoked ? 3 : args.waitForIncoming ? 2 : 1, ...(args.untilRevoked ? {untilRevoked:true as const} : {}), ...(args.waitForIncoming ? { waitForIncoming: true as const } : {}), id: `task_${randomUUID().replaceAll('-', '')}`, runId, owner, sourceId: source.id,
|
|
207
|
+
const task: Task = { version: args.anyConversation ? 4 : args.untilRevoked ? 3 : args.waitForIncoming ? 2 : 1, ...(args.untilRevoked ? {untilRevoked:true as const} : {}), ...(args.waitForIncoming ? { waitForIncoming: true as const } : {}), ...(args.anyConversation ? {anyConversation:true as const,publicBudget:{windowStartedAt:Date.now(),runs:0,responses:0,totalRuns:0,totalResponses:0}} : {}), ...(capabilities ? {capabilities} : {}), id: `task_${randomUUID().replaceAll('-', '')}`, runId, owner, sourceId: source.id,
|
|
150
208
|
bindingId: source.bindingId, accountId: head.accountId, conversationId: args.conversationId, purpose: args.purpose,
|
|
151
209
|
context: args.context, createdAt: Date.now(), expiresAt: args.untilRevoked ? 8640000000000000 : Date.now() + args.hours * 3600000, state: 'pending', notes: [], operations: {} }
|
|
152
210
|
if (this.prompt(task).length > 3500) throw new Error('Proposal is too long for owner review; shorten the shared context')
|
|
@@ -162,46 +220,94 @@ export class Tasks {
|
|
|
162
220
|
const run = await new RunStore(this.controlDir).get(runId)
|
|
163
221
|
if (!run || run.status !== 'running') throw new Error('No active task run')
|
|
164
222
|
const task = await this.authorize(run)
|
|
223
|
+
const incoming = run.external ? await new EventSources(this.controlDir).check(run.external, task.owner) : []
|
|
224
|
+
const contact = task.anyConversation ? incoming[0]?.conversationId : task.conversationId
|
|
225
|
+
if (!contact || incoming.some(event => event.conversationId !== contact)) throw new Error('Task correspondence changed')
|
|
165
226
|
if (command === 'context') {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return { purpose: task.purpose, context: task.context, contact: task.conversationId,
|
|
169
|
-
waitForIncoming: task.waitForIncoming === true,
|
|
227
|
+
return { purpose: task.purpose, context: task.context, contact,
|
|
228
|
+
waitForIncoming: task.waitForIncoming === true, capabilities: task.capabilities?.map(({id,description}) => ({id,description})),
|
|
170
229
|
expiresAt: task.untilRevoked ? null : task.expiresAt, notes: task.notes, operations: task.untilRevoked ? Object.fromEntries(Object.entries(task.operations).filter(([key])=>key.startsWith(`${run.id}_`))) : task.operations,
|
|
171
230
|
incoming }
|
|
172
231
|
}
|
|
232
|
+
if (command === 'capability_begin' || command === 'capability_result') {
|
|
233
|
+
const selected = task.capabilities?.find(item => item.id === args.id)
|
|
234
|
+
if (!selected || !bounded(args.input, 1000)) throw new Error('Channel capability is not authorized or has invalid input')
|
|
235
|
+
const inputHash = createHash('sha256').update(args.input).digest('hex')
|
|
236
|
+
const key = `${run.id}_${selected.id}_${inputHash}`
|
|
237
|
+
const prior = task.capabilityOperations?.[key]
|
|
238
|
+
if (command === 'capability_result') {
|
|
239
|
+
if (!prior || prior.lease !== args.lease) throw new Error('Channel capability lease is invalid')
|
|
240
|
+
if(selected.output==='file') {
|
|
241
|
+
const file=args.attachment as TaskAttachment
|
|
242
|
+
if(file?.runId!==run.id||file?.id!==prior.lease)throw Error('Attachment lease mismatch')
|
|
243
|
+
await readTaskAttachment(this.controlDir,file)
|
|
244
|
+
prior.attachment=file
|
|
245
|
+
}else if(args.attachment!==undefined)throw Error('Text capability cannot register an attachment')
|
|
246
|
+
prior.state = 'completed'; await this.save(task); return {accepted:true}
|
|
247
|
+
}
|
|
248
|
+
if (prior) return {capability:selected,lease:prior.lease}
|
|
249
|
+
const active=(await new RunStore(this.controlDir).list()).filter(item=>item.taskId===task.id&&['queued','running'].includes(item.status)).map(item=>`${item.id}_`)
|
|
250
|
+
task.capabilityOperations = Object.fromEntries(Object.entries(task.capabilityOperations ?? {}).filter(([item]) => active.some(prefix=>item.startsWith(prefix))))
|
|
251
|
+
if (Object.keys(task.capabilityOperations ?? {}).filter(item => item.startsWith(`${run.id}_`)).length >= 8) throw new Error('Channel capability limit reached')
|
|
252
|
+
const operation = {capabilityId:selected.id,inputHash,lease:randomUUID(),state:'authorized' as const}
|
|
253
|
+
task.capabilityOperations = {...task.capabilityOperations,[key]:operation}
|
|
254
|
+
await this.save(task)
|
|
255
|
+
return {capability:selected,lease:operation.lease}
|
|
256
|
+
}
|
|
173
257
|
if (!bounded(args.text, 4096)) throw new Error('Supply text (maximum 4096 characters)')
|
|
174
258
|
if (command === 'note') {
|
|
259
|
+
if (task.anyConversation) throw new Error('Any-conversation tasks do not retain cross-conversation notes')
|
|
175
260
|
if (task.untilRevoked) while (task.notes.join('').length + args.text.length > 16000) task.notes.shift()
|
|
176
261
|
if (task.notes.join('').length + args.text.length > 16000) throw new Error('Task notes are full')
|
|
177
262
|
task.notes.push(args.text); await this.save(task); return { saved: true }
|
|
178
263
|
}
|
|
179
264
|
if (command === 'complete' && task.waitForIncoming) throw new Error('This incoming-only watch stays active until expiry or owner revocation. Save a note and end this run; do not close the watch after replying.')
|
|
180
265
|
if (command === 'report' || command === 'complete') {
|
|
181
|
-
const item = await new RunStore(this.controlDir).enqueueMessage(run.id, `Task ${task.id} (${
|
|
266
|
+
const item = await new RunStore(this.controlDir).enqueueMessage(run.id, `Task ${task.id} (${contact}) reports:\n${args.text}`)
|
|
182
267
|
if (command === 'complete') { task.state = 'completed'; await this.save(task) }
|
|
183
268
|
return { queued: item.id }
|
|
184
269
|
}
|
|
185
270
|
if (command !== 'send' || typeof args.key !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(args.key)) throw new Error('Invalid task send')
|
|
271
|
+
let attachment:TaskAttachment|undefined
|
|
272
|
+
if(args.attachmentId!==undefined) {
|
|
273
|
+
const operation=Object.entries(task.capabilityOperations??{}).find(([key,value])=>key.startsWith(`${run.id}_`)&&value.lease===args.attachmentId&&value.state==='completed')?.[1]
|
|
274
|
+
attachment=operation?.attachment
|
|
275
|
+
if(!attachment||attachment.runId!==run.id)throw Error('Attachment is not authorized for this run')
|
|
276
|
+
}
|
|
186
277
|
const key = task.untilRevoked ? `${run.id}_${args.key}` : args.key
|
|
187
278
|
const providerKey = `${task.id}_${task.untilRevoked ? createHash('sha256').update(key).digest('hex') : key}`
|
|
188
279
|
const prior = Object.hasOwn(task.operations, key) ? task.operations[key] : undefined
|
|
189
280
|
if (prior) {
|
|
190
|
-
if (prior.text !== args.text) throw new Error('Message key already used for different text')
|
|
281
|
+
if (prior.text !== args.text || prior.attachmentId!==args.attachmentId) throw new Error('Message key already used for different text or attachment')
|
|
191
282
|
return prior // Uncertain sends are never blindly retried.
|
|
192
283
|
}
|
|
284
|
+
const source = await this.source(task)
|
|
285
|
+
if(attachment) {
|
|
286
|
+
const head=await sourceCall(source.socketPath,'events-head')
|
|
287
|
+
if(head.taskAttachments!==true)throw Error('This channel does not support task attachments')
|
|
288
|
+
if(Number.isSafeInteger(head.attachmentCaptionLimit)&&args.text.length>head.attachmentCaptionLimit)throw Error(`Attachment caption exceeds channel limit of ${head.attachmentCaptionLimit}`)
|
|
289
|
+
await readTaskAttachment(this.controlDir,attachment)
|
|
290
|
+
}
|
|
291
|
+
if(task.anyConversation){const budget=task.publicBudget!,now=Date.now();if(now-budget.windowStartedAt>=3600000){budget.windowStartedAt=now;budget.runs=0;budget.responses=0}
|
|
292
|
+
if(budget.responses>=60||budget.totalResponses>=1000)throw new Error('Public response budget reached; owner review is required')
|
|
293
|
+
budget.responses++;budget.totalResponses++;await this.save(task)}
|
|
294
|
+
if(task.untilRevoked) {
|
|
295
|
+
const active=(await new RunStore(this.controlDir).list()).filter(item=>item.taskId===task.id&&['queued','running'].includes(item.status)).map(item=>`${item.id}_`)
|
|
296
|
+
task.operations=Object.fromEntries(Object.entries(task.operations).filter(([item,value])=>active.some(prefix=>item.startsWith(prefix))||value.state==='uncertain'))
|
|
297
|
+
}
|
|
193
298
|
if (Object.keys(task.operations).filter(k=>!task.untilRevoked || k.startsWith(`${run.id}_`)).length >= 30) throw new Error('Task message limit reached; report to the owner')
|
|
194
|
-
|
|
299
|
+
const message={text:args.text,...(attachment?{attachmentId:attachment.id}:{})}
|
|
300
|
+
task.operations = { ...task.operations, [key]: { ...message, state: 'uncertain' } }
|
|
195
301
|
await this.save(task)
|
|
196
|
-
const source = await this.source(task)
|
|
197
302
|
try {
|
|
198
303
|
if (task.expiresAt <= Date.now()) throw new Error('Task expired before dispatch')
|
|
199
304
|
const receipt = await sourceCall(source.socketPath, 'task-send', {
|
|
200
|
-
accountId: task.accountId, conversationId:
|
|
305
|
+
accountId: task.accountId, conversationId: contact, text: args.text, key: providerKey,
|
|
306
|
+
...(attachment?{attachment}:{}),
|
|
201
307
|
})
|
|
202
|
-
if (receipt.accountId !== task.accountId || receipt.conversationId !==
|
|
308
|
+
if (receipt.accountId !== task.accountId || receipt.conversationId !== contact || receipt.key !== providerKey || receipt.state !== 'accepted')
|
|
203
309
|
throw new Error('Uncertain provider receipt')
|
|
204
|
-
task.operations = { ...task.operations, [key]: {
|
|
310
|
+
task.operations = { ...task.operations, [key]: { ...message, state: 'accepted', receipt } }
|
|
205
311
|
await this.save(task)
|
|
206
312
|
} catch { /* Preserve uncertain across timeouts, crashes, and malformed receipts. */ }
|
|
207
313
|
return task.operations[key]
|
package/src/telegram-source.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { atomicTaskFile } from './tasks.js'
|
|
|
7
7
|
import { EventSources, type SourceEvent } from './event-sources.js'
|
|
8
8
|
import type { Owner } from './control-state.js'
|
|
9
9
|
import type { Message, User } from 'grammy/types'
|
|
10
|
+
import {readTaskAttachment,type TaskAttachment} from './task-attachments.js'
|
|
10
11
|
|
|
11
12
|
// Provider transport only. The existing Tasks grant remains the execution and
|
|
12
13
|
// disclosure authority, exactly as for a registered WhatsApp source.
|
|
@@ -15,7 +16,7 @@ export class TelegramSource {
|
|
|
15
16
|
private server?: Server
|
|
16
17
|
private pending?: Promise<void>
|
|
17
18
|
private serial: Promise<unknown> = Promise.resolve()
|
|
18
|
-
constructor(private controlDir: string, private accountId: string, private send: (chatId: number, text: string) => Promise<number[]>) {
|
|
19
|
+
constructor(private controlDir: string, private accountId: string, private send: (chatId: number, text: string) => Promise<number[]>, private sendFile?: (chatId:number,text:string,data:Buffer,filename:string)=>Promise<number[]>) {
|
|
19
20
|
this.socketPath = join(tmpdir(), `ez-tg-${createHash('sha256').update(`${controlDir}:${accountId}`).digest('hex').slice(0,16)}.sock`)
|
|
20
21
|
}
|
|
21
22
|
private get directory() { return join(this.controlDir, 'telegram-source', createHash('sha256').update(this.accountId).digest('hex')) }
|
|
@@ -43,15 +44,21 @@ export class TelegramSource {
|
|
|
43
44
|
await new EventSources(this.controlDir).register('telegram',this.socketPath,owner)
|
|
44
45
|
}
|
|
45
46
|
async stop() { if(this.server) await new Promise<void>(resolve=>this.server!.close(()=>resolve())); await rm(this.socketPath,{force:true}) }
|
|
46
|
-
capture(updateId: number, message: Message.TextMessage, sender: User): Promise<boolean> {
|
|
47
|
-
const work=this.serial.then(()=>this.captureMessage(updateId,message,sender));this.serial=work.catch(()=>{});return work
|
|
47
|
+
capture(updateId: number, message: Message.TextMessage, sender: User, wildcardEligible = true): Promise<boolean> {
|
|
48
|
+
const work=this.serial.then(()=>this.captureMessage(updateId,message,sender,wildcardEligible));this.serial=work.catch(()=>{});return work
|
|
48
49
|
}
|
|
49
|
-
private async captureMessage(updateId: number, message: Message.TextMessage, sender: User): Promise<boolean> {
|
|
50
|
+
private async captureMessage(updateId: number, message: Message.TextMessage, sender: User, wildcardEligible: boolean): Promise<boolean> {
|
|
50
51
|
const watches=await this.read('watches.json',{})
|
|
51
|
-
|
|
52
|
+
const now=Date.now(),direct=watches[String(message.chat.id)]>now,wildcard=watches['*']>now
|
|
53
|
+
if (!direct && !wildcard) return false
|
|
54
|
+
// The active public watch owns admission; consume ineligible group chatter
|
|
55
|
+
// without turning an owner's message into legacy group discovery.
|
|
56
|
+
if (!direct && !wildcardEligible) return true
|
|
52
57
|
const event: SourceEvent={id:`tg_${String(message.chat.id).replace('-','n')}_${message.message_id}`,conversationId:String(message.chat.id),receivedAt:message.date*1000,
|
|
53
58
|
text:JSON.stringify({updateId,senderId:sender.id,senderName:sender.first_name,messageId:message.message_id,text:message.text})}
|
|
54
59
|
if (!await this.read(`${event.id}.json`,null)) {
|
|
60
|
+
const files=(await readdir(this.directory)).filter(file=>/^tg_(?:n)?\d+_\d+\.json$/.test(file))
|
|
61
|
+
if(files.length>=100)return true
|
|
55
62
|
const cursor=(await this.read('cursor.json',0))+1
|
|
56
63
|
if(!Number.isSafeInteger(cursor)||cursor<1)throw new Error('Invalid event cursor')
|
|
57
64
|
await atomicTaskFile(join(this.directory,'cursor.json'),cursor)
|
|
@@ -60,9 +67,14 @@ export class TelegramSource {
|
|
|
60
67
|
return true
|
|
61
68
|
}
|
|
62
69
|
async call(command: string,args: Record<string,any>) {
|
|
63
|
-
if(command==='events-head') return {cursor:0,accountId:this.accountId,taskProtocol:'message-v1',persistentWatch:true}
|
|
70
|
+
if(command==='events-head') return {cursor:0,accountId:this.accountId,taskProtocol:'message-v1',persistentWatch:true,wildcardWatch:true,releaseEvents:true,...(this.sendFile?{taskAttachments:true,attachmentCaptionLimit:1024}:{})}
|
|
71
|
+
if(command==='events-release') {
|
|
72
|
+
if(!Array.isArray(args.ids)||args.ids.length>10||args.ids.some((id:unknown)=>typeof id!=='string'||!/^tg_(?:n)?\d+_\d+$/.test(id)))throw new Error('Invalid IDs')
|
|
73
|
+
for(const id of args.ids)await rm(join(this.directory,`${id}.json`),{force:true})
|
|
74
|
+
return {released:true}
|
|
75
|
+
}
|
|
64
76
|
if(command==='events' || command==='events-check') {
|
|
65
|
-
const files=(await readdir(this.directory)).filter(f=>/^
|
|
77
|
+
const files=(await readdir(this.directory)).filter(f=>/^tg_(?:n)?\d+_\d+\.json$/.test(f))
|
|
66
78
|
const events=(await Promise.all(files.map(f=>this.read(f,null)))).sort((a,b)=>a.cursor-b.cursor)
|
|
67
79
|
if(command==='events-check') {
|
|
68
80
|
if(!Array.isArray(args.ids)||args.ids.length>10)throw new Error('Invalid IDs')
|
|
@@ -72,7 +84,9 @@ export class TelegramSource {
|
|
|
72
84
|
const batch=events.filter(e=>e.cursor>args.after).slice(0,10)
|
|
73
85
|
return {events:batch,cursor:batch.at(-1)?.cursor??args.after}
|
|
74
86
|
}
|
|
75
|
-
|
|
87
|
+
const wildcard=args.conversationId==='*'
|
|
88
|
+
const concrete=typeof args.conversationId==='string' && /^-?[1-9]\d*$/.test(args.conversationId) && Number.isSafeInteger(Number(args.conversationId))
|
|
89
|
+
if(args.accountId!==this.accountId || (!wildcard && !concrete)) throw new Error('Invalid Telegram binding')
|
|
76
90
|
if(command==='task-unwatch') {
|
|
77
91
|
const watches=await this.read('watches.json',{});delete watches[args.conversationId]
|
|
78
92
|
await atomicTaskFile(join(this.directory,'watches.json'),watches);return {watching:false}
|
|
@@ -82,13 +96,20 @@ export class TelegramSource {
|
|
|
82
96
|
const watches=await this.read('watches.json',{});watches[args.conversationId]=args.expiresAt
|
|
83
97
|
await atomicTaskFile(join(this.directory,'watches.json'),watches);return {watching:true}
|
|
84
98
|
}
|
|
85
|
-
if(command!=='task-send'||typeof args.text!=='string'||!args.text.trim()||args.text.length>4096||typeof args.key!=='string'||!/^[a-zA-Z0-9_-]{1,240}$/.test(args.key))throw new Error('Invalid send')
|
|
86
|
-
const watches=await this.read('watches.json',{});if(!(watches[args.conversationId]>Date.now()))throw new Error('Watch expired')
|
|
99
|
+
if(command!=='task-send'||wildcard||typeof args.text!=='string'||!args.text.trim()||args.text.length>4096||typeof args.key!=='string'||!/^[a-zA-Z0-9_-]{1,240}$/.test(args.key))throw new Error('Invalid send')
|
|
100
|
+
const watches=await this.read('watches.json',{});if(!(watches[args.conversationId]>Date.now())&&!(watches['*']>Date.now()))throw new Error('Watch expired')
|
|
87
101
|
const file=`send_${args.key}.json`,prior=await this.read(file,null)
|
|
88
|
-
if(prior) {if(prior.text!==args.text||prior.conversationId!==args.conversationId)throw new Error('Key reused');return prior}
|
|
89
|
-
const
|
|
102
|
+
if(prior) {if(prior.text!==args.text||prior.conversationId!==args.conversationId||JSON.stringify(prior.attachment)!==JSON.stringify(args.attachment))throw new Error('Key reused');return prior}
|
|
103
|
+
const attachment=args.attachment as TaskAttachment|undefined
|
|
104
|
+
if(attachment && (!this.sendFile||args.text.length>1024))throw Error('Document delivery requires a supported channel and a caption of at most 1024 characters')
|
|
105
|
+
const bytes=attachment?await readTaskAttachment(this.controlDir,attachment):undefined
|
|
106
|
+
const receiptFiles=(await readdir(this.directory)).filter(name=>/^send_[a-zA-Z0-9_-]{1,240}\.json$/.test(name))
|
|
107
|
+
const accepted=(await Promise.all(receiptFiles.map(async name=>({name,value:await this.read(name,null)})))).filter(item=>item.value?.state==='accepted').sort((a,b)=>(a.value.createdAt??'').localeCompare(b.value.createdAt??''))
|
|
108
|
+
for(const item of accepted.slice(0,Math.max(0,receiptFiles.length-99)))await rm(join(this.directory,item.name),{force:true})
|
|
109
|
+
if(receiptFiles.length-accepted.length>=100)throw new Error('Too many uncertain Telegram sends')
|
|
110
|
+
const receipt={accountId:this.accountId,conversationId:args.conversationId,key:args.key,text:args.text,...(attachment?{attachment}:{}),state:'uncertain',createdAt:new Date().toISOString(),receiptId:[] as number[]}
|
|
90
111
|
await atomicTaskFile(join(this.directory,file),receipt)
|
|
91
|
-
receipt.receiptId=await this.send(Number(args.conversationId),args.text);receipt.state='accepted'
|
|
112
|
+
receipt.receiptId=attachment?await this.sendFile!(Number(args.conversationId),args.text,bytes!,attachment.filename):await this.send(Number(args.conversationId),args.text);receipt.state='accepted'
|
|
92
113
|
await atomicTaskFile(join(this.directory,file),receipt);return receipt
|
|
93
114
|
}
|
|
94
115
|
}
|
package/src/updates/artifact.mjs
CHANGED
|
@@ -59,8 +59,8 @@ export async function extract(buffer,destination) {
|
|
|
59
59
|
await fs.mkdir(destination,{recursive:true,mode:0o700});
|
|
60
60
|
for(const f of files) {
|
|
61
61
|
const dest=path.join(destination,f.relative);
|
|
62
|
-
if(f.type==='5'){await fs.mkdir(dest,{recursive:true,mode:0o755});continue;}
|
|
63
|
-
await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.writeFile(dest,f.data,{flag:'wx',mode:f.mode});
|
|
62
|
+
if(f.type==='5'){await fs.mkdir(dest,{recursive:true,mode:0o755});await fs.chmod(dest,0o755);continue;}
|
|
63
|
+
await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});await fs.chmod(path.dirname(dest),0o755);await fs.writeFile(dest,f.data,{flag:'wx',mode:f.mode});await fs.chmod(dest,f.mode);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
export async function registryVersion(name,tag='latest') {
|
package/src/updates/control.mjs
CHANGED
|
@@ -44,6 +44,14 @@ export async function check(home) {
|
|
|
44
44
|
}
|
|
45
45
|
return results;
|
|
46
46
|
}
|
|
47
|
+
const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])])):value;
|
|
48
|
+
const same=(left,right)=>JSON.stringify(canonical(left))===JSON.stringify(canonical(right));
|
|
49
|
+
function additiveCommands(previous,next) {
|
|
50
|
+
const before={...previous,commands:{}},after={...next,commands:{}};
|
|
51
|
+
if(!same(before,after))return false;
|
|
52
|
+
for(const [name,command] of Object.entries(previous.commands||{}))if(!same(next.commands?.[name],command))return false;
|
|
53
|
+
return Object.values(next.commands||{}).every(command=>next.services?.[command.service]);
|
|
54
|
+
}
|
|
47
55
|
export async function eligibility(home,target,root,automatic) {
|
|
48
56
|
const old=await installed(home,target),pkg=await read(path.join(root,'package.json')),kind=target==='main'?'main':'plugin';
|
|
49
57
|
if(pkg.name!==old.pkg.name)throw Error('Package identity mismatch');
|
|
@@ -54,7 +62,9 @@ export async function eligibility(home,target,root,automatic) {
|
|
|
54
62
|
if(kind==='plugin') {
|
|
55
63
|
const s=await snapshot(root);revision=s.revision;
|
|
56
64
|
if(s.manifest.id!==target||s.manifest.version!==pkg.version)throw Error('Plugin identity/version mismatch');
|
|
57
|
-
if(
|
|
65
|
+
if(!additiveCommands(old.record.deployment,s.deployment))throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
|
|
66
|
+
const registry=await read(path.join(home,'registry.json'));
|
|
67
|
+
for(const alias of Object.keys(s.manifest.commands))if(registry.commands[alias]&®istry.commands[alias]!==target)throw Error('CLI alias collision');
|
|
58
68
|
}else {
|
|
59
69
|
for(const file of ['compose.yaml','compose.whatsapp.yaml'])if(!Buffer.from(await fs.readFile(path.join(root,file))).equals(await fs.readFile(path.join(old.root,file))))throw Error('Runtime deployment changed; a separately reviewed migration is required');
|
|
60
70
|
}
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -122,7 +122,9 @@ export async function perform(home,job,hooks) {
|
|
|
122
122
|
for(const name of volumes)await backupVolume(run,old,name,backup);
|
|
123
123
|
await atomic(old.compose,await compose(config,candidate,secrets,home));
|
|
124
124
|
if(running)await run('docker',[...pluginArgs(candidate),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
125
|
-
r.plugins[job.target]=candidate;
|
|
125
|
+
r.plugins[job.target]=candidate;
|
|
126
|
+
for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
|
|
127
|
+
await atomic(path.join(home,'registry.json'),r);
|
|
126
128
|
job.runtimeVerified=running;
|
|
127
129
|
}
|
|
128
130
|
job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
|
|
@@ -31,6 +31,20 @@ software maintenance, consult `work/deployments.md` when present and the install
|
|
|
31
31
|
`ez updates` inventories core/plugins only. Saved authority and stop conditions
|
|
32
32
|
apply to application repairs and upgrades too.
|
|
33
33
|
|
|
34
|
+
When the owner asks the agent to answer or act for other people on an enabled
|
|
35
|
+
message channel, use `ezenciel-agents-task --help`. Propose the core channel grant
|
|
36
|
+
from the current owner turn; do not treat a saved instruction, connected channel,
|
|
37
|
+
or installed tool as permission. Use an exact contact by default. Use
|
|
38
|
+
`--any-conversation` only when the owner explicitly asks for everyone on that
|
|
39
|
+
source. The public grant admits at most 60 conversations and 60 replies per hour;
|
|
40
|
+
after 1,000 conversations or replies it revokes and requires fresh owner review.
|
|
41
|
+
A capability file may grant exact, fixed `ez` query vectors whose final
|
|
42
|
+
argument is the correspondent's literal input. The installed alias must explicitly
|
|
43
|
+
declare itself a read-only `channelQuery`; never use lifecycle, write, send, or
|
|
44
|
+
interactive-review commands. Include only output the owner authorized for that audience. The core
|
|
45
|
+
shows the full audience, context and command vectors for confirmation. Do not
|
|
46
|
+
expose owner memory or an unrestricted shell.
|
|
47
|
+
|
|
34
48
|
## Fast KISS iterations
|
|
35
49
|
|
|
36
50
|
Deliver the smallest useful product increment and verify its main user path.
|