@jc_stack/ez-agents 0.1.0-beta.13 → 0.1.0-beta.18
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 +3 -0
- package/.env.example +15 -0
- package/AGENTS.md +6 -3
- package/CHANGELOG.md +49 -0
- package/CONTRIBUTING.md +34 -4
- package/README.md +3 -0
- package/compose.yaml +8 -1
- package/docker/run.ts +1 -1
- package/docs/architecture/ai-selection.md +8 -0
- package/docs/architecture/authority-boundaries.md +24 -1
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/docker-runtime.md +35 -0
- package/docs/host-service.md +19 -0
- package/docs/pagerduty.md +42 -0
- package/docs/plugin-catalog.md +27 -10
- package/docs/plugin-contributions.md +9 -0
- package/docs/plugins.md +12 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/scheduling.md +30 -4
- package/docs/selective-monitoring.md +12 -4
- package/docs/setup.md +39 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/package.json +6 -3
- package/scripts/generate-publish-caller.mjs +60 -0
- package/scripts/smoke-busy-reply.ts +58 -0
- package/scripts/trusted-beta.mjs +289 -0
- package/src/agent-guidance.ts +5 -0
- package/src/ai-cli.ts +2 -1
- package/src/ai.ts +15 -5
- package/src/client-defaults.ts +29 -13
- package/src/codex-session.ts +4 -2
- package/src/config.ts +29 -1
- package/src/control-state.ts +24 -7
- package/src/desktop-bridge.ts +8 -1
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +2 -1
- package/src/executor.ts +31 -6
- package/src/failure.ts +32 -0
- package/src/host-executor.ts +22 -13
- package/src/identity.ts +8 -3
- package/src/inbox.ts +7 -3
- package/src/index.ts +207 -79
- package/src/install-tools.mjs +2 -2
- package/src/menu.ts +6 -4
- package/src/model-policy.ts +15 -0
- package/src/owner.ts +3 -3
- package/src/pagerduty.ts +109 -0
- package/src/plugins/manager.mjs +47 -8
- package/src/plugins/shared.mjs +76 -0
- package/src/repair-policy.ts +13 -0
- package/src/reply-context.ts +67 -0
- package/src/reply-executor.ts +54 -0
- package/src/reply-mcp.ts +23 -0
- package/src/runs.ts +15 -4
- package/src/schedule-cli.ts +36 -7
- package/src/scheduler.ts +12 -3
- package/src/setup.ts +2 -1
- package/src/software-status.ts +5 -5
- package/src/task-cli.ts +3 -3
- package/src/task-executor.ts +7 -5
- package/src/tasks.ts +35 -17
- package/src/telegram-source.ts +94 -0
- package/src/updates/artifact.mjs +16 -0
- package/src/updates/binding.mjs +3 -1
- package/src/updates/control.mjs +4 -4
- package/src/updates/runtime.mjs +3 -1
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +6 -0
- package/templates/agent-guidance.md +13 -0
- package/templates/failure-review.md +9 -0
- package/templates/maintainer-purpose.md +15 -0
- package/templates/updates.md +2 -2
- package/test/agent-guidance.test.ts +110 -0
- package/test/ai-cli.test.ts +7 -6
- package/test/ai.test.ts +41 -0
- package/test/busy-reply-relay.test.ts +41 -0
- package/test/client-defaults.test.ts +37 -5
- package/test/codex-context.test.ts +5 -2
- package/test/codex-session.test.ts +4 -2
- package/test/config.test.ts +29 -0
- package/test/executor.test.ts +11 -1
- package/test/failure.test.ts +250 -0
- package/test/group-owner.test.ts +36 -0
- package/test/host-executor.test.ts +38 -7
- package/test/intake-relay.test.ts +141 -4
- package/test/model-policy.test.ts +61 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +3 -2
- package/test/relay.test.ts +2 -2
- package/test/repair-policy.test.ts +23 -0
- package/test/reply.test.ts +131 -0
- package/test/schedule-cli.test.ts +8 -2
- package/test/shared-services.test.mjs +98 -0
- package/test/software-status.test.ts +5 -5
- package/test/task-native.test.ts +2 -2
- package/test/tasks.test.ts +14 -6
- package/test/telegram-source.test.ts +75 -0
- package/test/trusted-beta.test.mjs +224 -0
- package/test/updates.test.mjs +35 -3
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { mkdir, readFile, writeFile, mkdtemp, rm, rename } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { resolve, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const REGISTRY = 'https://registry.npmjs.org/';
|
|
11
|
+
const CORE = 'jdorado/ez-agents';
|
|
12
|
+
const MAX_ARTIFACT = 100 * 1024 * 1024;
|
|
13
|
+
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
|
14
|
+
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
15
|
+
|
|
16
|
+
export function publishEnvironment(env) {
|
|
17
|
+
const allowed = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'CI',
|
|
18
|
+
'GITHUB_ACTIONS', 'GITHUB_WORKFLOW', 'GITHUB_WORKFLOW_REF', 'GITHUB_WORKFLOW_SHA',
|
|
19
|
+
'GITHUB_REPOSITORY', 'GITHUB_REPOSITORY_ID', 'GITHUB_REPOSITORY_OWNER', 'GITHUB_REPOSITORY_OWNER_ID',
|
|
20
|
+
'GITHUB_SERVER_URL', 'GITHUB_REF', 'GITHUB_REF_NAME', 'GITHUB_REF_TYPE', 'GITHUB_SHA',
|
|
21
|
+
'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT', 'GITHUB_EVENT_NAME', 'GITHUB_JOB',
|
|
22
|
+
'GITHUB_ACTOR', 'GITHUB_ACTOR_ID', 'RUNNER_ENVIRONMENT', 'RUNNER_OS', 'RUNNER_ARCH',
|
|
23
|
+
'ACTIONS_ID_TOKEN_REQUEST_URL', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'];
|
|
24
|
+
return Object.fromEntries(allowed.filter(key => env[key] !== undefined).map(key => [key, env[key]]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function publishArguments(path, npmrc, globalNpmrc) {
|
|
28
|
+
return ['publish', path, '--fetch-retries=0', '--ignore-scripts', '--provenance', '--access', 'public', '--tag', 'latest', '--registry', REGISTRY, '--userconfig', npmrc, '--globalconfig', globalNpmrc];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function identity(env) {
|
|
32
|
+
const value = { repository: env.RELEASE_REPOSITORY, package: env.RELEASE_PACKAGE,
|
|
33
|
+
version: env.RELEASE_VERSION, sourceSha: env.RELEASE_SOURCE_SHA,
|
|
34
|
+
sha256: env.RELEASE_SHA256, releaseId: Number(env.RELEASE_ID), requiredChecks: JSON.parse(env.RELEASE_REQUIRED_CHECKS || 'null') };
|
|
35
|
+
assert(/^[1-9]\d*$/.test(env.RELEASE_ID || '') && Number.isSafeInteger(value.releaseId), 'Invalid draft release ID');
|
|
36
|
+
assert(/^jdorado\/[A-Za-z0-9_.-]+$/.test(value.repository || ''), 'Invalid repository');
|
|
37
|
+
assert(/^@jc_stack\/[a-z0-9-]+$/.test(value.package || ''), 'Invalid package');
|
|
38
|
+
assert(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.(0|[1-9]\d*)$/.test(value.version || ''), 'Only immutable beta.N versions are allowed');
|
|
39
|
+
assert(/^[a-f0-9]{40}$/.test(value.sourceSha || ''), 'Invalid source SHA');
|
|
40
|
+
assert(/^[a-f0-9]{64}$/.test(value.sha256 || ''), 'Invalid artifact SHA256');
|
|
41
|
+
assert(Array.isArray(value.requiredChecks) && value.requiredChecks.length > 0 && value.requiredChecks.every(x => typeof x === 'string' && x.length > 0) && new Set(value.requiredChecks).size === value.requiredChecks.length, 'Required checks must be a nonempty unique list');
|
|
42
|
+
assert(env.GITHUB_REPOSITORY === value.repository, 'Caller repository mismatch');
|
|
43
|
+
assert(env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.GITHUB_REF === 'refs/heads/main', 'Must manually dispatch from main');
|
|
44
|
+
assert(env.GITHUB_SHA === value.sourceSha, 'Source must equal dispatched source');
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateManifest(manifest, expected) {
|
|
49
|
+
assert(manifest.name === expected.package && manifest.version === expected.version, 'Package identity mismatch');
|
|
50
|
+
assert(manifest.private !== true, 'Private packages cannot be published');
|
|
51
|
+
const repo = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url;
|
|
52
|
+
assert(repo === `git+https://github.com/${expected.repository}.git` || repo === `https://github.com/${expected.repository}.git` || repo === `https://github.com/${expected.repository}`, 'Package repository mismatch');
|
|
53
|
+
const config = manifest.publishConfig || {};
|
|
54
|
+
assert(Object.keys(config).every(key => ['access', 'tag', 'registry', 'provenance'].includes(key)), 'Unsupported publish configuration');
|
|
55
|
+
assert(config.access === undefined || config.access === 'public', 'Invalid publish access');
|
|
56
|
+
assert(config.tag === undefined || config.tag === 'latest', 'Invalid publish tag');
|
|
57
|
+
assert(config.registry === undefined || config.registry === REGISTRY || config.registry === REGISTRY.slice(0, -1), 'Invalid publish registry');
|
|
58
|
+
assert(config.provenance !== false, 'Provenance must not be disabled');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function validateReceipt(receipt, expected) {
|
|
62
|
+
for (const key of ['repository', 'package', 'version', 'sourceSha', 'sha256']) {
|
|
63
|
+
assert(receipt[key] === expected[key], `Receipt ${key} mismatch`);
|
|
64
|
+
}
|
|
65
|
+
const base = `https://github.com/${expected.repository}/`;
|
|
66
|
+
assert(typeof receipt.independentReviewUrl === 'string' && receipt.independentReviewUrl.startsWith(base) && /^pull\/[1-9]\d*(?:#[A-Za-z0-9_-]+)?$/.test(receipt.independentReviewUrl.slice(base.length)), 'Missing independent review PR URL');
|
|
67
|
+
assert(Array.isArray(receipt.testEvidenceUrls) && receipt.testEvidenceUrls.length > 0 && receipt.testEvidenceUrls.every(url => typeof url === 'string' && url.startsWith(base) && /^(?:actions\/runs|pull|issues)\/[1-9]\d*(?:#[A-Za-z0-9_-]+)?$/.test(url.slice(base.length))), 'Missing test evidence URLs');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function validateChecks(checks, expected) {
|
|
71
|
+
const selected = [];
|
|
72
|
+
for (const name of expected.requiredChecks) {
|
|
73
|
+
const runs = checks.filter(check => check.name === name && check.head_sha === expected.sourceSha && check.app?.id === 15368).sort((a, b) => b.id - a.id);
|
|
74
|
+
assert(runs.length > 0 && runs[0].status === 'completed' && runs[0].conclusion === 'success', `Required GitHub Actions check not successful: ${name}`);
|
|
75
|
+
selected.push(runs[0]);
|
|
76
|
+
}
|
|
77
|
+
return selected;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function tarManifest(path) {
|
|
81
|
+
const options = { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 30_000 };
|
|
82
|
+
const entries = execFileSync('tar', ['-tzf', path], options).trim().split('\n');
|
|
83
|
+
assert(entries.filter(name => name === 'package/package.json').length === 1, 'Tarball must have exactly one package/package.json');
|
|
84
|
+
assert(entries.every(name => name.startsWith('package/') && !name.split('/').includes('..') && !name.includes('\\')), 'Unsafe tarball path');
|
|
85
|
+
const details = execFileSync('tar', ['-tvzf', path], options).trim().split('\n');
|
|
86
|
+
assert(details.every(line => line.startsWith('-') || line.startsWith('d')), 'Tarball links or special files are forbidden');
|
|
87
|
+
return JSON.parse(execFileSync('tar', ['-xOf', path, 'package/package.json'], { ...options, maxBuffer: 1024 * 1024 }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function responseBytes(response, max = MAX_ARTIFACT) {
|
|
91
|
+
assert(response.ok, `HTTP ${response.status} while reading release evidence`);
|
|
92
|
+
assert(Number(response.headers.get('content-length') || 0) <= max, 'Response too large');
|
|
93
|
+
let size = 0; const chunks = [];
|
|
94
|
+
for await (const chunk of response.body) { size += chunk.length; assert(size <= max, 'Response too large'); chunks.push(chunk); }
|
|
95
|
+
return Buffer.concat(chunks);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function githubClient(token, fetcher = fetch) {
|
|
99
|
+
return async (path, binary = false) => {
|
|
100
|
+
assert(path.startsWith('/repos/'), 'Invalid GitHub API path');
|
|
101
|
+
const response = await fetcher(`https://api.github.com${path}`, {
|
|
102
|
+
headers: { Accept: binary ? 'application/octet-stream' : 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}), 'X-GitHub-Api-Version': '2022-11-28' },
|
|
103
|
+
redirect: 'manual', signal: AbortSignal.timeout(30_000),
|
|
104
|
+
});
|
|
105
|
+
if (binary && [301, 302, 303, 307, 308].includes(response.status)) {
|
|
106
|
+
const location = new URL(response.headers.get('location'));
|
|
107
|
+
assert(location.protocol === 'https:' && (location.hostname === 'release-assets.githubusercontent.com' || location.hostname === 'objects.githubusercontent.com'), 'Unexpected asset redirect');
|
|
108
|
+
return responseBytes(await fetcher(location, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
|
|
109
|
+
}
|
|
110
|
+
const bytes = await responseBytes(response, binary ? MAX_ARTIFACT : 8 * 1024 * 1024);
|
|
111
|
+
return binary ? bytes : JSON.parse(bytes.toString());
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function validateSource(expected, api) {
|
|
116
|
+
const repo = await api(`/repos/${expected.repository}`);
|
|
117
|
+
assert(repo.full_name === expected.repository && repo.private === false && repo.visibility === 'public' && repo.default_branch === 'main' && !repo.archived, 'Repository must be public and active on main');
|
|
118
|
+
const catalog = await api(`/repos/${CORE}/contents/docs/plugin-catalog.md?ref=main`);
|
|
119
|
+
const catalogText = Buffer.from(catalog.content, 'base64').toString('utf8');
|
|
120
|
+
const enrolled = catalogText.split('\n').some(line => line.startsWith('|') && line.includes(`](https://github.com/${expected.repository})`) && line.includes('`' + expected.package + '`'));
|
|
121
|
+
assert(expected.repository === CORE ? expected.package === '@jc_stack/ez-agents' : enrolled, 'Repository/package is not enrolled in public catalog');
|
|
122
|
+
const main = await api(`/repos/${expected.repository}/git/ref/heads/main`);
|
|
123
|
+
assert(main.object?.sha === expected.sourceSha, 'Source is no longer current main');
|
|
124
|
+
const manifest = await api(`/repos/${expected.repository}/contents/package.json?ref=${expected.sourceSha}`);
|
|
125
|
+
const sourceManifest = JSON.parse(Buffer.from(manifest.content, 'base64').toString('utf8'));
|
|
126
|
+
validateManifest(sourceManifest, expected);
|
|
127
|
+
let tag = (await api(`/repos/${expected.repository}/git/ref/tags/v${expected.version}`)).object;
|
|
128
|
+
for (let depth = 0; tag?.type === 'tag' && depth < 5; depth++) tag = (await api(`/repos/${expected.repository}/git/tags/${tag.sha}`)).object;
|
|
129
|
+
assert(tag?.type === 'commit' && tag.sha === expected.sourceSha, 'Release tag does not identify approved source');
|
|
130
|
+
// Select CI identity before considering outcomes: tag/PR runs at the same SHA
|
|
131
|
+
// must neither shadow main CI nor let a failed latest main run fall back.
|
|
132
|
+
const runs = [];
|
|
133
|
+
for (let page = 1; ; page++) {
|
|
134
|
+
assert(page <= 100, 'Too many workflow run pages');
|
|
135
|
+
const result = await api(`/repos/${expected.repository}/actions/workflows/ci.yml/runs?branch=main&event=push&head_sha=${expected.sourceSha}&per_page=100&page=${page}`);
|
|
136
|
+
assert(Array.isArray(result.workflow_runs), 'Invalid workflow run response');
|
|
137
|
+
runs.push(...result.workflow_runs.filter(run => run.head_sha === expected.sourceSha && run.event === 'push' && run.head_branch === 'main' && run.path === '.github/workflows/ci.yml'));
|
|
138
|
+
if (result.workflow_runs.length < 100) break;
|
|
139
|
+
}
|
|
140
|
+
const run = runs.sort((a, b) => b.id - a.id)[0];
|
|
141
|
+
assert(run && Number.isSafeInteger(run.id) && run.id > 0 && Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0 && run.status === 'completed' && run.conclusion === 'success', 'Required check is not successful main push CI');
|
|
142
|
+
const jobs = [];
|
|
143
|
+
for (let page = 1; ; page++) {
|
|
144
|
+
assert(page <= 100, 'Too many job pages');
|
|
145
|
+
const result = await api(`/repos/${expected.repository}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`);
|
|
146
|
+
assert(Array.isArray(result.jobs), 'Invalid job response');
|
|
147
|
+
jobs.push(...result.jobs);
|
|
148
|
+
if (result.jobs.length < 100) break;
|
|
149
|
+
}
|
|
150
|
+
const checks = [];
|
|
151
|
+
for (const name of expected.requiredChecks) {
|
|
152
|
+
const matches = jobs.filter(job => job.name === name);
|
|
153
|
+
assert(matches.length === 1, `Missing or ambiguous required CI job: ${name}`);
|
|
154
|
+
const job = matches[0];
|
|
155
|
+
assert(job.run_id === run.id && job.run_attempt === run.run_attempt && job.head_sha === expected.sourceSha && job.status === 'completed' && job.conclusion === 'success', `Required CI job not successful: ${name}`);
|
|
156
|
+
const prefix = `https://api.github.com/repos/${expected.repository}/check-runs/`;
|
|
157
|
+
assert(typeof job.check_run_url === 'string' && job.check_run_url.startsWith(prefix) && /^[1-9]\d*$/.test(job.check_run_url.slice(prefix.length)), 'Invalid job check evidence URL');
|
|
158
|
+
const check = await api(job.check_run_url.slice('https://api.github.com'.length));
|
|
159
|
+
assert(check.name === name && check.details_url === `https://github.com/${expected.repository}/actions/runs/${run.id}/job/${job.id}`, 'Check does not identify the required CI job');
|
|
160
|
+
checks.push(check);
|
|
161
|
+
}
|
|
162
|
+
validateChecks(checks, expected);
|
|
163
|
+
return sourceManifest;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function registryState(expected, fetcher = fetch) {
|
|
167
|
+
const response = await fetcher(`${REGISTRY}${encodeURIComponent(expected.package)}`, { signal: AbortSignal.timeout(30_000), redirect: 'error', headers: { Accept: 'application/json' } });
|
|
168
|
+
assert(response.status !== 404, 'Package does not exist: first reviewed beta needs interactive registry-owner publication before trust enrollment');
|
|
169
|
+
const data = JSON.parse((await responseBytes(response, 32 * 1024 * 1024)).toString());
|
|
170
|
+
assert(data.name === expected.package && data.versions && data['dist-tags'], 'Invalid registry package metadata');
|
|
171
|
+
const published = data.versions[expected.version];
|
|
172
|
+
if (!published) return { exists: false, latest: data['dist-tags'].latest ?? null, beta: data['dist-tags'].beta ?? null };
|
|
173
|
+
assert(published.name === expected.package && published.version === expected.version, 'Registry version identity mismatch');
|
|
174
|
+
const url = new URL(published.dist?.tarball);
|
|
175
|
+
assert(url.origin === REGISTRY.slice(0, -1), 'Unexpected registry tarball host');
|
|
176
|
+
const bytes = await responseBytes(await fetcher(url, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
|
|
177
|
+
assert(sha256(bytes) === expected.sha256, 'Existing registry artifact differs; never overwrite');
|
|
178
|
+
return { exists: true, latest: data['dist-tags'].latest ?? null, beta: data['dist-tags'].beta ?? null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function validate(output, env = process.env, api = githubClient(env.GH_TOKEN)) {
|
|
182
|
+
const expected = identity(env);
|
|
183
|
+
const sourceManifest = await validateSource(expected, api);
|
|
184
|
+
const release = await api(`/repos/${expected.repository}/releases/${expected.releaseId}`);
|
|
185
|
+
assert(release.id === expected.releaseId && release.draft === true && release.prerelease === true && release.tag_name === `v${expected.version}`, 'Candidate must be a draft prerelease');
|
|
186
|
+
const asset = name => {
|
|
187
|
+
const matches = (release.assets || []).filter(item => item.name === name && item.state === 'uploaded');
|
|
188
|
+
assert(matches.length === 1 && Number.isSafeInteger(matches[0].id), `Missing or ambiguous asset: ${name}`);
|
|
189
|
+
return matches[0];
|
|
190
|
+
};
|
|
191
|
+
const candidateAsset = asset('candidate.tgz');
|
|
192
|
+
const receiptAsset = asset('release-receipt.json');
|
|
193
|
+
const bytes = await api(`/repos/${expected.repository}/releases/assets/${candidateAsset.id}`, true);
|
|
194
|
+
assert(sha256(bytes) === expected.sha256, 'Candidate artifact SHA256 mismatch');
|
|
195
|
+
const receipt = JSON.parse((await api(`/repos/${expected.repository}/releases/assets/${receiptAsset.id}`, true)).toString());
|
|
196
|
+
validateReceipt(receipt, expected);
|
|
197
|
+
// Dispatch is the authorized maintainer's attestation to these review/test URLs.
|
|
198
|
+
// Their existence alone is not an independent review verdict.
|
|
199
|
+
const reviewPr = Number(new URL(receipt.independentReviewUrl).pathname.split('/')[4]);
|
|
200
|
+
const pr = await api(`/repos/${expected.repository}/pulls/${reviewPr}`);
|
|
201
|
+
assert(pr.merged === true && pr.base?.repo?.full_name === expected.repository && pr.base?.ref === 'main' && pr.merge_commit_sha === expected.sourceSha, 'Review PR must be merged as the exact release source');
|
|
202
|
+
await mkdir(output, { recursive: true });
|
|
203
|
+
const tarball = resolve(output, 'candidate.tgz');
|
|
204
|
+
await writeFile(tarball, bytes, { flag: 'wx' });
|
|
205
|
+
const packedManifest = tarManifest(tarball);
|
|
206
|
+
validateManifest(packedManifest, expected);
|
|
207
|
+
assert(isDeepStrictEqual(packedManifest, sourceManifest), 'Packed manifest differs from approved source manifest');
|
|
208
|
+
await writeFile(resolve(output, 'validated.json'), JSON.stringify({ schema: 1, expected, receipt, releaseId: release.id, candidateAssetId: candidateAsset.id }, null, 2) + '\n', { flag: 'wx' });
|
|
209
|
+
return { sourceSha: expected.sourceSha, sha256: expected.sha256, version: expected.version };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function publishOnce(expected, { readState, publishTarball, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), report = console.log, record = async () => {}, allowWrite = true }) {
|
|
213
|
+
const before = await readState();
|
|
214
|
+
await record({ phase: 'preflight', before });
|
|
215
|
+
if (before.exists) {
|
|
216
|
+
assert(before.latest === expected.version, 'Artifact exists but latest tag differs; reconcile without republishing');
|
|
217
|
+
return { status: 'already-published', version: expected.version, sha256: expected.sha256 };
|
|
218
|
+
}
|
|
219
|
+
assert(allowWrite, 'Rerun cannot repeat publication: reconcile registry state and create a fresh authorized dispatch if a new attempt is needed');
|
|
220
|
+
await record({ phase: 'write-started', before });
|
|
221
|
+
let writeError;
|
|
222
|
+
try { await publishTarball(); } catch (error) { writeError = error; }
|
|
223
|
+
let lastError;
|
|
224
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
225
|
+
if (attempt) await sleep(5000);
|
|
226
|
+
try {
|
|
227
|
+
const after = await readState();
|
|
228
|
+
await record({ phase: 'readback', before, after, publishCommandFailed: Boolean(writeError), attempt });
|
|
229
|
+
if (after.exists && after.latest === expected.version) {
|
|
230
|
+
if (writeError) report('Publish command was uncertain; registry readback verified exact artifact and latest tag.');
|
|
231
|
+
return { status: 'published', version: expected.version, sha256: expected.sha256 };
|
|
232
|
+
}
|
|
233
|
+
lastError = new Error('Exact artifact and latest tag not yet verified');
|
|
234
|
+
} catch (error) { lastError = error; await record({ phase: 'readback-error', before, error: error.message, attempt }); }
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Publication unresolved; do not repeat the write before registry reconciliation: ${lastError?.message || writeError?.message}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function publish(output, env = process.env) {
|
|
240
|
+
const expected = identity(env);
|
|
241
|
+
const bundle = JSON.parse(await readFile(resolve(output, 'validated.json'), 'utf8'));
|
|
242
|
+
assert(bundle.schema === 1 && JSON.stringify(bundle.expected) === JSON.stringify(expected), 'Validated bundle does not match dispatched identity');
|
|
243
|
+
validateReceipt(bundle.receipt, expected);
|
|
244
|
+
const path = resolve(output, 'candidate.tgz');
|
|
245
|
+
assert(sha256(await readFile(path)) === expected.sha256, 'Validated artifact changed');
|
|
246
|
+
const packedManifest = tarManifest(path);
|
|
247
|
+
validateManifest(packedManifest, expected);
|
|
248
|
+
const sourceManifest = await validateSource(expected, githubClient(env.GH_TOKEN));
|
|
249
|
+
assert(isDeepStrictEqual(packedManifest, sourceManifest), 'Packed manifest differs from approved source manifest');
|
|
250
|
+
assert(!env.NODE_AUTH_TOKEN && !env.NPM_TOKEN, 'Token-based npm publishing is forbidden');
|
|
251
|
+
const temporary = await mkdtemp(join(tmpdir(), 'trusted-beta-'));
|
|
252
|
+
try {
|
|
253
|
+
const npmrc = join(temporary, 'npmrc');
|
|
254
|
+
const globalNpmrc = join(temporary, 'global-npmrc');
|
|
255
|
+
await writeFile(npmrc, 'registry=https://registry.npmjs.org/\n');
|
|
256
|
+
await writeFile(globalNpmrc, '');
|
|
257
|
+
const events = [];
|
|
258
|
+
const record = async event => {
|
|
259
|
+
events.push({ at: new Date().toISOString(), ...event });
|
|
260
|
+
const receiptPath = resolve(output, 'publication-receipt.json');
|
|
261
|
+
await writeFile(`${receiptPath}.${process.pid}.tmp`, JSON.stringify({ schema: 1, expected, events }, null, 2) + '\n', { mode: 0o600 });
|
|
262
|
+
await rename(`${receiptPath}.${process.pid}.tmp`, receiptPath);
|
|
263
|
+
};
|
|
264
|
+
try {
|
|
265
|
+
const result = await publishOnce(expected, {
|
|
266
|
+
record,
|
|
267
|
+
allowWrite: env.GITHUB_RUN_ATTEMPT === '1',
|
|
268
|
+
readState: () => registryState(expected),
|
|
269
|
+
publishTarball: () => execFileSync('npm', publishArguments(path, npmrc, globalNpmrc), {
|
|
270
|
+
cwd: temporary, stdio: 'inherit', timeout: 180_000,
|
|
271
|
+
env: publishEnvironment(env),
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
await record({ phase: 'complete', result });
|
|
275
|
+
return result;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
await record({ phase: 'failed', error: error.message });
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
284
|
+
try {
|
|
285
|
+
const [mode, output] = process.argv.slice(2);
|
|
286
|
+
assert(['validate', 'publish'].includes(mode) && output && process.argv.length === 4, 'Usage: trusted-beta.mjs validate|publish OUTPUT_DIR');
|
|
287
|
+
console.log(JSON.stringify(await (mode === 'validate' ? validate(output) : publish(output))));
|
|
288
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
289
|
+
}
|
package/src/ai-cli.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util'
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { join } from 'node:path'
|
|
3
4
|
import { readModels, validateSelection, type AiPreset } from './ai.js'
|
|
4
5
|
import { ControlStore } from './control-state.js'
|
|
5
6
|
|
|
6
7
|
const {values,positionals}=parseArgs({allowPositionals:true,options:{cli:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
|
|
7
8
|
if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
|
|
8
|
-
const catalog=await readModels()
|
|
9
|
+
const catalog=await readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR,'cli','codex'))
|
|
9
10
|
if(positionals[0]==='list')console.log(JSON.stringify(catalog))
|
|
10
11
|
else if(positionals[0]==='select'){
|
|
11
12
|
const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',model:values.model,effort:values.effort}
|
package/src/ai.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CODEX_DEFAULT_MODEL, DEFAULT_EFFORT, assertEffort, allowedEffort } from './model-policy.js'
|
|
1
2
|
import { access, readFile } from 'node:fs/promises'
|
|
2
3
|
import { constants } from 'node:fs'
|
|
3
4
|
import { homedir } from 'node:os'
|
|
@@ -21,10 +22,18 @@ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
|
|
|
21
22
|
return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
|
|
22
23
|
}
|
|
23
24
|
export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
|
|
25
|
+
// The seed delegates model selection to the native client. Project its resolved
|
|
26
|
+
// settings for status without pinning future conversations to that snapshot.
|
|
27
|
+
export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
|
|
28
|
+
preset.cli === 'codex' && !preset.model && !preset.effort
|
|
29
|
+
? discovered.find((candidate) => candidate.cli === preset.cli) ?? preset
|
|
30
|
+
: preset
|
|
24
31
|
export const initialPreset = (cli: string): AiPreset => {
|
|
25
32
|
const key = executorKey(cli)
|
|
26
33
|
return {
|
|
27
34
|
id: 'initial', name: `${resolveExecutor(key).name} · current setup`, cli: key,
|
|
35
|
+
...(key === 'codex' || key === 'codex-gui'
|
|
36
|
+
? { model: CODEX_DEFAULT_MODEL, effort: DEFAULT_EFFORT } : {}),
|
|
28
37
|
...(key === 'opencode'
|
|
29
38
|
? { model: process.env.OPENCODE_MODEL || 'opencode/nemotron-3.5-lightning-free' } : {}),
|
|
30
39
|
}
|
|
@@ -40,17 +49,17 @@ export const installed = async (cli: string): Promise<boolean> => {
|
|
|
40
49
|
|
|
41
50
|
// Read only metadata from native client catalogs. Never import prompts, credentials,
|
|
42
51
|
// provider configuration, or model instructions into relay context.
|
|
43
|
-
export const readModels = async (home = homedir(), available = installed): Promise<ModelChoice[]> => {
|
|
52
|
+
export const readModels = async (home = homedir(), available = installed, codexHome = join(home, '.codex')): Promise<ModelChoice[]> => {
|
|
44
53
|
const models: ModelChoice[] = []
|
|
45
54
|
const record = (value: unknown): Record<string, unknown> =>
|
|
46
55
|
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
47
56
|
const efforts = (value: unknown, key: string): string[] =>
|
|
48
|
-
(Array.isArray(value) ? value : []).map((e: unknown) => record(e)[key]).filter(safe)
|
|
57
|
+
(Array.isArray(value) ? value : []).map((e: unknown) => record(e)[key]).filter(safe).filter(allowedEffort)
|
|
49
58
|
const json = async (file: string) => {
|
|
50
|
-
try { return record(JSON.parse(await readFile(
|
|
59
|
+
try { return record(JSON.parse(await readFile(file, 'utf8'))) } catch { return {} }
|
|
51
60
|
}
|
|
52
61
|
if (await available('grok')) {
|
|
53
|
-
const cache = await json('.grok
|
|
62
|
+
const cache = await json(join(home, '.grok', 'models_cache.json'))
|
|
54
63
|
for (const entry of Object.values(record(cache.models))) {
|
|
55
64
|
const info = record(record(entry).info)
|
|
56
65
|
if (info.hidden || !safe(info.id)) continue
|
|
@@ -59,7 +68,7 @@ export const readModels = async (home = homedir(), available = installed): Promi
|
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
if (await available('codex')) {
|
|
62
|
-
const cache = await json('
|
|
71
|
+
const cache = await json(join(codexHome, 'models_cache.json'))
|
|
63
72
|
for (const entry of Array.isArray(cache.models) ? cache.models : []) {
|
|
64
73
|
const info = record(entry)
|
|
65
74
|
if (info.visibility !== 'list' || !safe(info.slug)) continue
|
|
@@ -80,6 +89,7 @@ export const readModels = async (home = homedir(), available = installed): Promi
|
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
|
|
92
|
+
assertEffort(p.effort)
|
|
83
93
|
if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
|
|
84
94
|
if (!p.model && !p.effort && p.cli !== 'agy') return
|
|
85
95
|
const model = catalog.find((m) => m.cli === p.cli && m.model === p.model)
|
package/src/client-defaults.ts
CHANGED
|
@@ -15,11 +15,28 @@ const value = (v: unknown): string | undefined =>
|
|
|
15
15
|
const command = async (cli: string, args: string[], cwd: string): Promise<string> =>
|
|
16
16
|
(await promisify(execFile)(cli, args, { cwd, env: executorEnvironment(), timeout: 8000, maxBuffer: 2 * 1024 * 1024 })).stdout
|
|
17
17
|
|
|
18
|
+
export const resolvedCodexDefaults = (configValue: unknown, catalogValue: unknown): Record<string, unknown> => {
|
|
19
|
+
const config = record(configValue)
|
|
20
|
+
const managed = record(record(config.models).new_thread)
|
|
21
|
+
const model = value(managed.model) ?? value(config.model)
|
|
22
|
+
const effort = value(managed.model_reasoning_effort) ?? value(config.model_reasoning_effort)
|
|
23
|
+
const catalog = (Array.isArray(catalogValue) ? catalogValue : []).map(record)
|
|
24
|
+
const selected = model
|
|
25
|
+
? catalog.find((entry) => value(entry.model) === model || value(entry.id) === model)
|
|
26
|
+
: catalog.find((entry) => entry.isDefault === true)
|
|
27
|
+
return {
|
|
28
|
+
...(model ?? value(selected?.model) ?? value(selected?.id) ? { model: model ?? value(selected?.model) ?? value(selected?.id) } : {}),
|
|
29
|
+
...(effort ?? value(selected?.defaultReasoningEffort) ? { effort: effort ?? value(selected?.defaultReasoningEffort) } : {}),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
// Native config/read resolves Codex's layers; do not reimplement TOML or start a turn.
|
|
19
|
-
export const codexDefaults = (cwd: string): Promise<Record<string, unknown>> => new Promise((resolve) => {
|
|
20
|
-
const child = spawn('codex', ['app-server'], { cwd,
|
|
34
|
+
export const codexDefaults = (cwd: string, codexHome?: string, nativeFallback = false): Promise<Record<string, unknown>> => new Promise((resolve) => {
|
|
35
|
+
const child = spawn('codex', ['app-server'], { cwd,
|
|
36
|
+
env: { ...executorEnvironment(), ...(codexHome ? { CODEX_HOME: codexHome } : {}) }, stdio: ['pipe', 'pipe', 'ignore'] })
|
|
21
37
|
const lines = createInterface({ input: child.stdout })
|
|
22
38
|
let done = false
|
|
39
|
+
let config: unknown = {}
|
|
23
40
|
const finish = (config: Record<string, unknown> = {}) => {
|
|
24
41
|
if (done) return
|
|
25
42
|
done = true
|
|
@@ -37,10 +54,12 @@ export const codexDefaults = (cwd: string): Promise<Record<string, unknown>> =>
|
|
|
37
54
|
send({ method: 'initialized', params: {} })
|
|
38
55
|
send({ id: 1, method: 'config/read', params: { includeLayers: false, cwd } })
|
|
39
56
|
} else if (message.id === 1) {
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
config = message.result?.config
|
|
58
|
+
const resolved = resolvedCodexDefaults(config, [])
|
|
59
|
+
if (!nativeFallback || (resolved.model && resolved.effort)) finish(resolved)
|
|
60
|
+
else send({ id: 2, method: 'model/list', params: { limit: 100, includeHidden: false } })
|
|
61
|
+
} else if (message.id === 2) {
|
|
62
|
+
finish(resolvedCodexDefaults(config, message.result?.data))
|
|
44
63
|
}
|
|
45
64
|
} catch { finish() }
|
|
46
65
|
})
|
|
@@ -56,7 +75,7 @@ export const grokSettings = (text: string): { model?: string; effort?: string }
|
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
export const discoverDefaults = async (cwd: string, options: {
|
|
59
|
-
home?: string; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
|
|
78
|
+
home?: string; codexHome?: string; nativeCodexFallback?: boolean; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
|
|
60
79
|
} = {}): Promise<AiPreset[]> => {
|
|
61
80
|
const home = options.home ?? homedir()
|
|
62
81
|
const available = options.available ?? installed
|
|
@@ -73,7 +92,7 @@ export const discoverDefaults = async (cwd: string, options: {
|
|
|
73
92
|
model = settings.model ?? value((await run(cli, ['models'], cwd)).match(/^Default model:\s*(\S+)/m)?.[1])
|
|
74
93
|
effort = settings.effort
|
|
75
94
|
} else if (cli === 'codex') {
|
|
76
|
-
const config = await (options.codex ?? codexDefaults)(cwd)
|
|
95
|
+
const config = await (options.codex ?? codexDefaults)(cwd, options.codexHome, options.nativeCodexFallback)
|
|
77
96
|
model = value(config.model); effort = value(config.effort)
|
|
78
97
|
} else if (cli === 'claude') {
|
|
79
98
|
// Match Claude's documented user -> project -> local settings precedence.
|
|
@@ -91,11 +110,8 @@ export const discoverDefaults = async (cwd: string, options: {
|
|
|
91
110
|
}))
|
|
92
111
|
const discovered = results.filter((p): p is AiPreset => Boolean(p))
|
|
93
112
|
if (await available('codex-gui')) {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const id = 'detected_' + createHash('sha256').update(JSON.stringify(['codex-gui', model, effort])).digest('hex').slice(0, 12)
|
|
97
|
-
discovered.push({ id, cli: 'codex-gui', model, effort,
|
|
98
|
-
name: `codex-gui · ${model || 'desktop'}${effort ? ` · ${effort}` : ''}`.slice(0, 80) })
|
|
113
|
+
const id = 'detected_' + createHash('sha256').update('codex-gui').digest('hex').slice(0, 12)
|
|
114
|
+
discovered.push({ id, cli: 'codex-gui', name: 'codex-gui · desktop' })
|
|
99
115
|
}
|
|
100
116
|
return discovered
|
|
101
117
|
}
|
package/src/codex-session.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import { executionDefaults } from './model-policy.js'
|
|
1
2
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
2
3
|
import { createInterface } from 'node:readline'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import path from 'node:path'
|
|
5
6
|
import { terminateJob } from './executor.js'
|
|
6
7
|
|
|
7
|
-
type Options = {workspace:string;controlDir:string;toolsHome?:string;model?:string;effort?:string;prompt:string;goal:boolean}
|
|
8
|
+
type Options = {workspace:string;controlDir:string;toolsHome?:string;sharedWorkspace?:string;model?:string;effort?:string;prompt:string;goal:boolean}
|
|
8
9
|
type Message = {id?:number;method?:string;params?:any;result?:any;error?:{message:string;code?:number}}
|
|
9
10
|
|
|
10
11
|
// Keep Codex's native session alive. Codex itself starts goal continuation turns;
|
|
11
12
|
// this transport never generates a continuation prompt or an Ez goal record.
|
|
12
13
|
export async function runCodexSession(options:Options, io:{launch?:()=>ChildProcess;emit?:(line:string)=>void}={}):Promise<number> {
|
|
14
|
+
options = executionDefaults('codex', options)
|
|
13
15
|
const child=io.launch?.() ?? spawn('codex',['app-server','--stdio','--disable','memories','--enable','skip_host_skill_discovery'],{cwd:options.workspace,env:process.env,stdio:['pipe','pipe','pipe']})
|
|
14
16
|
const emit=io.emit ?? (line=>process.stdout.write(line+'\n'))
|
|
15
17
|
let id=0,threadId:string|undefined,activeTurn:string|undefined,finished=false,sawTurn=false,hadGoal=options.goal
|
|
@@ -69,7 +71,7 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
69
71
|
send({method:'initialized',params:{}})
|
|
70
72
|
const result=await request('thread/start',{
|
|
71
73
|
cwd:options.workspace,approvalPolicy:'never',sandbox:'workspace-write',model:options.model,
|
|
72
|
-
config:{'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[])],
|
|
74
|
+
config:{'sandbox_workspace_write.writable_roots':[options.controlDir,...(options.toolsHome?[options.toolsHome]:[]),...(options.sharedWorkspace?[options.sharedWorkspace]:[])],
|
|
73
75
|
'sandbox_workspace_write.network_access':Boolean(options.toolsHome),...(options.effort?{model_reasoning_effort:options.effort}:{})},
|
|
74
76
|
})
|
|
75
77
|
threadId=result.thread?.id
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { repairEnabled } from './repair-policy.js'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import { homedir } from 'node:os'
|
|
3
4
|
|
|
@@ -7,20 +8,26 @@ export type ControlConfig = {
|
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export type Config = ControlConfig & {
|
|
11
|
+
repairEnabled?: boolean
|
|
10
12
|
telegramBotToken: string
|
|
11
13
|
workspace: string
|
|
12
14
|
executorTimeoutMs: number
|
|
15
|
+
codexAutoCompactTokens?: number
|
|
13
16
|
executorCli: string
|
|
14
17
|
channelBackendUrl?: string
|
|
15
18
|
channelBackendToken?: string
|
|
16
19
|
geminiApiKey?: string
|
|
17
20
|
openaiApiKey?: string
|
|
21
|
+
pagerDutyRoutingKey?: string
|
|
22
|
+
pagerDutyStocksHealthUrl?: string
|
|
23
|
+
pagerDutyPollMs?: number
|
|
24
|
+
pagerDutyFailureThreshold?: number
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
const positiveInteger = (value: string | undefined, name: string, fallback: number): number => {
|
|
21
28
|
if (!value) return fallback
|
|
22
29
|
const parsed = Number(value)
|
|
23
|
-
if (!Number.
|
|
30
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
24
31
|
throw new Error(`${name} must be a positive integer`)
|
|
25
32
|
}
|
|
26
33
|
return parsed
|
|
@@ -39,15 +46,36 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
39
46
|
if (!telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
|
|
40
47
|
|
|
41
48
|
if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
|
|
49
|
+
const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
|
|
50
|
+
const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
|
|
51
|
+
if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
|
|
52
|
+
throw new Error('PAGERDUTY_ROUTING_KEY is required when EZ_PAGERDUTY_STOCKS_HEALTH_URL is set')
|
|
53
|
+
if (pagerDutyStocksHealthUrl) {
|
|
54
|
+
let url: URL
|
|
55
|
+
try { url = new URL(pagerDutyStocksHealthUrl) }
|
|
56
|
+
catch { throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL') }
|
|
57
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash)
|
|
58
|
+
throw new Error('EZ_PAGERDUTY_STOCKS_HEALTH_URL must be an absolute HTTP(S) URL without credentials or a fragment')
|
|
59
|
+
}
|
|
42
60
|
return {
|
|
43
61
|
...loadControlConfig(env),
|
|
44
62
|
telegramBotToken,
|
|
63
|
+
repairEnabled: repairEnabled(env.EZ_REPAIR_ENABLED),
|
|
45
64
|
workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
|
|
46
65
|
executorTimeoutMs: 0,
|
|
66
|
+
codexAutoCompactTokens: positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS', 64000),
|
|
47
67
|
executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
|
|
48
68
|
channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
|
|
49
69
|
channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
|
|
50
70
|
geminiApiKey: env.GEMINI_API_KEY?.trim(),
|
|
51
71
|
openaiApiKey: env.OPENAI_API_KEY?.trim(),
|
|
72
|
+
pagerDutyRoutingKey,
|
|
73
|
+
pagerDutyStocksHealthUrl,
|
|
74
|
+
pagerDutyPollMs: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
|
|
75
|
+
? positiveInteger(env.EZ_PAGERDUTY_POLL_SECONDS, 'EZ_PAGERDUTY_POLL_SECONDS', 30) * 1_000
|
|
76
|
+
: undefined,
|
|
77
|
+
pagerDutyFailureThreshold: pagerDutyRoutingKey && pagerDutyStocksHealthUrl
|
|
78
|
+
? positiveInteger(env.EZ_PAGERDUTY_FAILURE_THRESHOLD, 'EZ_PAGERDUTY_FAILURE_THRESHOLD', 3)
|
|
79
|
+
: undefined,
|
|
52
80
|
}
|
|
53
81
|
}
|