@jc_stack/ez-agents 0.1.0-beta.29 → 0.1.0-beta.32
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 +1 -1
- package/CHANGELOG.md +41 -1
- 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 +2 -0
- 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/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/binding.mjs +1 -1
- package/templates/agent-guidance.md +24 -0
- package/test/agent-guidance.test.ts +8 -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 +3 -2
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/binding.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
|
|
|
19
19
|
await fs.writeFile(path.join(bin,'ez'),`#!${process.execPath}\nimport fs from 'node:fs';import {pathToFileURL} from 'node:url';const c=JSON.parse(fs.readFileSync(${configFile}));const m=await import(pathToFileURL(c.packageRoot+'/src/plugins/manager.mjs'));m.main(['--home',${JSON.stringify(home)},...process.argv.slice(2)]).catch(e=>{console.error(e.message);process.exitCode=1});\n`,{mode:0o700});
|
|
20
20
|
for(const [name,entry] of Object.entries(pkg.bin)) {
|
|
21
21
|
const dest=path.join(bin,name);await fs.rm(dest,{force:true});
|
|
22
|
-
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
|
|
22
|
+
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR:c.deploymentDir+'/control'}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
|
|
23
23
|
}
|
|
24
24
|
await bindToolDiscovery(home,config.workspace);
|
|
25
25
|
return {ok:true,home,deploymentDir,packageRoot,policy:'Automatic compatible beta-channel updates by default; saved stable-only or manual policies take precedence. Local candidates require an explicit owner request.'};
|
|
@@ -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.
|
|
@@ -67,6 +81,16 @@ a conflicting runner, context/prompt reconstruction or competing agent-turn queu
|
|
|
67
81
|
Revise a violating patch before approval or merge; document real capability gaps
|
|
68
82
|
instead of weakening the boundary to fit existing code.
|
|
69
83
|
|
|
84
|
+
For an owner-authorized product, fix or contribution, complete one coherent
|
|
85
|
+
loop: current main, focused PR, independent final-head review, required CI and
|
|
86
|
+
applicable QA. When the owner request or saved maintainer mandate includes merge
|
|
87
|
+
or release authority, then merge; release authority includes the merge needed for
|
|
88
|
+
the release. Otherwise leave the reviewed PR ready and state the remaining
|
|
89
|
+
authority. When release authority applies, continue through immutable
|
|
90
|
+
artifact publication, the repository's reviewed GitHub publisher, registry
|
|
91
|
+
readback and eligible installed-runtime verification. Local QA,
|
|
92
|
+
diagnostics, auth-only work and deployment remain separate authority boundaries.
|
|
93
|
+
|
|
70
94
|
Within the owner's request or saved contribution mandate, you may report evidenced
|
|
71
95
|
existing violations and submit focused fixes. Check existing issues, PRs and active
|
|
72
96
|
owners first; add sanitized evidence and a concrete next action to the existing
|
|
@@ -25,6 +25,14 @@ test('shared guidance makes direct owner chat replies a native transport action'
|
|
|
25
25
|
assert.match(guidance, /unchanged scheduled\nmonitoring stays quiet/)
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
+
test('shared guidance completes authorized contribution and release loops', () => {
|
|
29
|
+
const guidance = agentGuidance()
|
|
30
|
+
assert.match(guidance, /independent final-head review, required CI and\napplicable QA/)
|
|
31
|
+
assert.match(guidance, /mandate includes merge\nor release authority, then merge; release authority includes the merge needed for\nthe release. Otherwise leave the reviewed PR ready and state the remaining\nauthority/)
|
|
32
|
+
assert.match(guidance, /immutable\nartifact publication, the repository's reviewed GitHub publisher, registry\nreadback/)
|
|
33
|
+
assert.match(guidance, /Local QA,\ndiagnostics, auth-only work and deployment remain separate authority boundaries/)
|
|
34
|
+
})
|
|
35
|
+
|
|
28
36
|
test('package guidance resolution ignores a workspace shadow file', async () => {
|
|
29
37
|
const root = path.join(tmpdir(), `ez-guidance-${randomUUID()}`)
|
|
30
38
|
await mkdir(root, { recursive: true })
|
|
@@ -9,7 +9,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
11
|
import { ApplicationChannel, ApplicationBindings, applicationScope } from '../src/application-channel.js'
|
|
12
|
-
import { ControlStore } from '../src/control-state.js'
|
|
12
|
+
import { ControlStore, ownerId } from '../src/control-state.js'
|
|
13
13
|
import { RunStore } from '../src/runs.js'
|
|
14
14
|
import { initialPreset } from '../src/ai.js'
|
|
15
15
|
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
@@ -85,6 +85,10 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
|
|
|
85
85
|
await channel.deliver(run,message)
|
|
86
86
|
assert.deepEqual(await runs.waitForDelivery(message.id),{delivered:true})
|
|
87
87
|
assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).messages,[{id:message.id,text:'Here is the proposal'}])
|
|
88
|
+
const approval=await runs.enqueueApproval(run.id,'Allow this channel grant?','task_'+'a'.repeat(32))
|
|
89
|
+
await runs.claimOutbox(approval.id);await channel.deliver(run,approval)
|
|
90
|
+
assert.deepEqual((await channel.snapshot(first.bindingId,run.id)).approvals,[{id:'task_'+'a'.repeat(32),prompt:'Allow this channel grant?',state:'delivered'}])
|
|
91
|
+
assert.equal((await request('/v1/approvals/task_'+'a'.repeat(32),secondToken,{decision:'approved'})).status,400)
|
|
88
92
|
await channel.bindings.register('first',null,owned)
|
|
89
93
|
await assert.rejects(requireOwnerExecution(root,run.id),/revoked/)
|
|
90
94
|
await assert.rejects(channel.deliver(run,message),/revoked/)
|
|
@@ -281,3 +285,50 @@ test('following Telegram requires an explicit sharing grant and cannot override
|
|
|
281
285
|
}
|
|
282
286
|
assert.equal((await new RunStore(root).list()).length, 0)
|
|
283
287
|
})
|
|
288
|
+
|
|
289
|
+
test('generic attachments stage after auth, preserve literal comments and reject retry/isolation failures', async t => {
|
|
290
|
+
const root=await mkdtemp(join(tmpdir(),'ez-app-attachment-'))
|
|
291
|
+
const owned=await owner(root), runs=new RunStore(root)
|
|
292
|
+
const channel=new ApplicationChannel({controlDir:root,workspace:root,initial:initialPreset('codex'),wake:()=>{},cancel:async()=>{}})
|
|
293
|
+
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
294
|
+
const secret=token(), other=token()
|
|
295
|
+
const binding=(await channel.bindings.register('web',secret,owned,true))!
|
|
296
|
+
await channel.bindings.register('other',other,owned)
|
|
297
|
+
const address=await channel.listen(0) as {port:number}
|
|
298
|
+
const post=(body:unknown,bearer=secret)=>fetch(`http://127.0.0.1:${address.port}/v1/runs`,{method:'POST',headers:{Authorization:`Bearer ${bearer}`},body:JSON.stringify(body)})
|
|
299
|
+
const input={requestId:'image',scope:'chat',followOwner:true,text:' /goal literal\n comment ',attachment:{name:'image.png',data:Buffer.from('89504e470d0a1a0a','hex').toString('base64')}}
|
|
300
|
+
assert.equal((await post(input,'bad')).status,401)
|
|
301
|
+
const {readdir,stat}=await import('node:fs/promises')
|
|
302
|
+
await assert.rejects(readdir(join(root,'inbox')),/ENOENT/)
|
|
303
|
+
for (const [name,bytes] of [['image.png',Buffer.from('89504e470d0a1a0a','hex')],['file.pdf',Buffer.from('%PDF-1.4\nfixture')],['notes.md',Buffer.from('# Fixture')]] as const) {
|
|
304
|
+
const body={...input,requestId:name,attachment:{name,data:bytes.toString('base64')}}
|
|
305
|
+
const response=await post(body);assert.equal(response.status,202)
|
|
306
|
+
const snapshot=await response.json() as {id:string}
|
|
307
|
+
const run=(await runs.get(snapshot.id))!
|
|
308
|
+
assert.equal(run.ownerId,ownerId(owned))
|
|
309
|
+
assert.equal(run.application?.inputText,input.text)
|
|
310
|
+
assert.ok(run.texts[0].endsWith(`Caption: ${input.text}`))
|
|
311
|
+
const path=run.texts[0].match(/staged at (inbox\/[^ ]+)/)![1]
|
|
312
|
+
assert.deepEqual(await readFile(join(root,path)),bytes)
|
|
313
|
+
assert.equal((await stat(join(root,path))).mode & 0o777,0o600)
|
|
314
|
+
const before=(await readdir(join(root,'inbox'))).length
|
|
315
|
+
assert.equal((await post(body)).status,202)
|
|
316
|
+
assert.equal((await readdir(join(root,'inbox'))).length,before)
|
|
317
|
+
assert.equal((await post({...body,text:'changed'})).status,409)
|
|
318
|
+
assert.equal((await post({...body,attachment:{name,data:Buffer.from('changed').toString('base64')}})).status,409)
|
|
319
|
+
assert.equal((await fetch(`http://127.0.0.1:${address.port}/v1/runs/${snapshot.id}`,{headers:{Authorization:`Bearer ${other}`}})).status,404)
|
|
320
|
+
}
|
|
321
|
+
const control=new ControlStore(root,1000)
|
|
322
|
+
const priorSession=(await control.status()).activeSession
|
|
323
|
+
const invalidActivation=await post({...input,requestId:'invalid-activation',followOwner:false,activateTelegram:true,scope:'new-scope',attachment:{name:'x.exe',data:Buffer.from('text').toString('base64')}})
|
|
324
|
+
assert.equal(invalidActivation.status,400)
|
|
325
|
+
assert.deepEqual((await control.status()).activeSession,priorSession)
|
|
326
|
+
const before=(await readdir(join(root,'inbox'))).length
|
|
327
|
+
for (const attachment of [{name:'x.exe',data:Buffer.from('text').toString('base64')},{name:'x.txt',data:'%%%invalid'},{name:'x.txt',data:Buffer.alloc(10*1024*1024+1,65).toString('base64')}]) {
|
|
328
|
+
assert.equal((await post({...input,requestId:'bad',attachment})).status,400)
|
|
329
|
+
}
|
|
330
|
+
assert.equal((await readdir(join(root,'inbox'))).length,before)
|
|
331
|
+
await channel.bindings.register('web',null,owned)
|
|
332
|
+
await assert.rejects(channel.submit(binding.bindingId,{...input,requestId:'revoked'}),/revoked/)
|
|
333
|
+
assert.equal((await readdir(join(root,'inbox'))).length,before)
|
|
334
|
+
})
|