@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
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/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +41 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +8 -3
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +131 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +1 -0
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +220 -1
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +120 -12
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +87 -3
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +136 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
package/src/ai.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import { assertEffort, allowedEffort } from './model-policy.js'
|
|
2
2
|
import { access, readFile } from 'node:fs/promises'
|
|
3
3
|
import { constants } from 'node:fs'
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
4
6
|
import { homedir } from 'node:os'
|
|
5
7
|
import { join, delimiter } from 'node:path'
|
|
6
|
-
import { executorKey, resolveExecutor } from './executor.js'
|
|
8
|
+
import { executorEnvironment, executorKey, resolveExecutor } from './executor.js'
|
|
7
9
|
import { desktopCodexPath } from './desktop-bridge.js'
|
|
8
10
|
|
|
9
11
|
export type AiPreset = { id: string; name: string; cli: string; provider?: string; model?: string; effort?: string }
|
|
10
12
|
export type ExecutionChoice = { sessionId: string; preset: AiPreset }
|
|
11
13
|
export type ModelChoice = { cli: string; provider?: string; model?: string; name: string; efforts: string[] }
|
|
12
14
|
const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
|
|
15
|
+
const knownClis = ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy', 'pi']
|
|
16
|
+
const nativeEffort = (cli: string, effort: string) =>
|
|
17
|
+
cli === 'pi' ? ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) : true
|
|
13
18
|
export const isPreset = (p: unknown): p is AiPreset => {
|
|
14
19
|
if (!p || typeof p !== 'object') return false
|
|
15
20
|
const v = p as AiPreset
|
|
16
21
|
return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
|
|
17
|
-
|
|
22
|
+
knownClis.includes(v.cli) &&
|
|
18
23
|
(v.provider === undefined || safe(v.provider)) && (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
|
|
19
24
|
}
|
|
20
25
|
export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
|
|
@@ -45,7 +50,81 @@ export const installed = async (cli: string): Promise<boolean> => {
|
|
|
45
50
|
|
|
46
51
|
// Read only metadata from native client catalogs. Never import prompts, credentials,
|
|
47
52
|
// provider configuration, or model instructions into relay context.
|
|
48
|
-
|
|
53
|
+
// The optional runner injects `opencode models` output in tests; production
|
|
54
|
+
// spawns the installed CLI with the whitelisted executor environment.
|
|
55
|
+
export const readOpencodeModels = async (run?: (args: string[]) => Promise<string>, dataHome?: string): Promise<ModelChoice[]> => {
|
|
56
|
+
const exec = run ?? (async (args: string[]) =>
|
|
57
|
+
(await promisify(execFile)('opencode', args, { env: { ...executorEnvironment(), ...(dataHome ? { XDG_DATA_HOME: dataHome } : {}) }, timeout: 8000, maxBuffer: 4 * 1024 * 1024 })).stdout)
|
|
58
|
+
const record = (value: unknown): Record<string, unknown> =>
|
|
59
|
+
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
60
|
+
try {
|
|
61
|
+
const stdout = await exec(['models', '--verbose'])
|
|
62
|
+
// Verbose output alternates a `provider/model` identifier line with its
|
|
63
|
+
// pretty-printed JSON metadata (name, variants). Split on identifier lines
|
|
64
|
+
// so variant keys can be projected as selectable efforts.
|
|
65
|
+
const segments = stdout.split(/^([A-Za-z0-9_.\-]+\/[A-Za-z0-9_./:~\-]+)$/m)
|
|
66
|
+
const entries: ModelChoice[] = []
|
|
67
|
+
for (let index = 1; index + 1 < segments.length; index += 2) {
|
|
68
|
+
const id = segments[index].trim()
|
|
69
|
+
if (!safe(id)) continue
|
|
70
|
+
let meta: Record<string, unknown>
|
|
71
|
+
try { meta = record(JSON.parse(segments[index + 1])) } catch { continue }
|
|
72
|
+
const friendly = typeof meta.name === 'string' ? meta.name.trim() : ''
|
|
73
|
+
const efforts = Object.keys(record(meta.variants))
|
|
74
|
+
.filter((effort) => safe(effort) && allowedEffort(effort, id, 'opencode'))
|
|
75
|
+
entries.push({ cli: 'opencode', model: id,
|
|
76
|
+
name: (friendly ? `${friendly} · ${id}` : id).slice(0, 80), efforts })
|
|
77
|
+
}
|
|
78
|
+
if (entries.length) return entries
|
|
79
|
+
} catch { /* fall through to the plain list, then the client default */ }
|
|
80
|
+
try {
|
|
81
|
+
const stdout = await exec(['models'])
|
|
82
|
+
const entries: ModelChoice[] = []
|
|
83
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
84
|
+
const id = line.trim()
|
|
85
|
+
if (!id || !safe(id)) continue
|
|
86
|
+
entries.push({ cli: 'opencode', model: id, name: id.slice(0, 80), efforts: [] })
|
|
87
|
+
}
|
|
88
|
+
if (entries.length) return entries
|
|
89
|
+
} catch { /* unavailable metadata stays client default */ }
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// User-curated entries live in the agent's own setup (control/ai-models.json),
|
|
94
|
+
// never in the repo. They pin models the native catalogs may not currently
|
|
95
|
+
// serve (e.g. paid tiers) and merge ahead of discovered entries, so selection
|
|
96
|
+
// validation accepts them.
|
|
97
|
+
export const readCuratedModels = async (controlDir?: string): Promise<ModelChoice[]> => {
|
|
98
|
+
if (!controlDir) return []
|
|
99
|
+
const curated: ModelChoice[] = []
|
|
100
|
+
const seen = new Set<string>()
|
|
101
|
+
try {
|
|
102
|
+
const raw = JSON.parse(await readFile(join(controlDir, 'ai-models.json'), 'utf8'))
|
|
103
|
+
if (!Array.isArray(raw)) return []
|
|
104
|
+
for (const candidate of raw) {
|
|
105
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue
|
|
106
|
+
const entry = candidate as Record<string, unknown>
|
|
107
|
+
if (typeof entry.cli !== 'string' || !knownClis.includes(entry.cli)) continue
|
|
108
|
+
if (entry.provider !== undefined && !safe(entry.provider)) continue
|
|
109
|
+
if (entry.model !== undefined && !safe(entry.model)) continue
|
|
110
|
+
const key = `${entry.cli}‖${entry.provider ?? ''}‖${entry.model ?? ''}`
|
|
111
|
+
if (seen.has(key)) continue
|
|
112
|
+
seen.add(key)
|
|
113
|
+
curated.push({
|
|
114
|
+
cli: entry.cli,
|
|
115
|
+
...(entry.provider !== undefined ? { provider: entry.provider as string } : {}),
|
|
116
|
+
...(entry.model !== undefined ? { model: entry.model as string } : {}),
|
|
117
|
+
name: (typeof entry.name === 'string' && entry.name ? entry.name : String(entry.model ?? entry.cli)).slice(0, 80),
|
|
118
|
+
efforts: (Array.isArray(entry.efforts) ? entry.efforts : [])
|
|
119
|
+
.filter((effort): effort is string => safe(effort))
|
|
120
|
+
.filter((effort) => allowedEffort(effort, typeof entry.model === 'string' ? entry.model : undefined, entry.cli as string) && nativeEffort(entry.cli as string, effort)),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
} catch { return [] }
|
|
124
|
+
return curated
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex'), opencodeRunner?: (args: string[]) => Promise<string>, opencodeDataHome?: string, opencodeAllowlist?: string[], curationDir?: string): Promise<ModelChoice[]> => {
|
|
49
128
|
const models: ModelChoice[] = []
|
|
50
129
|
const record = (value: unknown): Record<string, unknown> =>
|
|
51
130
|
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
@@ -79,9 +158,56 @@ export const readModels = async (home = homedir(), available = installed, codexH
|
|
|
79
158
|
models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
|
|
80
159
|
}
|
|
81
160
|
// Other adapters expose the authenticated client's default, not a guessed catalog.
|
|
82
|
-
for (const cli of ['claude', '
|
|
161
|
+
for (const cli of ['claude', 'agy'])
|
|
83
162
|
if (await available(cli)) models.push({ cli, name: `${cli} · client default`, efforts: [] })
|
|
84
|
-
|
|
163
|
+
const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
|
|
164
|
+
if (await available('opencode')) models.push(...await opencodeCatalogModels(opencodeRunner, opencodeDataHome, allow))
|
|
165
|
+
// Pi has separate provider configuration. OpenCode's catalog cannot
|
|
166
|
+
// establish which models Pi can execute.
|
|
167
|
+
if (!allow && await available('pi')) models.push({ cli: 'pi', name: 'Pi · client default', efforts: [] })
|
|
168
|
+
const curated = await readCuratedModels(curationDir)
|
|
169
|
+
if (!curated.length) return models
|
|
170
|
+
const scoped: ModelChoice[] = []
|
|
171
|
+
for (const entry of curated) {
|
|
172
|
+
if (!(await available(entry.cli))) continue
|
|
173
|
+
if (['opencode', 'pi'].includes(entry.cli) && allow &&
|
|
174
|
+
(entry.model === undefined || !allow.includes(entry.model.split('/')[0]))) continue
|
|
175
|
+
scoped.push(entry)
|
|
176
|
+
}
|
|
177
|
+
const key = (m: { cli: string; provider?: string; model?: string }) => `${m.cli}‖${m.provider ?? ''}‖${m.model ?? ''}`
|
|
178
|
+
const curatedKeys = new Set(scoped.map(key))
|
|
179
|
+
return [...scoped, ...models.filter((m) => !curatedKeys.has(key(m)))]
|
|
180
|
+
}
|
|
181
|
+
export const opencodeCatalogModels = async (
|
|
182
|
+
opencodeRunner?: (args: string[]) => Promise<string>,
|
|
183
|
+
opencodeDataHome?: string,
|
|
184
|
+
opencodeAllowlist?: string[],
|
|
185
|
+
): Promise<ModelChoice[]> => {
|
|
186
|
+
const discovered = await readOpencodeModels(opencodeRunner, opencodeDataHome)
|
|
187
|
+
const allow = opencodeAllowlist ?? opencodeProviderAllowlist()
|
|
188
|
+
const scoped = allow ? discovered.filter((m) => m.model && allow.includes(m.model.split('/')[0])) : discovered
|
|
189
|
+
if (scoped.length) return scoped
|
|
190
|
+
// A set allowlist that matches nothing offers no choice rather than falling
|
|
191
|
+
// back outside the allowed providers.
|
|
192
|
+
if (allow) return []
|
|
193
|
+
return [{ cli: 'opencode', name: 'opencode · client default', efforts: [] as string[] }]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Optional deployment-scoped restriction of the OpenCode catalog to named
|
|
197
|
+
// providers (e.g. EZ_OPENCODE_PROVIDERS=opencode-go). Unset means unfiltered.
|
|
198
|
+
// Reads process env directly like OPENCODE_MODEL; malformed values fail fast
|
|
199
|
+
// so /ai reports the misconfiguration instead of a silently wrong list.
|
|
200
|
+
const providerId = (p: unknown): p is string => typeof p === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(p)
|
|
201
|
+
export const validateOpencodeProviders = (value: unknown): string[] | undefined => {
|
|
202
|
+
if (value === undefined) return undefined
|
|
203
|
+
if (!Array.isArray(value) || !value.length || value.length > 16 || new Set(value).size !== value.length || value.some((p) => !providerId(p)))
|
|
204
|
+
throw new Error('opencode provider allowlist must be one to sixteen unique provider IDs')
|
|
205
|
+
return value as string[]
|
|
206
|
+
}
|
|
207
|
+
export const opencodeProviderAllowlist = (env: NodeJS.ProcessEnv = process.env): string[] | undefined => {
|
|
208
|
+
const raw = env.EZ_OPENCODE_PROVIDERS?.trim()
|
|
209
|
+
if (!raw) return undefined
|
|
210
|
+
return validateOpencodeProviders([...new Set(raw.split(',').map((p) => p.trim()).filter(Boolean))])
|
|
85
211
|
}
|
|
86
212
|
|
|
87
213
|
export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
|
|
@@ -2,7 +2,7 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
|
|
|
2
2
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { ControlStore, sessionTitle, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
5
|
+
import { ControlStore, sessionTitle, telegramOwner, type ControlGuard, type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
6
6
|
import { RunStore, type RunRecord, type OutboxItem } from './runs.js'
|
|
7
7
|
import { ownsRun } from './identity.js'
|
|
8
8
|
import { applicationId, validApplicationOrigin } from './application-origin.js'
|
|
@@ -74,6 +74,8 @@ export class ApplicationChannel {
|
|
|
74
74
|
controlDir: string; workspace?: string; initial: AiPreset
|
|
75
75
|
wake: () => void
|
|
76
76
|
cancel: (id: string) => Promise<void>
|
|
77
|
+
createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
|
|
78
|
+
telegramAvailable?: () => boolean
|
|
77
79
|
aiControls?: {
|
|
78
80
|
catalog: () => Promise<ModelChoice[]>
|
|
79
81
|
select: (preset: AiPreset, expectedSession: string | null, guard?: ControlGuard) => Promise<unknown>
|
|
@@ -88,6 +90,29 @@ export class ApplicationChannel {
|
|
|
88
90
|
if (!binding || (shared && !binding.shareTelegram) || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit controls')
|
|
89
91
|
return binding.owner
|
|
90
92
|
}
|
|
93
|
+
private async telegramConnection(bindingId: string) {
|
|
94
|
+
await this.sharedBinding(bindingId)
|
|
95
|
+
const owner = (await new ControlStore(this.options.controlDir, 900000).status()).owner
|
|
96
|
+
const connected = Boolean(telegramOwner(owner))
|
|
97
|
+
return this.options.telegramAvailable
|
|
98
|
+
? { connected, ready: this.options.telegramAvailable() }
|
|
99
|
+
: { connected }
|
|
100
|
+
}
|
|
101
|
+
private async createTelegramConnection(bindingId: string) {
|
|
102
|
+
const owner = await this.sharedBinding(bindingId)
|
|
103
|
+
const current = (await new ControlStore(this.options.controlDir, 900000).status()).owner
|
|
104
|
+
if (telegramOwner(current)) return { connected: true } as const
|
|
105
|
+
if (!this.options.createTelegramPairing) throw new Error('Telegram connection is unavailable')
|
|
106
|
+
return this.options.createTelegramPairing(bindingId, owner)
|
|
107
|
+
}
|
|
108
|
+
async claimTelegramConnection(token: string, telegramUserId: number, telegramChatId: number): Promise<boolean> {
|
|
109
|
+
const control = new ControlStore(this.options.controlDir, 900000)
|
|
110
|
+
const owner = await control.claimApplicationTelegramPairing(token, telegramUserId, telegramChatId, async (bindingId, expectedOwner) => {
|
|
111
|
+
const binding = (await this.bindings.list()).find(item => item.bindingId === bindingId)
|
|
112
|
+
return Boolean(binding?.shareTelegram && sameOwner(binding.owner, expectedOwner))
|
|
113
|
+
})
|
|
114
|
+
return Boolean(owner)
|
|
115
|
+
}
|
|
91
116
|
async controls(bindingId: string) {
|
|
92
117
|
await this.sharedBinding(bindingId)
|
|
93
118
|
if (!this.options.aiControls) throw new Error('Application controls unavailable')
|
|
@@ -233,8 +258,8 @@ export class ApplicationChannel {
|
|
|
233
258
|
}
|
|
234
259
|
const control = new ControlStore(this.options.controlDir, 900000)
|
|
235
260
|
if (!sameOwner(binding.owner, (await control.status()).owner)) throw new Error('Application authority revoked')
|
|
236
|
-
if (bytes && !this.options.
|
|
237
|
-
const staged = bytes ? await stageChatAttachment(this.options.
|
|
261
|
+
if (bytes && !this.options.controlDir) throw new Error('Invalid application attachment: control unavailable')
|
|
262
|
+
const staged = bytes ? await stageChatAttachment(this.options.controlDir, value.attachment!.name as string, bytes, value.text) : undefined
|
|
238
263
|
let run: RunRecord
|
|
239
264
|
try {
|
|
240
265
|
const execution = value.followTelegram ? await control.captureChoice(this.options.initial) : await control.captureApplicationChoice(this.options.initial, applicationScope(bindingId, value.scope), binding.shareTelegram === true && value.activateTelegram === true, requestedPreset, value.expectedNativeSessionId as string | undefined)
|
|
@@ -270,13 +295,33 @@ export class ApplicationChannel {
|
|
|
270
295
|
if (request.method === 'GET' && path === '/v1/registration') {
|
|
271
296
|
send(200, {ownerId: ownerId(binding.owner), bindingId: binding.bindingId, channel: binding.id}); return
|
|
272
297
|
}
|
|
298
|
+
if (path === '/v1/telegram' && request.method === 'GET') {
|
|
299
|
+
if ([...url.searchParams.keys()].length) throw new Error('Invalid application Telegram request')
|
|
300
|
+
if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram connection' }); return }
|
|
301
|
+
send(200, await this.telegramConnection(binding.bindingId)); return
|
|
302
|
+
}
|
|
303
|
+
if (path === '/v1/telegram/link' && request.method === 'POST') {
|
|
304
|
+
const chunks: Buffer[] = []; let size = 0
|
|
305
|
+
for await (const chunk of request) { size += chunk.length; if (size > 1024) throw new Error('Invalid application Telegram request'); chunks.push(chunk) }
|
|
306
|
+
if (Buffer.concat(chunks).toString('utf8').trim()) throw new Error('Invalid application Telegram request')
|
|
307
|
+
if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram connection' }); return }
|
|
308
|
+
send(200, await this.createTelegramConnection(binding.bindingId)); return
|
|
309
|
+
}
|
|
273
310
|
if (request.method === 'GET' && path === '/v1/runs') {
|
|
274
|
-
const
|
|
311
|
+
const owned = await this.runs.list()
|
|
312
|
+
const all = owned.filter(run => (run.application ?? run.delivery)?.bindingId === binding.bindingId)
|
|
313
|
+
const currentOwner = binding.shareTelegram ? telegramOwner((await new ControlStore(this.options.controlDir, 900000).status()).owner) : null
|
|
314
|
+
const telegram = currentOwner
|
|
315
|
+
? owned.filter(run => run.application === undefined && run.delivery === undefined && run.taskId === undefined && run.external === undefined && run.scheduled === undefined && run.telegramUserId === currentOwner.telegramUserId && run.ownerId === ownerId(currentOwner) && run.ownerEpoch === ownerEpoch(currentOwner) && (run.telegramApplication === undefined || run.telegramApplication.bindingId === binding.bindingId) && !all.some(item => item.id === run.id))
|
|
316
|
+
: []
|
|
317
|
+
const visible = [...all, ...telegram]
|
|
275
318
|
const before = url.searchParams.get('before')
|
|
276
|
-
const end = before ?
|
|
319
|
+
const end = before ? visible.findIndex(run => run.id === before) : visible.length
|
|
277
320
|
if (end < 0) throw new Error('Invalid application inbox cursor')
|
|
278
|
-
const records =
|
|
279
|
-
send(200, {runs: await Promise.all(records.map(run =>
|
|
321
|
+
const records = visible.slice(Math.max(0,end-100),end)
|
|
322
|
+
send(200, {runs: await Promise.all(records.map(run => (run.application ?? run.delivery)
|
|
323
|
+
? this.snapshot(binding.bindingId, run.id)
|
|
324
|
+
: Promise.resolve({id: run.id, scope: 'telegram', status: run.status, texts: run.texts, telegramUserId: run.telegramUserId, createdAt: run.createdAt}))), nextCursor:end>100?records[0].id:null}); return
|
|
280
325
|
}
|
|
281
326
|
if (path==='/v1/scope-control' && ['GET','POST'].includes(request.method ?? '')) {
|
|
282
327
|
const scope = url.searchParams.get('scope')
|
|
@@ -314,6 +359,32 @@ export class ApplicationChannel {
|
|
|
314
359
|
if(!await new Tasks(this.options.controlDir).decide(approvalMatch[1]))throw new Error('Unknown application approval')
|
|
315
360
|
send(200,{id:approvalMatch[1],decision:input.decision});return
|
|
316
361
|
}
|
|
362
|
+
const attachMatch = path.match(/^\/v1\/runs\/([A-Za-z0-9_-]{1,160})\/application$/)
|
|
363
|
+
if (attachMatch && request.method === 'POST') {
|
|
364
|
+
if (!binding.shareTelegram) { send(403, { error: 'Application authority does not permit Telegram admission' }); return }
|
|
365
|
+
const chunks: Buffer[] = []; let size = 0
|
|
366
|
+
for await (const chunk of request) { size += chunk.length; if (size > 65536) throw new Error('Invalid application admission size'); chunks.push(chunk) }
|
|
367
|
+
const input = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { context?: unknown; scope?: unknown }
|
|
368
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => key !== 'context' && key !== 'scope')) throw new Error('Invalid application admission')
|
|
369
|
+
const scope = input.scope === undefined ? 'telegram' : input.scope
|
|
370
|
+
if (typeof scope !== 'string') throw new Error('Invalid application admission')
|
|
371
|
+
const target = await this.runs.get(attachMatch[1])
|
|
372
|
+
if (target?.telegramApplication?.bindingId === binding.bindingId) {
|
|
373
|
+
const same = target.telegramApplication.scope === scope &&
|
|
374
|
+
JSON.stringify(target.telegramApplication.context ?? null) === JSON.stringify(input.context ?? null)
|
|
375
|
+
if (same) { send(200, { id: target.id, scope: target.telegramApplication.scope, status: target.status }); return }
|
|
376
|
+
throw new Error('Application admission conflicts with prior context')
|
|
377
|
+
}
|
|
378
|
+
if (!target || target.application || target.telegramApplication || target.taskId || target.external || target.scheduled || target.telegramUserId === undefined) throw new Error('Unknown application run')
|
|
379
|
+
const state = await new ControlStore(this.options.controlDir, 900000).status()
|
|
380
|
+
const linked = telegramOwner(state.owner)
|
|
381
|
+
if (!linked || target.telegramUserId !== linked.telegramUserId || !sameOwner(binding.owner, state.owner)) throw new Error('Application authority does not permit Telegram admission')
|
|
382
|
+
if (target.status !== 'queued' && target.status !== 'running') throw new Error('Application admission conflicts with finished run')
|
|
383
|
+
const application = { bindingId: binding.bindingId, scope, requestId: target.id, context: input.context as Record<string, unknown> }
|
|
384
|
+
if (!validApplicationOrigin(application)) throw new Error('Invalid application admission')
|
|
385
|
+
const admitted = await this.runs.attachTelegramApplication(target.id, application)
|
|
386
|
+
send(200, { id: admitted.id, scope, status: admitted.status }); return
|
|
387
|
+
}
|
|
317
388
|
const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
|
|
318
389
|
if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
|
|
319
390
|
await this.snapshot(binding.bindingId, match[1])
|
package/src/application-cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { loadControlConfig } from './config.js'
|
|
3
|
+
import { loadControlConfig, telegramToken } from './config.js'
|
|
4
4
|
import { ControlStore, sameOwner, ownerId } from './control-state.js'
|
|
5
5
|
import { ApplicationBindings, applicationScope, validateApplicationRegistration } from './application-channel.js'
|
|
6
6
|
import { initialPreset } from './ai.js'
|
|
@@ -17,7 +17,7 @@ async function main() {
|
|
|
17
17
|
const config = loadControlConfig(), control = new ControlStore(config.controlDir, config.pairingTtlMs)
|
|
18
18
|
if (values.owner && values['owner-id']) throw new Error('Choose one owner registration form')
|
|
19
19
|
if (values['owner-id'] && (!values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner-id requires channel registration')
|
|
20
|
-
if (values.owner && (process.env
|
|
20
|
+
if (values.owner && (telegramToken(process.env) || !values.id || !values['token-file'] || values.revoke || values.list || values['import-scope'])) throw new Error('--owner bootstrap requires an agent without a Telegram bot')
|
|
21
21
|
const registrationToken = values['token-file'] ? (await readFile(values['token-file'], 'utf8')).trim() : null
|
|
22
22
|
if (values.owner || values['owner-id']) validateApplicationRegistration(values.id!, registrationToken)
|
|
23
23
|
const owner = values['owner-id'] ? await control.registerOwner(values['owner-id']) : values.owner ? await control.bootstrapApplicationOwner(Number(values.owner)) : (await control.status()).owner
|
package/src/approval-cli.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { loadControlConfig } from './config.js'
|
|
2
|
-
import {
|
|
3
|
-
import { RunStore } from './runs.js'
|
|
2
|
+
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
4
3
|
|
|
5
4
|
const args = process.argv.slice(2).filter((arg) => arg !== '--')
|
|
6
5
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -27,10 +26,12 @@ for (let i = 0; i < args.length; i++) {
|
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
const config = loadControlConfig()
|
|
30
|
-
const
|
|
29
|
+
const socketPath = socketPathFor(config.controlDir)
|
|
31
30
|
|
|
32
31
|
if (checkId) {
|
|
33
|
-
const record = await
|
|
32
|
+
const record = await callDeliverySocket(socketPath, { op: 'approvalCheck', payload: { actionId: checkId } }) as {
|
|
33
|
+
actionId: string; decision: string; decidedAt?: string; decidedBy?: number
|
|
34
|
+
}
|
|
34
35
|
console.log(JSON.stringify({
|
|
35
36
|
ok: true,
|
|
36
37
|
actionId: checkId,
|
|
@@ -53,7 +54,13 @@ if (!runId) {
|
|
|
53
54
|
process.exit(1)
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const item = await
|
|
57
|
+
// The relay records the approval request and delivers its card; the decision
|
|
58
|
+
// arrives later through Telegram and is read back with --check.
|
|
59
|
+
const item = await callDeliverySocket(socketPath, {
|
|
60
|
+
op: 'enqueue',
|
|
61
|
+
payload: {
|
|
62
|
+
kind: 'approval', runId, approvalPrompt: prompt, approvalActionId: actionId,
|
|
63
|
+
...(replyTo !== undefined ? { replyToMessageId: replyTo } : {}),
|
|
64
|
+
},
|
|
65
|
+
}) as { outbox_id: string; id: string }
|
|
59
66
|
console.log(JSON.stringify({ ok: true, run: runId, actionId, outbox_id: item.id, status: 'requested' }))
|
package/src/approval.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
-
import path from 'node:path'
|
|
3
1
|
import { assertId } from './identity.js'
|
|
4
2
|
import { ownerEpoch, ownerId, type Owner } from './control-state.js'
|
|
5
3
|
|
|
@@ -34,28 +32,32 @@ const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
|
|
|
34
32
|
)
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// Stateless pipe: approvals live in relay memory. Engine children request
|
|
36
|
+
// decisions through the delivery socket (folded into the approval send);
|
|
37
|
+
// callbacks and task arbitration record them in-relay. A restart drops
|
|
38
|
+
// pending approvals by design.
|
|
39
|
+
const approvalsByControl = new Map<string, Map<string, ApprovalRecord>>()
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
41
|
+
const approvalsFor = (controlDir: string): Map<string, ApprovalRecord> => {
|
|
42
|
+
let map = approvalsByControl.get(controlDir)
|
|
43
|
+
if (!map) { map = new Map(); approvalsByControl.set(controlDir, map) }
|
|
44
|
+
return map
|
|
45
|
+
}
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
+
export class ApprovalStore {
|
|
48
|
+
constructor(private readonly controlDir: string) {}
|
|
47
49
|
|
|
48
|
-
private
|
|
50
|
+
private key(actionId: string): string {
|
|
49
51
|
const safe = assertId(actionId)
|
|
50
52
|
if (safe.length > 40) throw new Error('Approval identifier must be at most 40 characters')
|
|
51
|
-
return
|
|
53
|
+
return safe
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
async requestApproval(actionId: string, prompt: string, runId?: string): Promise<ApprovalRecord> {
|
|
55
57
|
if (!prompt.trim()) throw new Error('Approval prompt is empty')
|
|
56
58
|
if (runId) assertId(runId)
|
|
57
|
-
|
|
58
|
-
const existing =
|
|
59
|
+
const store = approvalsFor(this.controlDir)
|
|
60
|
+
const existing = store.get(this.key(actionId)) ?? null
|
|
59
61
|
if (existing) {
|
|
60
62
|
if (existing.runId !== runId || existing.prompt !== prompt)
|
|
61
63
|
throw new Error('Approval ID already belongs to another request')
|
|
@@ -70,10 +72,7 @@ export class ApprovalStore {
|
|
|
70
72
|
decision: 'pending',
|
|
71
73
|
createdAt: new Date().toISOString(),
|
|
72
74
|
}
|
|
73
|
-
|
|
74
|
-
const temporary = `${file}.${process.pid}.tmp`
|
|
75
|
-
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
76
|
-
await rename(temporary, file)
|
|
75
|
+
store.set(this.key(actionId), record)
|
|
77
76
|
return record
|
|
78
77
|
}
|
|
79
78
|
|
|
@@ -83,8 +82,8 @@ export class ApprovalStore {
|
|
|
83
82
|
decidedBy: number,
|
|
84
83
|
decisionUpdateId?: number,
|
|
85
84
|
): Promise<ApprovalRecord> {
|
|
86
|
-
|
|
87
|
-
const existing =
|
|
85
|
+
const store = approvalsFor(this.controlDir)
|
|
86
|
+
const existing = store.get(this.key(actionId)) ?? null
|
|
88
87
|
if (!existing) throw new Error('Unknown approval request')
|
|
89
88
|
if (
|
|
90
89
|
decisionUpdateId !== undefined &&
|
|
@@ -102,34 +101,25 @@ export class ApprovalStore {
|
|
|
102
101
|
decidedBy,
|
|
103
102
|
decisionUpdateId,
|
|
104
103
|
}
|
|
105
|
-
|
|
106
|
-
const temporary = `${file}.${process.pid}.tmp`
|
|
107
|
-
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
108
|
-
await rename(temporary, file)
|
|
104
|
+
store.set(this.key(actionId), record)
|
|
109
105
|
return record
|
|
110
106
|
}
|
|
111
107
|
|
|
112
108
|
async recordOwnerDecision(actionId: string, decision: 'approved' | 'denied', owner: Owner): Promise<ApprovalRecord> {
|
|
113
|
-
|
|
114
|
-
const existing =
|
|
109
|
+
const store = approvalsFor(this.controlDir)
|
|
110
|
+
const existing = store.get(this.key(actionId)) ?? null
|
|
115
111
|
if (!existing) throw new Error('Unknown approval request')
|
|
116
112
|
if (existing.decision !== 'pending') throw new Error('Approval already decided')
|
|
117
113
|
if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
|
|
118
114
|
const record: ApprovalRecord = {...existing,version:2,decision,decidedAt:new Date().toISOString(),decidedOwnerId:ownerId(owner),decidedOwnerEpoch:ownerEpoch(owner)}
|
|
119
|
-
|
|
120
|
-
await writeFile(temporary,`${JSON.stringify(record,null,2)}\n`,{mode:0o600});await rename(temporary,file)
|
|
115
|
+
store.set(this.key(actionId), record)
|
|
121
116
|
return record
|
|
122
117
|
}
|
|
123
118
|
|
|
124
119
|
async getDecision(actionId: string): Promise<ApprovalRecord | null> {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return parsed
|
|
130
|
-
} catch (error: unknown) {
|
|
131
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
132
|
-
throw error
|
|
133
|
-
}
|
|
120
|
+
const record = approvalsFor(this.controlDir).get(this.key(actionId)) ?? null
|
|
121
|
+
if (record && (!isApprovalRecord(record) || record.actionId !== actionId))
|
|
122
|
+
throw new Error('Invalid approval record shape')
|
|
123
|
+
return record
|
|
134
124
|
}
|
|
135
125
|
}
|
package/src/channel-backend.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
2
1
|
import type { Config } from './config.js'
|
|
3
2
|
import type { RunRecord } from './runs.js'
|
|
4
|
-
import {
|
|
3
|
+
import { readStagedAttachment } from './files.js'
|
|
5
4
|
|
|
6
5
|
// Application-owned jobs; no CLI state, provider credentials or business routing here.
|
|
7
6
|
export async function dispatchChannel(config: Config, run: RunRecord): Promise<string | null> {
|
|
@@ -14,7 +13,7 @@ export async function dispatchChannel(config: Config, run: RunRecord): Promise<s
|
|
|
14
13
|
for (const item of run.items ?? []) {
|
|
15
14
|
let attachment
|
|
16
15
|
if (item.attachment) {
|
|
17
|
-
const bytes = await
|
|
16
|
+
const bytes = await readStagedAttachment([config.controlDir, config.workspace], item.attachment.path)
|
|
18
17
|
if (bytes.length > 12 * 1024 * 1024) throw new Error('Channel attachment exceeds 12 MB')
|
|
19
18
|
attachment = { type: item.attachment.type, data: bytes.toString('base64') }
|
|
20
19
|
}
|
package/src/config.ts
CHANGED
|
@@ -26,10 +26,6 @@ export type Config = ControlConfig & {
|
|
|
26
26
|
applicationHost?: string
|
|
27
27
|
geminiApiKey?: string
|
|
28
28
|
openaiApiKey?: string
|
|
29
|
-
pagerDutyRoutingKey?: string
|
|
30
|
-
pagerDutyStocksHealthUrl?: string
|
|
31
|
-
pagerDutyPollMs?: number
|
|
32
|
-
pagerDutyFailureThreshold?: number
|
|
33
29
|
}
|
|
34
30
|
|
|
35
31
|
const positiveInteger = (value: string | undefined, name: string, fallback?: number): number => {
|
|
@@ -49,12 +45,20 @@ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): Control
|
|
|
49
45
|
}
|
|
50
46
|
}
|
|
51
47
|
|
|
48
|
+
export const telegramToken = (env: NodeJS.ProcessEnv = process.env): string => {
|
|
49
|
+
const token = env.TELEGRAM_BOT_TOKEN?.trim() || ''
|
|
50
|
+
if (token && !/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('TELEGRAM_BOT_TOKEN is malformed; refusing to start Telegram transport')
|
|
51
|
+
return token
|
|
52
|
+
}
|
|
53
|
+
|
|
52
54
|
export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const telegramBotToken =
|
|
55
|
+
const legacy = env.EZ_TELEGRAM_ENABLED
|
|
56
|
+
if (legacy !== undefined && legacy !== 'true' && legacy !== 'false') throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
|
|
57
|
+
const telegramBotToken = telegramToken(env)
|
|
58
|
+
const telegramEnabled = Boolean(telegramBotToken)
|
|
59
|
+
if (legacy !== undefined && (legacy === 'false') === telegramEnabled)
|
|
60
|
+
throw new Error('EZ_TELEGRAM_ENABLED was removed; it contradicts the inferred Telegram transport (a token means enabled). Unset EZ_TELEGRAM_ENABLED, and remove any stale TELEGRAM_BOT_TOKEN to stay application-only.')
|
|
56
61
|
if (!telegramEnabled && !env.EZ_APPLICATION_PORT) throw new Error('Application-only execution requires EZ_APPLICATION_PORT')
|
|
57
|
-
if (telegramEnabled && !telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
|
|
58
62
|
|
|
59
63
|
if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
|
|
60
64
|
if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
|
|
@@ -63,17 +67,6 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
63
67
|
const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
|
|
64
68
|
if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
|
|
65
69
|
if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || transport !== 'local')) throw new Error('External Codex sandbox requires native local execution')
|
|
66
|
-
const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
|
|
67
|
-
const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
|
|
68
|
-
if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
|
|
69
|
-
throw new Error('PAGERDUTY_ROUTING_KEY is required when EZ_PAGERDUTY_STOCKS_HEALTH_URL is set')
|
|
70
|
-
if (pagerDutyStocksHealthUrl) {
|
|
71
|
-
let url: URL
|
|
72
|
-
try { url = new URL(pagerDutyStocksHealthUrl) }
|
|
73
|
-
catch { throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL') }
|
|
74
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash)
|
|
75
|
-
throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL without credentials or a fragment')
|
|
76
|
-
}
|
|
77
70
|
return {
|
|
78
71
|
...loadControlConfig(env),
|
|
79
72
|
telegramEnabled,
|
|
@@ -92,13 +85,5 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
92
85
|
channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
|
|
93
86
|
geminiApiKey: env.GEMINI_API_KEY?.trim(),
|
|
94
87
|
openaiApiKey: env.OPENAI_API_KEY?.trim(),
|
|
95
|
-
pagerDutyRoutingKey,
|
|
96
|
-
pagerDutyStocksHealthUrl,
|
|
97
|
-
pagerDutyPollMs: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
|
|
98
|
-
? positiveInteger(env.EZ_PAGERDUTY_POLL_SECONDS, 'EZ_PAGERDUTY_POLL_SECONDS', 30) * 1_000
|
|
99
|
-
: undefined,
|
|
100
|
-
pagerDutyFailureThreshold: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
|
|
101
|
-
? positiveInteger(env.EZ_PAGERDUTY_FAILURE_THRESHOLD, 'EZ_PAGERDUTY_FAILURE_THRESHOLD', 3)
|
|
102
|
-
: undefined,
|
|
103
88
|
}
|
|
104
89
|
}
|