@jc_stack/ez-agents 0.1.0-beta.13 → 0.1.0-beta.19
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 +20 -0
- package/AGENTS.md +12 -3
- package/CHANGELOG.md +63 -0
- package/CONTRIBUTING.md +62 -6
- package/Dockerfile +6 -0
- package/README.md +11 -2
- package/bin/ezenciel-agents-watch.mjs +8 -0
- package/compose.workforce-watch.yaml +33 -0
- package/compose.yaml +8 -1
- package/docker/run.ts +1 -1
- package/docs/architecture/ai-selection.md +15 -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 +28 -10
- package/docs/plugin-contributions.md +9 -0
- package/docs/plugins.md +46 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/responsive-channels.md +57 -0
- package/docs/scheduling.md +32 -4
- package/docs/selective-monitoring.md +12 -4
- package/docs/setup.md +43 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/docs/workforce-watch.md +101 -0
- package/package.json +9 -4
- 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 +9 -0
- package/src/ai-cli.ts +2 -1
- package/src/ai.ts +26 -8
- 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 +26 -7
- package/src/desktop-bridge.ts +11 -2
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +2 -1
- package/src/executor.ts +34 -7
- package/src/failure.ts +32 -0
- package/src/host-executor-client.ts +7 -1
- package/src/host-executor.ts +22 -13
- package/src/identity.ts +8 -3
- package/src/inbox.ts +7 -3
- package/src/index.ts +260 -92
- package/src/install-tools.mjs +2 -2
- package/src/menu.ts +8 -6
- package/src/model-policy.ts +18 -0
- package/src/owner.ts +3 -3
- package/src/pagerduty.ts +109 -0
- package/src/plugins/manager.mjs +115 -8
- package/src/plugins/shared.mjs +76 -0
- package/src/repair-policy.ts +13 -0
- package/src/reply-context.ts +71 -0
- package/src/reply-executor.ts +55 -0
- package/src/reply-mcp.ts +23 -0
- package/src/runs.ts +14 -16
- package/src/schedule-cli.ts +36 -7
- package/src/scheduled-tasks.ts +33 -0
- package/src/scheduler.ts +22 -4
- package/src/setup.ts +3 -2
- package/src/software-status.ts +5 -5
- package/src/task-cli.ts +3 -3
- package/src/task-executor.ts +9 -6
- 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 +5 -2
- package/src/workforce-watch-cli.ts +14 -0
- package/src/workforce-watch.ts +155 -0
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +6 -0
- package/templates/agent-guidance.md +24 -0
- package/templates/chat-guidance.md +23 -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 +125 -0
- package/test/ai-cli.test.ts +7 -6
- package/test/ai.test.ts +81 -1
- 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/event-sources.test.ts +4 -0
- package/test/executor.test.ts +11 -1
- package/test/failure.test.ts +256 -0
- package/test/group-owner.test.ts +36 -0
- package/test/host-executor.test.ts +54 -7
- package/test/intake-relay.test.ts +145 -4
- package/test/model-policy.test.ts +69 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +52 -2
- package/test/relay.test.ts +2 -2
- package/test/repair-policy.test.ts +23 -0
- package/test/reply.test.ts +153 -0
- package/test/runs.test.ts +7 -0
- package/test/schedule-cli.test.ts +10 -2
- package/test/scheduled-tasks.test.ts +43 -0
- 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
- package/test/workforce-watch.test.ts +180 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Workforce Watch
|
|
2
|
+
|
|
3
|
+
Workforce Watch is a separate, small Docker service for missed agent check-ins
|
|
4
|
+
and explicit terminal failures. It is not an agent, scheduler, log store or
|
|
5
|
+
remote-execution channel. It retains only the five latest compact activity
|
|
6
|
+
records supplied by each enrolled worker.
|
|
7
|
+
|
|
8
|
+
Run it in a failure domain separate from the workers it watches. In particular,
|
|
9
|
+
it must not rely on the Stocks VM to report a Stocks VM outage, and a separate
|
|
10
|
+
host is required to detect a Mac-wide outage.
|
|
11
|
+
|
|
12
|
+
## Start the monitor
|
|
13
|
+
|
|
14
|
+
Set private monitor-only values, never agent-workspace or source-control values:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
EZ_WATCH_ENROLL_TOKEN=<private fleet enrollment secret>
|
|
18
|
+
EZ_WATCH_PAGERDUTY_SECRET_FILE=/absolute/path/to/a-0600-workforce-pagerduty-key
|
|
19
|
+
TELEGRAM_BOT_TOKEN=<alert bot token>
|
|
20
|
+
EZ_WATCH_TELEGRAM_CHAT_ID=<owner chat id>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
PagerDuty is the fleet incident owner. Workforce Watch sends a `trigger` for
|
|
24
|
+
each missed check-in or terminal failure, then a `resolve` with the stable
|
|
25
|
+
deduplication key `ez:workforce:<worker-id>` after sustained recovery. Use one
|
|
26
|
+
approved Events API v2 routing key for the fleet; it may be the existing
|
|
27
|
+
PagerDuty integration when that is the desired escalation policy. Never put it
|
|
28
|
+
in a worker's environment. The secret file contains only that key, is mode
|
|
29
|
+
`0600`, and is mounted as `/run/secrets/workforce_watch_pagerduty`; it is never
|
|
30
|
+
a Compose or container environment value. Telegram is optional, supplementary
|
|
31
|
+
owner visibility. PagerDuty is required: a Watch deployment without its
|
|
32
|
+
routing-key secret fails closed rather than silently running without paging.
|
|
33
|
+
|
|
34
|
+
The owner may explicitly authorize the CTO bot token temporarily. A dedicated
|
|
35
|
+
alert bot remains preferable because it preserves an independent delivery identity.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
docker compose -f compose.workforce-watch.yaml up -d --build
|
|
39
|
+
curl http://127.0.0.1:9919/healthz
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The default bind is loopback. Put private TLS networking or a private reverse
|
|
43
|
+
proxy in front of it before remote workers use it; do not expose enrollment or
|
|
44
|
+
check-in traffic publicly.
|
|
45
|
+
|
|
46
|
+
## Enroll and check in
|
|
47
|
+
|
|
48
|
+
The worker selects its monitor through its configured URL. It submits the fleet
|
|
49
|
+
enrollment secret once; the response contains the worker-specific secret. Store
|
|
50
|
+
that response only in the worker's private service environment and discard it
|
|
51
|
+
from shell history and logs.
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
curl --fail-with-body -X POST "$EZ_WATCH_URL/v1/enroll" \
|
|
55
|
+
-H "Authorization: Bearer $EZ_WATCH_ENROLL_TOKEN" \
|
|
56
|
+
-H 'content-type: application/json' \
|
|
57
|
+
--data '{"workerId":"stocks-production","checkInSeconds":300,"graceSeconds":180,"severity":"critical"}'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The supervisor, not the LLM, posts check-ins using the returned worker secret:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
curl --fail-with-body -X POST "$EZ_WATCH_URL/v1/workers/stocks-production/check-in" \
|
|
64
|
+
-H "Authorization: Bearer $EZ_WATCH_WORKER_TOKEN" \
|
|
65
|
+
-H 'content-type: application/json' \
|
|
66
|
+
--data '{"status":"ok","activity":"strategy receipt delivered","runId":"daily-strategy-2026-09-11"}'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
An unrecoverable condition sends `{"status":"failed","terminal":true,...}`.
|
|
70
|
+
Optional `activity`, `error`, `runId`, and `logsHint` fields are size-limited and
|
|
71
|
+
appear in an alert. They must be public-safe operational context, never stdout,
|
|
72
|
+
prompts, holdings, credentials, or provider payloads.
|
|
73
|
+
|
|
74
|
+
## Inspect
|
|
75
|
+
|
|
76
|
+
The enrollment token reads redacted state; worker secrets never appear:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
curl --fail-with-body "$EZ_WATCH_URL/v1/workers" \
|
|
80
|
+
-H "Authorization: Bearer $EZ_WATCH_ENROLL_TOKEN"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For host replacement or a suspected worker-secret exposure, rotate that worker's
|
|
84
|
+
secret with the enrollment token. The old secret stops working immediately:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
curl --fail-with-body -X POST "$EZ_WATCH_URL/v1/workers/stocks-production/rotate" \
|
|
88
|
+
-H "Authorization: Bearer $EZ_WATCH_ENROLL_TOKEN"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Store the returned worker secret in the replacement supervisor before stopping
|
|
92
|
+
the prior one.
|
|
93
|
+
|
|
94
|
+
The service triggers once for a missed deadline or terminal failure. It sends a
|
|
95
|
+
recovery notice only after two clean check-ins by default to prevent flapping.
|
|
96
|
+
|
|
97
|
+
If a worker fails again during a partially delivered recovery, channels that
|
|
98
|
+
already accepted recovery receive a fresh trigger. Channels still open retain
|
|
99
|
+
their delivery state, so they do not receive duplicate opening alerts. This
|
|
100
|
+
applies to terminal failures, failed check-ins, and missed check-ins, including
|
|
101
|
+
after a Watch restart.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.19",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"ezenciel-agents-create": "bin/ezenciel-agents-create",
|
|
21
21
|
"ezenciel-agents-host": "bin/ezenciel-agents-host",
|
|
22
22
|
"ezenciel-agents-tools": "bin/ezenciel-agents-tools.mjs",
|
|
23
|
-
"ezenciel-agents-ai": "bin/ezenciel-agents-ai.mjs"
|
|
23
|
+
"ezenciel-agents-ai": "bin/ezenciel-agents-ai.mjs",
|
|
24
|
+
"ezenciel-agents-watch": "bin/ezenciel-agents-watch.mjs"
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
26
27
|
"default-plugins.json",
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"CONTRIBUTING.md",
|
|
36
37
|
"Dockerfile",
|
|
37
38
|
"compose.yaml",
|
|
39
|
+
"compose.workforce-watch.yaml",
|
|
38
40
|
"docker",
|
|
39
41
|
"compose.whatsapp.yaml",
|
|
40
42
|
"scripts/smoke.ts",
|
|
@@ -47,11 +49,14 @@
|
|
|
47
49
|
"test",
|
|
48
50
|
"tsconfig.json",
|
|
49
51
|
"scripts/assert-local-registry.mjs",
|
|
50
|
-
".dockerignore"
|
|
52
|
+
".dockerignore",
|
|
53
|
+
"scripts/trusted-beta.mjs",
|
|
54
|
+
"scripts/generate-publish-caller.mjs",
|
|
55
|
+
"scripts/smoke-busy-reply.ts"
|
|
51
56
|
],
|
|
52
57
|
"publishConfig": {
|
|
53
58
|
"access": "public",
|
|
54
|
-
"tag": "
|
|
59
|
+
"tag": "latest"
|
|
55
60
|
},
|
|
56
61
|
"scripts": {
|
|
57
62
|
"setup": "tsx --env-file-if-exists=.env src/setup.ts",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Print a reviewed caller. Does not write repositories or enroll npm trust.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export function generateCaller({ repository, packageName, publisherSha, checks }) {
|
|
7
|
+
assert.match(repository, /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, 'Invalid repository');
|
|
8
|
+
assert.match(packageName, /^@[a-z0-9_-]+\/[a-z0-9_.-]+$/, 'Expected scoped npm package');
|
|
9
|
+
assert.match(publisherSha, /^[a-f0-9]{40}$/, 'Pin the reviewed shared publisher to a full commit SHA');
|
|
10
|
+
assert(Array.isArray(checks) && checks.length > 0 && checks.every(c => typeof c === 'string' && /^[A-Za-z0-9 (),_.-]+$/.test(c)), 'Required CI check names are mandatory');
|
|
11
|
+
const core = repository === 'jdorado/ez-agents';
|
|
12
|
+
const reference = core ? './.github/workflows/npm-beta-shared.yml' : `jdorado/ez-agents/.github/workflows/npm-beta-shared.yml@${publisherSha}`;
|
|
13
|
+
return `# Generated by scripts/generate-publish-caller.mjs; review changes before merging.
|
|
14
|
+
name: Publish verified npm beta
|
|
15
|
+
on:
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
inputs:
|
|
18
|
+
release-id:
|
|
19
|
+
description: 'Numeric ID of the staged draft prerelease'
|
|
20
|
+
required: true
|
|
21
|
+
type: string
|
|
22
|
+
version:
|
|
23
|
+
description: 'Reviewed beta version staged as draft release vVERSION'
|
|
24
|
+
required: true
|
|
25
|
+
type: string
|
|
26
|
+
source-sha:
|
|
27
|
+
description: 'Full tested commit SHA; must be current main'
|
|
28
|
+
required: true
|
|
29
|
+
type: string
|
|
30
|
+
artifact-sha256:
|
|
31
|
+
description: 'SHA-256 of the exact independently verified candidate.tgz'
|
|
32
|
+
required: true
|
|
33
|
+
type: string
|
|
34
|
+
permissions: {}
|
|
35
|
+
jobs:
|
|
36
|
+
publish:
|
|
37
|
+
if: github.repository == '${repository}' && github.ref == 'refs/heads/main'
|
|
38
|
+
permissions:
|
|
39
|
+
contents: write
|
|
40
|
+
checks: read
|
|
41
|
+
actions: read
|
|
42
|
+
pull-requests: read
|
|
43
|
+
id-token: write
|
|
44
|
+
uses: ${reference}
|
|
45
|
+
with:
|
|
46
|
+
package: '${packageName}'
|
|
47
|
+
publisher-sha: ${core ? '${{ github.sha }}' : `'${publisherSha}'`}
|
|
48
|
+
required-checks: '${JSON.stringify(checks)}'
|
|
49
|
+
release-id: \${{ inputs.release-id }}
|
|
50
|
+
version: \${{ inputs.version }}
|
|
51
|
+
source-sha: \${{ inputs.source-sha }}
|
|
52
|
+
artifact-sha256: \${{ inputs.artifact-sha256 }}
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
57
|
+
const [repository, packageName, publisherSha, checksJson, ...extra] = process.argv.slice(2);
|
|
58
|
+
if (!checksJson || extra.length) throw new Error('Usage: node scripts/generate-publish-caller.mjs OWNER/REPO @SCOPE/PACKAGE SHARED_COMMIT_SHA \'["required CI name"]\'');
|
|
59
|
+
process.stdout.write(generateCaller({ repository, packageName, publisherSha, checks: JSON.parse(checksJson) }));
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Real restricted Codex reply while a synthetic writer stays active. No Telegram network.
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { createRelay } from '../src/index.js'
|
|
7
|
+
import { RunStore } from '../src/runs.js'
|
|
8
|
+
import { ControlStore } from '../src/control-state.js'
|
|
9
|
+
import { initialPreset } from '../src/ai.js'
|
|
10
|
+
import { startExecutorJob } from '../src/executor.js'
|
|
11
|
+
import { serveHostExecutor } from '../src/host-executor.js'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
14
|
+
import type { Update } from 'grammy/types'
|
|
15
|
+
if (process.argv.includes('--host')) {
|
|
16
|
+
const root=process.argv[process.argv.indexOf('--host')+1], abort=new AbortController()
|
|
17
|
+
process.once('SIGTERM',()=>abort.abort())
|
|
18
|
+
await serveHostExecutor({cli:'codex',agents:[{name:'fixture',workspace:join(root,'mind'),controlDir:join(root,'control'),binDir:fileURLToPath(new URL('../bin',import.meta.url)),sharedWorkspace:join(root,'mind')}]},abort.signal,async(texts,options)=>{
|
|
19
|
+
if((await new RunStore(options.controlDir).get(options.runId))?.replyOnly)return startExecutorJob(texts,options)
|
|
20
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['pipe','pipe','pipe']})
|
|
21
|
+
return {child,stdout:'',cleanup:async()=>{}}
|
|
22
|
+
})
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
const root=await mkdtemp(join(tmpdir(),'ez-busy-reply-')), workspace=join(root,'mind'), controlDir=join(root,'control')
|
|
26
|
+
await initializeWorkspace(workspace);await mkdir(controlDir,{recursive:true})
|
|
27
|
+
if(process.env.EZ_REPLY_QA_AUTH){await mkdir(join(controlDir,'cli','codex'),{recursive:true});await symlink(process.env.EZ_REPLY_QA_AUTH,join(controlDir,'cli','codex','auth.json'))}
|
|
28
|
+
const hostMode=process.argv.includes('--transport')
|
|
29
|
+
const hostEnvironment={...process.env};delete hostEnvironment.EZ_EXECUTOR_TRANSPORT
|
|
30
|
+
const host=hostMode?spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(import.meta.url),'--host',root],{env:hostEnvironment,stdio:['ignore','inherit','inherit']}):undefined
|
|
31
|
+
if(hostMode)process.env.EZ_EXECUTOR_TRANSPORT='host'
|
|
32
|
+
const control=new ControlStore(controlDir,1000),runs=new RunStore(controlDir)
|
|
33
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
34
|
+
let writer:any,replyEvents=''
|
|
35
|
+
const relay=createRelay({workspace,controlDir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(texts,options)=>{
|
|
36
|
+
if(hostMode)return startExecutorJob(texts,options)
|
|
37
|
+
const run=await runs.get(options.runId)
|
|
38
|
+
if(run?.replyOnly){const job=await startExecutorJob(texts,options);job.child.stdout?.on('data',c=>{replyEvents+=c});return job}
|
|
39
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:['pipe','pipe','pipe']});writer=child
|
|
40
|
+
return {child,stdout:'',cleanup:async()=>{}}
|
|
41
|
+
})
|
|
42
|
+
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
43
|
+
const replies:string[]=[]
|
|
44
|
+
relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:method==='sendMessage'?{message_id:replies.length,date:0,chat:{id:101,type:'private'},text:(payload as any).text}:true} as any})
|
|
45
|
+
const msg=(id:number,text:string):Update=>({update_id:id,message:{message_id:id,date:0,text,from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
|
|
46
|
+
try{
|
|
47
|
+
await relay.bot.handleUpdate(msg(1,'Long work'));await relay.drainInbox(true)
|
|
48
|
+
await relay.bot.handleUpdate(msg(2,'What is running? Also calculate 17 times 19. Use your available reply tools. Do not queue any work.'));await relay.drainInbox(true)
|
|
49
|
+
const started=Date.now()
|
|
50
|
+
while(!replies.length && Date.now()-started<120000){await relay.drainOutbox();await new Promise(r=>setTimeout(r,250))}
|
|
51
|
+
if(!replies.some(s=>s.includes('323')))throw new Error('No verified arithmetic reply: '+JSON.stringify(replies))
|
|
52
|
+
if((await runs.get('tg_1'))?.status!=='running')throw new Error('Writer stopped')
|
|
53
|
+
while((await runs.get('tg_2'))?.status==='running' && Date.now()-started<120000)await new Promise(r=>setTimeout(r,250))
|
|
54
|
+
if((await runs.get('tg_2'))?.status!=='completed')throw new Error('Reply did not finish successfully')
|
|
55
|
+
const reply=await runs.get('tg_2');if(!reply?.replyOnly)throw new Error('No restricted reply lane')
|
|
56
|
+
await writeFile(join(root,'evidence.json'),JSON.stringify({replyMs:Date.now()-started,replies,writerRunning:(await runs.get('tg_1'))?.status==='running',replyEvents},null,2))
|
|
57
|
+
console.log(JSON.stringify({root,replyMs:Date.now()-started,replies,writerRunning:true}))
|
|
58
|
+
}finally{await relay.stop();writer?.kill();host?.kill();delete process.env.EZ_EXECUTOR_TRANSPORT}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
// Resolve against the installed package, never the agent's editable workspace.
|
|
4
|
+
export const agentGuidance = (): string =>
|
|
5
|
+
readFileSync(new URL('../templates/agent-guidance.md', import.meta.url), 'utf8').trim()
|
|
6
|
+
|
|
7
|
+
// Channel behavior is shared without exposing owner workspace guidance to contacts.
|
|
8
|
+
export const chatGuidance = (): string =>
|
|
9
|
+
readFileSync(new URL('../templates/chat-guidance.md', import.meta.url), 'utf8').trim()
|
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}
|