@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
|
@@ -16,16 +16,16 @@ test('Telegram software status uses loaded version and only fresh host plugin me
|
|
|
16
16
|
const heartbeat=path.join(root,'host-executor/heartbeat.json')
|
|
17
17
|
await writeFile(heartbeat,JSON.stringify({at:Date.now(),version:'0.1.0-beta.4',plugins}))
|
|
18
18
|
const lines=await softwareStatus(root)
|
|
19
|
-
assert.equal(lines[0],`
|
|
20
|
-
assert(lines.includes('Host transport:
|
|
21
|
-
assert(lines.includes('Plugins
|
|
19
|
+
assert.equal(lines[0],`Relay: running · v${packageVersion}`)
|
|
20
|
+
assert(lines.includes('Host transport: running · v0.1.0-beta.4'))
|
|
21
|
+
assert(lines.includes('Plugins: whatsapp 0.1.0-beta.3'))
|
|
22
22
|
assert(!JSON.stringify(lines).includes('/private'))
|
|
23
23
|
for(const h of [{at:Date.now()-60000,plugins},{at:Date.now()+60000,plugins},{}]) {
|
|
24
24
|
await writeFile(heartbeat,JSON.stringify(h))
|
|
25
|
-
assert((await softwareStatus(root)).includes('Plugins
|
|
25
|
+
assert((await softwareStatus(root)).includes('Plugins: unknown'))
|
|
26
26
|
}
|
|
27
27
|
await writeFile(heartbeat,JSON.stringify({at:Date.now()}))
|
|
28
|
-
assert((await softwareStatus(root)).includes('Host transport: version unknown
|
|
28
|
+
assert((await softwareStatus(root)).includes('Host transport: running · version unknown'))
|
|
29
29
|
await writeFile(path.join(root,'registry.json'),'broken')
|
|
30
30
|
assert.equal(await installedPluginVersions(root),null)
|
|
31
31
|
})
|
package/test/task-native.test.ts
CHANGED
|
@@ -63,8 +63,8 @@ test('native restricted task has only bounded MCP tools, ignores private guidanc
|
|
|
63
63
|
const broker = [process.execPath, '--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)), fileURLToPath(new URL('../src/task-mcp.ts', import.meta.url)), root, run.id]
|
|
64
64
|
const catalog = await promisify(execFile)('codex', ['debug', 'models', '--bundled'], { maxBuffer: 4 * 1024 * 1024 });
|
|
65
65
|
await writeFile(`${root}/models.json`, JSON.stringify(taskModelCatalog(JSON.parse(catalog.stdout))));
|
|
66
|
-
const args = taskArguments(directory, broker, 'Read task context.')
|
|
67
|
-
args.splice(-1, 0, '--disable', 'enable_request_compression', '-c', 'model_provider="fixture"', '-c', `model_providers.fixture={name="fixture",base_url="http://127.0.0.1:${(server.address() as any).port}/v1",wire_api="responses",requires_openai_auth=false}
|
|
66
|
+
const args = taskArguments(directory, broker, 'Read task context.', undefined, {model:'gpt-6-astra'})
|
|
67
|
+
args.splice(-1, 0, '--disable', 'enable_request_compression', '-c', 'model_provider="fixture"', '-c', `model_providers.fixture={name="fixture",base_url="http://127.0.0.1:${(server.address() as any).port}/v1",wire_api="responses",requires_openai_auth=false}`)
|
|
68
68
|
child = spawn('codex', args, { cwd: directory, env: { PATH: process.env.PATH, HOME: home, CODEX_HOME: home }, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
69
69
|
let stderr = ''; child.stderr!.on('data', c => { stderr += c }); child.stdout!.resume()
|
|
70
70
|
const code = await new Promise(r => child!.on('close', r))
|
package/test/tasks.test.ts
CHANGED
|
@@ -112,12 +112,20 @@ test('uncertain send survives core restart and is never replayed; key prototype
|
|
|
112
112
|
})
|
|
113
113
|
test('file RPC verifies stored authority rather than role supplied in request', async t => {
|
|
114
114
|
const f = await fixture(t), { run } = await f.activate()
|
|
115
|
-
const drain = taskRequests(f.tasks)
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
const drain = taskRequests(f.tasks)
|
|
116
|
+
let pending: Promise<void> | undefined, drainError: unknown
|
|
117
|
+
const timer = setInterval(() => { pending = drain().catch(error => { drainError = error }) }, 10)
|
|
118
|
+
try {
|
|
119
|
+
const context: any = await taskCall(f.dir, run.id, 'worker', 'context')
|
|
120
|
+
assert.match(context.purpose, /Book a table/)
|
|
121
|
+
await assert.rejects(taskCall(f.dir, run.id, 'owner', 'revoke', { taskId: run.taskId }), /blocked/)
|
|
122
|
+
await assert.rejects(taskCall(f.dir, 'owner', 'worker', 'send', { text: 'x', key: 'x' }), /inactive/)
|
|
123
|
+
} finally {
|
|
124
|
+
// Complete the in-flight drain before the fixture removes its directory.
|
|
125
|
+
clearInterval(timer)
|
|
126
|
+
await pending
|
|
127
|
+
if (drainError) throw drainError
|
|
128
|
+
}
|
|
121
129
|
})
|
|
122
130
|
|
|
123
131
|
test('approved initial task crosses the real host file client and uses a fresh restricted runtime', async t => {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { TelegramSource } from '../src/telegram-source.js'
|
|
5
|
+
import { ControlStore } from '../src/control-state.js'
|
|
6
|
+
import { EventSources } from '../src/event-sources.js'
|
|
7
|
+
import { Tasks } from '../src/tasks.js'
|
|
8
|
+
import { ApprovalStore } from '../src/approval.js'
|
|
9
|
+
import { RunStore } from '../src/runs.js'
|
|
10
|
+
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
11
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
12
|
+
|
|
13
|
+
test('Telegram uses the existing persistent conversation grant, restricted context, receipts and revocation',async t=>{
|
|
14
|
+
const dir=await mkdtemp('/tmp/ez-tg-test-'),sent:any[]=[]
|
|
15
|
+
await ownerRun(dir,'owner')
|
|
16
|
+
const source=new TelegramSource(dir,'999',async(chat,text)=>{sent.push({chat,text});return [sent.length]})
|
|
17
|
+
t.after(async()=>{await source.stop();await rm(dir,{recursive:true,force:true})})
|
|
18
|
+
await source.start((await new ControlStore(dir,900000).status()).owner!)
|
|
19
|
+
const tasks=new Tasks(dir),runs=new RunStore(dir),sources=new EventSources(dir)
|
|
20
|
+
const message=(chat=-101)=>({message_id:1,date:Math.ceil(Date.now()/1000),chat:{id:chat,type:'group',title:'Family'},text:'Hi Annie'} as any)
|
|
21
|
+
const sender={id:202,is_bot:false,first_name:'Family member'}
|
|
22
|
+
assert.equal(await source.capture(1,{...message(),message_id:2},sender),false)
|
|
23
|
+
await assert.rejects(tasks.ownerCall('owner','propose',{sourceId:'telegram',conversationId:'-101',purpose:'Family chat',context:'Only family-group context',hours:24,untilRevoked:true}),/incoming-only/)
|
|
24
|
+
const proposed=await tasks.ownerCall('owner','propose',{sourceId:'telegram',conversationId:'-101',purpose:'Family chat',context:'Only family-group context',hours:24,waitForIncoming:true,untilRevoked:true}) as any
|
|
25
|
+
const approval=new ApprovalStore(dir)
|
|
26
|
+
assert.match((await approval.getDecision(proposed.id))!.prompt,/until owner revocation/)
|
|
27
|
+
await tasks.decide(proposed.id)
|
|
28
|
+
assert.equal(await source.capture(2,message(),sender),false)
|
|
29
|
+
await approval.recordDecision(proposed.id,'approved',101);await tasks.decide(proposed.id)
|
|
30
|
+
assert.equal((await runs.list()).length,1) // No opening send or run.
|
|
31
|
+
assert.equal(await source.capture(3,message(),sender),true)
|
|
32
|
+
assert.equal(await source.capture(3,message(),sender),true) // Durable duplicate intake.
|
|
33
|
+
assert.equal(await source.capture(4,message(-202),sender),false)
|
|
34
|
+
const registration=(await sources.list())[0],batch=await sources.batch(registration)
|
|
35
|
+
assert.equal(batch.events.length,1)
|
|
36
|
+
const task=(await tasks.get(proposed.id))!
|
|
37
|
+
assert.equal(task.version,3)
|
|
38
|
+
const run=await runs.create({id:'event_group_test',taskId:task.id,chatId:101,telegramUserId:101,texts:[],external:{sourceId:registration.id,bindingId:registration.bindingId,eventIds:batch.events.map(e=>e.id)}})
|
|
39
|
+
await runs.patch(run.id,{status:'running'})
|
|
40
|
+
await assert.rejects(requireOwnerExecution(dir,run.id),/blocked/)
|
|
41
|
+
const context:any=await tasks.workerCall(run.id,'context',{})
|
|
42
|
+
assert.equal(context.context,'Only family-group context');assert.equal(context.expiresAt,null)
|
|
43
|
+
assert.match(context.incoming[0].text,/Family member/)
|
|
44
|
+
await tasks.workerCall(run.id,'send',{text:'Hello!',key:'reply',conversationId:'-202'})
|
|
45
|
+
await tasks.workerCall(run.id,'send',{text:'Hello!',key:'reply'})
|
|
46
|
+
assert.deepEqual(sent,[{chat:-101,text:'Hello!'}])
|
|
47
|
+
await assert.rejects(tasks.workerCall(run.id,'send',{text:'Changed',key:'reply'}),/different text/)
|
|
48
|
+
await assert.rejects(source.call('task-send',{accountId:'wrong',conversationId:'-101',key:'x',text:'no'}),/binding/)
|
|
49
|
+
await assert.rejects(tasks.ownerCall(run.id,'propose',{}),/blocked/)
|
|
50
|
+
// Persistent grants survive more than 30 total replies, with per-run keys.
|
|
51
|
+
for(let i=0;i<31;i++) {
|
|
52
|
+
const next=await runs.create({id:`event_followup_${i}`,taskId:task.id,chatId:101,telegramUserId:101,texts:[],external:run.external})
|
|
53
|
+
await runs.patch(next.id,{status:'running'})
|
|
54
|
+
await tasks.workerCall(next.id,'send',{text:`Reply ${i}`,key:'reply'})
|
|
55
|
+
await runs.patch(next.id,{status:'completed'})
|
|
56
|
+
}
|
|
57
|
+
assert.equal(sent.length,32)
|
|
58
|
+
// Update IDs may move backwards after idle; local cursors must still advance.
|
|
59
|
+
await sources.advance(registration,batch.cursor)
|
|
60
|
+
assert.equal(await source.capture(1,{...message(),message_id:2},sender),true)
|
|
61
|
+
assert.equal((await sources.batch(registration)).events[0].id,'tg_n101_2')
|
|
62
|
+
const call=source.call.bind(source)
|
|
63
|
+
source.call=async(command,args)=>{if(command==='task-unwatch')throw new Error('Provider offline');return call(command,args)}
|
|
64
|
+
await assert.rejects(tasks.ownerCall('owner','revoke',{taskId:task.id}))
|
|
65
|
+
assert.equal((await tasks.get(task.id))!.unwatchPending,true)
|
|
66
|
+
await assert.rejects(tasks.ownerCall('owner','propose',{sourceId:'telegram',conversationId:'-101',purpose:'Family chat',context:'Only group context',hours:24,waitForIncoming:true,untilRevoked:true}),/already has a task/)
|
|
67
|
+
source.call=call
|
|
68
|
+
await new Tasks(dir).decide(task.id) // Reconciles after a restart or outage.
|
|
69
|
+
assert.equal((await tasks.get(task.id))!.unwatchPending,undefined)
|
|
70
|
+
await tasks.ownerCall('owner','revoke',{taskId:task.id}) // Idempotent.
|
|
71
|
+
|
|
72
|
+
await assert.rejects(tasks.workerCall(run.id,'send',{text:'After revoke',key:'late'}),/inactive/)
|
|
73
|
+
assert.equal(sent.length,32)
|
|
74
|
+
assert.equal(await source.capture(5,message(),sender),false)
|
|
75
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { identity, sha256, validateManifest, validateReceipt, validateChecks, tarManifest, validateSource, registryState, publishOnce, githubClient } from '../scripts/trusted-beta.mjs';
|
|
8
|
+
|
|
9
|
+
const env = { RELEASE_ID: '123', RELEASE_REPOSITORY: 'jdorado/ez-agents', RELEASE_PACKAGE: '@jc_stack/ez-agents', RELEASE_VERSION: '1.2.3-beta.1', RELEASE_SOURCE_SHA: 'a'.repeat(40), RELEASE_SHA256: sha256(Buffer.from('candidate')), RELEASE_REQUIRED_CHECKS: '["verify (22)"]', GITHUB_REPOSITORY: 'jdorado/ez-agents', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/main', GITHUB_SHA: 'a'.repeat(40) };
|
|
10
|
+
const expected = identity(env);
|
|
11
|
+
const manifest = { name: expected.package, version: expected.version, repository: { url: 'git+https://github.com/jdorado/ez-agents.git' }, publishConfig: { access: 'public', tag: 'latest' } };
|
|
12
|
+
const receipt = { ...Object.fromEntries(['repository', 'package', 'version', 'sourceSha', 'sha256'].map(key => [key, expected[key]])), independentReviewUrl: 'https://github.com/jdorado/ez-agents/pull/41#issuecomment-123', testEvidenceUrls: ['https://github.com/jdorado/ez-agents/actions/runs/123'] };
|
|
13
|
+
const mainRun = { id: 123, run_attempt: 1, head_sha: expected.sourceSha, event: 'push', head_branch: 'main', path: '.github/workflows/ci.yml', status: 'completed', conclusion: 'success' };
|
|
14
|
+
const runsPath = `/repos/${expected.repository}/actions/workflows/ci.yml/runs?branch=main&event=push&head_sha=${expected.sourceSha}&per_page=100&page=1`;
|
|
15
|
+
const jobsPath = `/repos/${expected.repository}/actions/runs/123/attempts/1/jobs?per_page=100&page=1`;
|
|
16
|
+
const checkPath = `/repos/${expected.repository}/check-runs/1`;
|
|
17
|
+
const job = { id: 456, run_id: 123, run_attempt: 1, head_sha: expected.sourceSha, name: 'verify (22)', status: 'completed', conclusion: 'success', check_run_url: `https://api.github.com${checkPath}` };
|
|
18
|
+
const check = { id: 1, details_url: 'https://github.com/jdorado/ez-agents/actions/runs/123/job/456', name: 'verify (22)', head_sha: expected.sourceSha, app: { id: 15368 }, status: 'completed', conclusion: 'success' };
|
|
19
|
+
|
|
20
|
+
test('identity rejects source, repository, trigger, channel and empty check substitution', () => {
|
|
21
|
+
for (const changed of [{ RELEASE_VERSION: '1.2.3' }, { RELEASE_VERSION: '1.2.3-alpha.1' }, { RELEASE_VERSION: '1.2.3-beta.01' }, { RELEASE_VERSION: '1.2.3-beta.1+build' }, { RELEASE_REPOSITORY: 'attacker/ez-agents' }, { GITHUB_REPOSITORY: 'jdorado/other' }, { GITHUB_REF: 'refs/heads/feature' }, { GITHUB_EVENT_NAME: 'push' }, { GITHUB_SHA: 'b'.repeat(40) }, { RELEASE_REQUIRED_CHECKS: '[]' }, { RELEASE_REQUIRED_CHECKS: '["a","a"]' }, { RELEASE_SHA256: 'oops' }]) assert.throws(() => identity({ ...env, ...changed }));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('manifest and receipt are bound to independent dispatch identity', () => {
|
|
25
|
+
validateManifest(manifest, expected); validateReceipt(receipt, expected);
|
|
26
|
+
for (const changed of [{ name: '@jc_stack/other' }, { version: '1.2.3' }, { private: true }, { repository: 'https://github.com/attacker/repo' }, { publishConfig: { tag: 'beta' } }, { publishConfig: { access: 'restricted' } }, { publishConfig: { registry: 'https://evil.example/' } }, { publishConfig: { provenance: false } }]) assert.throws(() => validateManifest({ ...manifest, ...changed }, expected));
|
|
27
|
+
for (const changed of [{ repository: 'jdorado/other' }, { sourceSha: 'b'.repeat(40) }, { sha256: '0'.repeat(64) }, { independentReviewUrl: 'https://evil.example/pull/1' }, { independentReviewUrl: 'https://github.com/jdorado/ez-agents/issues/1' }, { testEvidenceUrls: [] }, { testEvidenceUrls: ['https://github.com/jdorado/ez-agents/actions/runs/1/../../evil'] }]) assert.throws(() => validateReceipt({ ...receipt, ...changed }, expected));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('checks cannot pass vacuously, from another app/source or an earlier run', () => {
|
|
31
|
+
validateChecks([check], expected);
|
|
32
|
+
for (const checks of [[], [{ ...check, app: { id: 1 } }], [{ ...check, head_sha: 'b'.repeat(40) }], [{ ...check, conclusion: 'skipped' }], [check, { ...check, id: 2, status: 'in_progress', conclusion: null }]]) assert.throws(() => validateChecks(checks, expected));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function sourceApi(overrides = {}) {
|
|
36
|
+
const responses = {
|
|
37
|
+
[runsPath]: { workflow_runs: [mainRun] },
|
|
38
|
+
[jobsPath]: { jobs: [job] },
|
|
39
|
+
[checkPath]: check,
|
|
40
|
+
'/repos/jdorado/ez-agents': { full_name: expected.repository, private: false, visibility: 'public', default_branch: 'main' },
|
|
41
|
+
'/repos/jdorado/ez-agents/contents/docs/plugin-catalog.md?ref=main': { content: Buffer.from('| [Plugin](https://github.com/jdorado/ez-whatsapp) | `@jc_stack/ez-whatsapp` |').toString('base64') },
|
|
42
|
+
'/repos/jdorado/ez-agents/git/ref/heads/main': { object: { sha: expected.sourceSha } },
|
|
43
|
+
[`/repos/jdorado/ez-agents/contents/package.json?ref=${expected.sourceSha}`]: { content: Buffer.from(JSON.stringify(manifest)).toString('base64') },
|
|
44
|
+
[`/repos/jdorado/ez-agents/git/ref/tags/v${expected.version}`]: { object: { type: 'commit', sha: expected.sourceSha } },
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
return async path => { assert.ok(path in responses, `Unexpected API request ${path}`); return responses[path]; };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('source gates reject private repository, changed main, wrong tag and failed CI', async () => {
|
|
51
|
+
await validateSource(expected, sourceApi());
|
|
52
|
+
for (const override of [
|
|
53
|
+
{ '/repos/jdorado/ez-agents': { full_name: expected.repository, private: true, visibility: 'private', default_branch: 'main' } },
|
|
54
|
+
{ '/repos/jdorado/ez-agents/git/ref/heads/main': { object: { sha: 'b'.repeat(40) } } },
|
|
55
|
+
{ [`/repos/jdorado/ez-agents/git/ref/tags/v${expected.version}`]: { object: { type: 'commit', sha: 'b'.repeat(40) } } },
|
|
56
|
+
{ [jobsPath]: { jobs: [] } },
|
|
57
|
+
]) await assert.rejects(validateSource(expected, sourceApi(override)));
|
|
58
|
+
await assert.rejects(validateSource({ ...expected, repository: 'jdorado/ez-library', package: '@jc_stack/ez-library' }, async path => path.endsWith('ez-library') ? { full_name: 'jdorado/ez-library', private: false, visibility: 'public', default_branch: 'main' } : { content: Buffer.from('').toString('base64') }), /not enrolled/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('tarball parser reads one regular manifest without extracting files', async () => {
|
|
62
|
+
const dir = await mkdtemp(join(tmpdir(), 'beta-tar-test-'));
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(join(dir, 'package'));
|
|
65
|
+
await writeFile(join(dir, 'package/package.json'), JSON.stringify(manifest));
|
|
66
|
+
execFileSync('tar', ['-czf', join(dir, 'good.tgz'), '-C', dir, 'package/package.json']);
|
|
67
|
+
assert.deepEqual(tarManifest(join(dir, 'good.tgz')), manifest);
|
|
68
|
+
execFileSync('tar', ['-czf', join(dir, 'duplicate.tgz'), '-C', dir, 'package/package.json', 'package/package.json']);
|
|
69
|
+
assert.throws(() => tarManifest(join(dir, 'duplicate.tgz')), /exactly one/);
|
|
70
|
+
execFileSync('ln', ['-s', '/etc/passwd', join(dir, 'package/link')]);
|
|
71
|
+
execFileSync('tar', ['-czf', join(dir, 'link.tgz'), '-C', dir, 'package']);
|
|
72
|
+
assert.throws(() => tarManifest(join(dir, 'link.tgz')), /links or special/);
|
|
73
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('registry distinguishes missing version, absent package, server failures and changed bytes', async () => {
|
|
77
|
+
const document = { name: expected.package, versions: {}, 'dist-tags': { latest: '1.0.0', beta: '1.2.3-beta.0' } };
|
|
78
|
+
const response = data => new Response(JSON.stringify(data));
|
|
79
|
+
assert.deepEqual(await registryState(expected, async () => response(document)), { exists: false, latest: '1.0.0', beta: '1.2.3-beta.0' });
|
|
80
|
+
await assert.rejects(registryState(expected, async () => new Response('', { status: 404 })), /first reviewed beta/);
|
|
81
|
+
await assert.rejects(registryState(expected, async () => new Response('', { status: 503 })), /HTTP 503/);
|
|
82
|
+
const published = { ...manifest, dist: { tarball: 'https://registry.npmjs.org/file.tgz' } };
|
|
83
|
+
const existing = { ...document, versions: { [expected.version]: published } };
|
|
84
|
+
let n = 0;
|
|
85
|
+
const read = await registryState(expected, async () => n++ === 0 ? response(existing) : new Response('candidate'));
|
|
86
|
+
assert.equal(read.exists, true);
|
|
87
|
+
n = 0;
|
|
88
|
+
await assert.rejects(registryState(expected, async () => n++ === 0 ? response(existing) : new Response('changed')), /differs/);
|
|
89
|
+
await assert.rejects(registryState(expected, async () => response({ ...existing, versions: { [expected.version]: { ...published, dist: { tarball: 'https://evil.example/file' } } } })), /tarball host/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('publish writes once and reconciles a timeout using exact registry readback', async () => {
|
|
93
|
+
let writes = 0; let reads = 0;
|
|
94
|
+
const result = await publishOnce(expected, { readState: async () => reads++ === 0 ? { exists: false, latest: '1.0.0' } : { exists: true, latest: expected.version, beta: 'old' }, publishTarball: async () => { writes++; throw new Error('timeout'); }, sleep: async () => {}, report: () => {} });
|
|
95
|
+
assert.equal(writes, 1); assert.equal(result.status, 'published');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('existing matching publication is read-only; partial or changed tag never republished', async () => {
|
|
99
|
+
let writes = 0;
|
|
100
|
+
const options = { readState: async () => ({ exists: true, latest: expected.version }), publishTarball: async () => { writes++; } };
|
|
101
|
+
assert.equal((await publishOnce(expected, options)).status, 'already-published');
|
|
102
|
+
await assert.rejects(publishOnce(expected, { ...options, readState: async () => ({ exists: true, latest: 'other' }) }), /reconcile/);
|
|
103
|
+
assert.equal(writes, 0);
|
|
104
|
+
for (const after of [{ exists: true, latest: 'changed', beta: expected.version }, { exists: true, latest: '1.0.0', beta: 'other' }, { exists: false, latest: '1.0.0' }]) {
|
|
105
|
+
let reads = 0;
|
|
106
|
+
await assert.rejects(publishOnce(expected, { readState: async () => reads++ === 0 ? { exists: false, latest: '1.0.0' } : after, publishTarball: async () => { writes++; }, sleep: async () => {} }), /do not repeat/);
|
|
107
|
+
}
|
|
108
|
+
assert.equal(writes, 3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('asset redirect never carries GitHub authorization to storage host', async () => {
|
|
112
|
+
let count = 0;
|
|
113
|
+
const api = githubClient('sensitive', async (url, options) => {
|
|
114
|
+
if (count++ === 0) { assert.equal(options.headers.Authorization, 'Bearer sensitive'); return new Response('', { status: 302, headers: { location: 'https://release-assets.githubusercontent.com/file' } }); }
|
|
115
|
+
assert.equal(options.headers, undefined); return new Response('candidate');
|
|
116
|
+
});
|
|
117
|
+
assert.equal((await api('/repos/jdorado/ez-agents/releases/assets/1', true)).toString(), 'candidate');
|
|
118
|
+
await assert.rejects(githubClient('sensitive', async () => new Response('', { status: 302, headers: { location: 'https://evil.example/file' } }))('/repos/jdorado/ez-agents/releases/assets/1', true), /Unexpected asset redirect/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('workflow reruns can verify success but cannot repeat an absent-version write', async () => {
|
|
122
|
+
let writes = 0;
|
|
123
|
+
const options = { allowWrite: false, publishTarball: async () => { writes++; } };
|
|
124
|
+
await assert.rejects(publishOnce(expected, { ...options, readState: async () => ({ exists: false }) }), /fresh authorized dispatch/);
|
|
125
|
+
assert.equal((await publishOnce(expected, { ...options, readState: async () => ({ exists: true, latest: expected.version }) })).status, 'already-published');
|
|
126
|
+
assert.equal(writes, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('write-started receipt precedes publication and readback records partial failures', async () => {
|
|
130
|
+
const events = []; let read = 0;
|
|
131
|
+
await assert.rejects(publishOnce(expected, { readState: async () => read++ === 0 ? { exists: false, latest: '1' } : { exists: false, latest: '1' }, record: async event => events.push(event), publishTarball: async () => { assert.equal(events.at(-1).phase, 'write-started'); throw new Error('timeout'); }, sleep: async () => {} }), /unresolved/);
|
|
132
|
+
assert.equal(events[0].phase, 'preflight');
|
|
133
|
+
assert.ok(events.some(event => event.phase === 'readback' && event.publishCommandFailed));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('required check workflow must be main push CI, not a same-named alternate workflow', async () => {
|
|
137
|
+
for (const changed of [{ event: 'pull_request' }, { head_branch: 'feature' }, { path: '.github/workflows/unrelated.yml' }, { conclusion: 'failure' }, { head_sha: 'b'.repeat(40) }]) {
|
|
138
|
+
await assert.rejects(validateSource(expected, sourceApi({ [runsPath]: { workflow_runs: [{ ...mainRun, ...changed }] } })), /main push CI/);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('publish environment contains only required platform identity and OIDC capabilities', async () => {
|
|
143
|
+
const { publishEnvironment } = await import('../scripts/trusted-beta.mjs');
|
|
144
|
+
const clean = publishEnvironment({ PATH: '/bin', HOME: '/tmp', GITHUB_SHA: expected.sourceSha, ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-only', GH_TOKEN: 'secret', GITHUB_TOKEN: 'secret', NPM_TOKEN: 'secret', TELEGRAM_BOT_TOKEN: 'secret', npm_config_registry: 'https://evil.example', NODE_OPTIONS: '--require=/evil.js' });
|
|
145
|
+
assert.deepEqual(clean, { PATH: '/bin', HOME: '/tmp', GITHUB_SHA: expected.sourceSha, ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-only' });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('generated caller pins shared code, permits manual dispatch only and rejects YAML injection', async () => {
|
|
149
|
+
const { generateCaller } = await import('../scripts/generate-publish-caller.mjs');
|
|
150
|
+
const config = { repository: 'jdorado/ez-whatsapp', packageName: '@jc_stack/ez-whatsapp', publisherSha: 'a'.repeat(40), checks: ['verify (22)', 'docker'] };
|
|
151
|
+
const caller = generateCaller(config);
|
|
152
|
+
assert.ok(caller.includes(`uses: jdorado/ez-agents/.github/workflows/npm-beta-shared.yml@${config.publisherSha}`));
|
|
153
|
+
assert.ok(caller.includes(' workflow_dispatch:'));
|
|
154
|
+
assert.ok(!/^\s+(?:push|pull_request|workflow_run):/m.test(caller));
|
|
155
|
+
assert.ok(caller.includes(' release-id: ${{ inputs.release-id }}'));
|
|
156
|
+
const matrixChecks = ['test (ubuntu-latest, 22)', 'test (macos-latest, 24)', 'docker'];
|
|
157
|
+
assert.ok(generateCaller({ ...config, checks: matrixChecks }).includes(`required-checks: '${JSON.stringify(matrixChecks)}'`));
|
|
158
|
+
assert.ok(generateCaller({ ...config, repository: 'jdorado/ez-agents', packageName: '@jc_stack/ez-agents' }).includes('uses: ./.github/workflows/npm-beta-shared.yml'));
|
|
159
|
+
for (const invalid of [{ repository: "jdorado/repo'\nsteps:" }, { packageName: "@jc_stack/p'\nsteps:" }, { publisherSha: 'main' }, { checks: ['a\nsteps:'] }, { checks: [] }]) assert.throws(() => generateCaller({ ...config, ...invalid }));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('npm publication disables transport retries and scripts, using the explicit latest tag', async () => {
|
|
163
|
+
const { publishArguments } = await import('../scripts/trusted-beta.mjs');
|
|
164
|
+
const args = publishArguments('/candidate.tgz', '/user-npmrc', '/global-npmrc');
|
|
165
|
+
assert.ok(args.includes('--fetch-retries=0'));
|
|
166
|
+
assert.ok(args.includes('--ignore-scripts'));
|
|
167
|
+
assert.equal(args[args.indexOf('--tag') + 1], 'latest');
|
|
168
|
+
assert.equal(args[args.indexOf('--registry') + 1], 'https://registry.npmjs.org/');
|
|
169
|
+
assert.notEqual(args[args.indexOf('--userconfig') + 1], args[args.indexOf('--globalconfig') + 1]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
test('newer same-SHA tag, PR and alternate workflow runs cannot shadow main CI', async () => {
|
|
174
|
+
for (const changed of [{ head_branch: 'v1.2.3-beta.1' }, { event: 'pull_request' }, { path: '.github/workflows/other.yml' }, { head_sha: 'b'.repeat(40) }]) {
|
|
175
|
+
await validateSource(expected, sourceApi({
|
|
176
|
+
[runsPath]: { workflow_runs: [mainRun, { ...mainRun, id: 124, ...changed }] },
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('latest eligible main CI cannot fall back to older success or missing checks', async () => {
|
|
182
|
+
for (const changed of [{ conclusion: 'failure' }, { conclusion: 'cancelled' }, { status: 'in_progress', conclusion: null }, { status: 'queued', conclusion: null }]) {
|
|
183
|
+
await assert.rejects(validateSource(expected, sourceApi({
|
|
184
|
+
[runsPath]: { workflow_runs: [mainRun, { ...mainRun, id: 124, ...changed }] },
|
|
185
|
+
})), /main push CI/);
|
|
186
|
+
}
|
|
187
|
+
await assert.rejects(validateSource(expected, sourceApi({
|
|
188
|
+
[runsPath]: { workflow_runs: [mainRun, { ...mainRun, id: 124 }] },
|
|
189
|
+
[jobsPath.replace('/123/', '/124/')]: { jobs: [] },
|
|
190
|
+
})), /Missing or ambiguous/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('latest attempt cannot borrow successful jobs from prior attempts or runs', async () => {
|
|
194
|
+
const attemptPath = jobsPath.replace('/attempts/1/', '/attempts/2/');
|
|
195
|
+
for (const jobs of [[], [job], [{ ...job, run_attempt: 2, run_id: 124 }], [{ ...job, run_attempt: 2, head_sha: 'b'.repeat(40) }], [{ ...job, run_attempt: 2, conclusion: 'failure' }], [{ ...job, run_attempt: 2, status: 'in_progress', conclusion: null }]]) {
|
|
196
|
+
await assert.rejects(validateSource(expected, sourceApi({
|
|
197
|
+
[runsPath]: { workflow_runs: [{ ...mainRun, run_attempt: 2 }] },
|
|
198
|
+
[attemptPath]: { jobs },
|
|
199
|
+
})), /Missing or ambiguous|not successful/);
|
|
200
|
+
}
|
|
201
|
+
await validateSource(expected, sourceApi({
|
|
202
|
+
[runsPath]: { workflow_runs: [{ ...mainRun, run_attempt: 2 }] },
|
|
203
|
+
[attemptPath]: { jobs: [{ ...job, run_attempt: 2 }] },
|
|
204
|
+
}));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('required job and check evidence cannot substitute repository, identity or outcomes', async () => {
|
|
208
|
+
for (const changed of [{ check_run_url: job.check_run_url.replace('jdorado/ez-agents', 'attacker/ez-agents') }, { check_run_url: job.check_run_url + '/extra' }, { check_run_url: undefined }, { conclusion: 'skipped' }]) {
|
|
209
|
+
await assert.rejects(validateSource(expected, sourceApi({ [jobsPath]: { jobs: [{ ...job, ...changed }] } })));
|
|
210
|
+
}
|
|
211
|
+
for (const changed of [{ details_url: check.details_url.replace('/123/', '/124/') }, { details_url: undefined }, { name: 'other' }, { app: { id: 1 } }, { head_sha: 'b'.repeat(40) }, { conclusion: 'failure' }]) {
|
|
212
|
+
await assert.rejects(validateSource(expected, sourceApi({ [checkPath]: { ...check, ...changed } })));
|
|
213
|
+
}
|
|
214
|
+
await assert.rejects(validateSource(expected, sourceApi({ [jobsPath]: { jobs: [job, job] } })), /ambiguous/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('workflow runs and attempt jobs are paginated before selecting required evidence', async () => {
|
|
218
|
+
await validateSource(expected, sourceApi({
|
|
219
|
+
[runsPath]: { workflow_runs: Array.from({ length: 100 }, (_, i) => ({ ...mainRun, id: i + 200, event: 'pull_request' })) },
|
|
220
|
+
[runsPath.replace('&page=1', '&page=2')]: { workflow_runs: [mainRun] },
|
|
221
|
+
[jobsPath]: { jobs: Array.from({ length: 100 }, (_, i) => ({ ...job, name: `other-${i}` })) },
|
|
222
|
+
[jobsPath.replace('&page=1', '&page=2')]: { jobs: [job] },
|
|
223
|
+
}));
|
|
224
|
+
});
|
package/test/updates.test.mjs
CHANGED
|
@@ -113,9 +113,13 @@ test('archive admission rejects traversal, links, special files, duplicates and
|
|
|
113
113
|
await assert.rejects(extract(Buffer.from('not gzip'),path.join(f.root,'bad')));
|
|
114
114
|
await assert.rejects(fs.access(path.join(f.root,'bad')));
|
|
115
115
|
});
|
|
116
|
-
test('policy defaults
|
|
116
|
+
test('policy defaults beta; prepared local candidates need explicit authority and a live supervisor',async t=>{
|
|
117
117
|
const f=await fixture(t),job=await prepare(f.home,'main',{file:await f.pack()});
|
|
118
|
+
assert.deepEqual(await command(f.home,['policy','main']),{automatic:true,channel:'beta'});
|
|
119
|
+
assert.deepEqual(await command(f.home,['policy','sample']),{automatic:true,channel:'beta'});
|
|
120
|
+
await command(f.home,['policy','main','stable']);
|
|
118
121
|
assert.deepEqual(await command(f.home,['policy','main']),{automatic:true,channel:'stable'});
|
|
122
|
+
assert.deepEqual(await command(f.home,['policy','sample']),{automatic:true,channel:'beta'});
|
|
119
123
|
await assert.rejects(submit(f.home,job.id,false));
|
|
120
124
|
await atomic(path.join(f.home,'updates/supervisor.json'),{at:Date.now()});
|
|
121
125
|
await assert.rejects(submit(f.home,job.id,true),/Local/);
|
|
@@ -183,7 +187,13 @@ test('interrupted activation recovers previous code; rollback failure is explici
|
|
|
183
187
|
const retried=await read(path.join(jobPath(f.home,interrupted.id),'job.json'));assert.equal((await perform(f.home,retried,r)).status,'rolled-back');
|
|
184
188
|
});
|
|
185
189
|
test('bound dispatch follows active package root and retains private scope',async t=>{
|
|
186
|
-
const f=await fixture(t);
|
|
190
|
+
const f=await fixture(t);
|
|
191
|
+
await fs.appendFile(path.join(f.agent.workspace,'TOOLS.md'),"\n## Software updates\nThe default policy\nauthorizes compatible stable updates without asking again. Respect an owner's\nmanual policy or beta opt-in.\nOwner notes stay here.\n");
|
|
192
|
+
const bound=await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
|
|
193
|
+
assert.match(bound.policy,/beta-channel/);
|
|
194
|
+
const guidance=await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8');
|
|
195
|
+
assert.match(guidance,/beta channel/);assert.match(guidance,/Owner notes stay here/);
|
|
196
|
+
assert.doesNotMatch(guidance,/beta opt-in/);
|
|
187
197
|
const config=await read(path.join(f.home,'config.json'));config.packageRoot=f.source;await atomic(path.join(f.home,'config.json'),config);
|
|
188
198
|
// A native launcher from the real package looks up its entry point in the active root.
|
|
189
199
|
await fs.writeFile(path.join(f.source,'bin/ezenciel-agents.mjs'),'#!/usr/bin/env node\nconsole.log(process.env.EZ_DEPLOYMENT_DIR)',{mode:0o755});
|
|
@@ -252,7 +262,11 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
|
|
|
252
262
|
await fs.rm(running);
|
|
253
263
|
await wait(async()=>{const j=await read(path.join(jobPath(f.home,job.id),'job.json'));if(j.status==='failed'||j.status==='rolled-back')throw Error(JSON.stringify(j)+first.output());return j.status==='completed';});
|
|
254
264
|
const newBeat=await heartbeat();assert.notEqual(newBeat.pid,oldBeat.pid);assert(first.p.exitCode===null);
|
|
255
|
-
|
|
265
|
+
// Completion is persisted before the supervisor publishes its attention receipt.
|
|
266
|
+
await wait(async()=>{
|
|
267
|
+
try{return (await read(path.join(f.agent.controlDir,'update-attention.json'))).id===digest(job.id);}
|
|
268
|
+
catch(error){if(error.code==='ENOENT')return false;throw error;}
|
|
269
|
+
});
|
|
256
270
|
const closed=new Promise(r=>first.p.once('close',r));first.p.kill('SIGTERM');await closed;
|
|
257
271
|
const active=(await read(path.join(f.home,'config.json'))).packageRoot;assert(active.endsWith('/runtime'));
|
|
258
272
|
assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).packageManager.command,provider);
|
|
@@ -280,3 +294,21 @@ test('npm candidates verify exact version and integrity; automatic policy is enf
|
|
|
280
294
|
pkg.dist.integrity='sha512-bad';await assert.rejects(prepare(f.home,'main',{release:'0.1.1'}),/integrity/);
|
|
281
295
|
pkg.version='0.1.2';await assert.rejects(prepare(f.home,'main',{release:'0.1.1'}),/version mismatch/);
|
|
282
296
|
});
|
|
297
|
+
|
|
298
|
+
test('update discovery follows latest while preserving legacy beta and stable-only policies',async t=>{
|
|
299
|
+
const {registryCandidate}=await import('../src/updates/artifact.mjs');
|
|
300
|
+
const name='@ez-test/example',pkg=v=>({name,version:v});
|
|
301
|
+
const data={name,'dist-tags':{latest:'0.2.0-beta.2',beta:'0.2.0-beta.1'},versions:{'0.1.0':pkg('0.1.0'),'0.2.0-beta.1':pkg('0.2.0-beta.1'),'0.2.0-beta.2':pkg('0.2.0-beta.2')}};
|
|
302
|
+
const original=globalThis.fetch;globalThis.fetch=async()=>new Response(JSON.stringify(data));t.after(()=>globalThis.fetch=original);
|
|
303
|
+
assert.equal((await registryCandidate(name,'beta')).version,'0.2.0-beta.2');
|
|
304
|
+
assert.equal((await registryCandidate(name,'stable')).version,'0.1.0');
|
|
305
|
+
data['dist-tags']={latest:'0.1.0',beta:'0.2.0-beta.2'};
|
|
306
|
+
assert.equal((await registryCandidate(name,'beta')).version,'0.2.0-beta.2');
|
|
307
|
+
delete data.versions['0.1.0'];data['dist-tags']={latest:'0.2.0-beta.2'};
|
|
308
|
+
assert.equal(await registryCandidate(name,'stable'),null);
|
|
309
|
+
assert.equal((await registryCandidate(name,'beta')).version,'0.2.0-beta.2');
|
|
310
|
+
data.versions['0.2.0-beta.2'].deprecated='withdrawn';assert.equal(await registryCandidate(name,'beta'),null);
|
|
311
|
+
delete data.versions['0.2.0-beta.2'].deprecated;
|
|
312
|
+
data.versions['0.2.0-beta.2'].name='@wrong/package';await assert.rejects(registryCandidate(name,'beta'),/identity|version mismatch/i);
|
|
313
|
+
data.name='@wrong/package';await assert.rejects(registryCandidate(name,'beta'),/identity mismatch/i);
|
|
314
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { WorkforceWatch, WorkforceWatchServer } from '../src/workforce-watch.js'
|
|
7
|
+
|
|
8
|
+
const fixture = async () => {
|
|
9
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
10
|
+
let now = 0
|
|
11
|
+
const notices: string[] = []
|
|
12
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => { notices.push(text) } })
|
|
13
|
+
return { stateDir, watch, notices, advance: (ms: number) => { now += ms }, close: () => rm(stateDir, { recursive: true, force: true }) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('worker enrollment, terminal alert context, and sustained recovery preserve only redacted state', async () => {
|
|
17
|
+
const f = await fixture()
|
|
18
|
+
try {
|
|
19
|
+
await assert.rejects(() => f.watch.enroll('wrong', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0 }), /Unauthorized/)
|
|
20
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0, runbookUrl: 'https://runbooks.example/stocks' })
|
|
21
|
+
await assert.rejects(() => f.watch.checkIn('stocks-production', 'wrong', { status: 'ok' }), /Unauthorized/)
|
|
22
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'IBKR session probe', error: 'No broker server time', runId: 'daily-42', logsHint: 'journalctl --user -u ez-stocks.service --since 30m' })
|
|
23
|
+
assert.match(f.notices[0], /IBKR session probe/)
|
|
24
|
+
assert.match(f.notices[0], /No broker server time/)
|
|
25
|
+
const state = await f.watch.inspect('stocks-production') as Record<string, unknown>
|
|
26
|
+
assert.equal('tokenHash' in state, false)
|
|
27
|
+
assert.deepEqual((state.history as Array<Record<string, unknown>>).at(-1)?.runId, 'daily-42')
|
|
28
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry one' })
|
|
29
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry two' })
|
|
30
|
+
assert.equal(f.notices.length, 2)
|
|
31
|
+
assert.match(f.notices[1], /recovered/)
|
|
32
|
+
} finally { await f.close() }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a missed check-in alerts once and requires clean check-ins to recover', async () => {
|
|
36
|
+
const f = await fixture()
|
|
37
|
+
try {
|
|
38
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'annie-pa', checkInSeconds: 10, graceSeconds: 2, severity: 'warning' })
|
|
39
|
+
f.advance(12_001)
|
|
40
|
+
await f.watch.evaluate(); await f.watch.evaluate()
|
|
41
|
+
assert.equal(f.notices.length, 1)
|
|
42
|
+
assert.match(f.notices[0], /missed check-in/)
|
|
43
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
|
|
44
|
+
assert.equal(f.notices.length, 1)
|
|
45
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
|
|
46
|
+
assert.equal(f.notices.length, 2)
|
|
47
|
+
} finally { await f.close() }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('PagerDuty uses one worker-specific deduplication key and resolves only a triggered incident', async () => {
|
|
51
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
52
|
+
const pages: Array<{ action: string; dedupKey: string; customDetails: Record<string, string> }> = []
|
|
53
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event) } })
|
|
54
|
+
try {
|
|
55
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'stocks', checkInSeconds: 10, graceSeconds: 0, severity: 'critical' })
|
|
56
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'broker probe', error: 'gateway unavailable', logsHint: 'journalctl -u stocks' })
|
|
57
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
58
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
59
|
+
assert.deepEqual(pages.map(page => [page.action, page.dedupKey]), [['trigger', 'ez:workforce:stocks'], ['resolve', 'ez:workforce:stocks']])
|
|
60
|
+
assert.equal(pages[0]?.customDetails.error, 'gateway unavailable')
|
|
61
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('PagerDuty trigger is not duplicated when supplemental Telegram delivery fails', async () => {
|
|
65
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
66
|
+
const pages: string[] = []
|
|
67
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event.action) }, notify: async () => { throw new Error('Telegram unavailable') } })
|
|
68
|
+
try {
|
|
69
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
|
|
70
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
|
|
71
|
+
await assert.rejects(() => watch.evaluate(), /Telegram unavailable/)
|
|
72
|
+
assert.deepEqual(pages, ['trigger'])
|
|
73
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a failed recovery notification stays pending and is retried on evaluation', async () => {
|
|
77
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
78
|
+
let now = 0, failRecovery = true
|
|
79
|
+
const notices: string[] = []
|
|
80
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => {
|
|
81
|
+
if (text.includes('recovered') && failRecovery) throw new Error('Telegram unavailable')
|
|
82
|
+
notices.push(text)
|
|
83
|
+
} })
|
|
84
|
+
try {
|
|
85
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'dusk-dune', checkInSeconds: 10, graceSeconds: 0 })
|
|
86
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true })
|
|
87
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
88
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
|
|
89
|
+
assert.ok((await watch.inspect('dusk-dune') as { incident?: unknown }).incident)
|
|
90
|
+
failRecovery = false; now += 1
|
|
91
|
+
await watch.evaluate()
|
|
92
|
+
assert.equal((await watch.inspect('dusk-dune') as { incident?: unknown }).incident, undefined)
|
|
93
|
+
assert.equal(notices.length, 2)
|
|
94
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('an incident whose opening alert never delivered resolves quietly', async () => {
|
|
98
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
99
|
+
const notices: string[] = []
|
|
100
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', notify: async text => {
|
|
101
|
+
if (text.includes('alert')) throw new Error('Telegram unavailable')
|
|
102
|
+
notices.push(text)
|
|
103
|
+
} })
|
|
104
|
+
try {
|
|
105
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'jc-stack', checkInSeconds: 10, graceSeconds: 0 })
|
|
106
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
|
|
107
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
|
|
108
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
109
|
+
assert.deepEqual(notices, [])
|
|
110
|
+
assert.equal((await watch.inspect('jc-stack') as { incident?: unknown }).incident, undefined)
|
|
111
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('an enrollment-authorized token rotation invalidates the prior worker secret', async () => {
|
|
115
|
+
const f = await fixture()
|
|
116
|
+
try {
|
|
117
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
|
|
118
|
+
await assert.rejects(() => f.watch.rotate('wrong', 'aifit'), /Unauthorized/)
|
|
119
|
+
const rotated = await f.watch.rotate('fleet-secret', 'aifit')
|
|
120
|
+
await assert.rejects(() => f.watch.checkIn('aifit', enrolled.workerToken, { status: 'ok' }), /Unauthorized/)
|
|
121
|
+
await f.watch.checkIn('aifit', rotated.workerToken, { status: 'ok' })
|
|
122
|
+
} finally { await f.close() }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('HTTP enrollment and check-in endpoints reject secrets not owned by the caller', async () => {
|
|
126
|
+
const f = await fixture(), server = new WorkforceWatchServer(f.watch, 'fleet-secret', () => {})
|
|
127
|
+
try {
|
|
128
|
+
await server.listen(0, '127.0.0.1')
|
|
129
|
+
const base = `http://127.0.0.1:${server.port()}`
|
|
130
|
+
assert.equal((await fetch(`${base}/v1/workers`)).status, 401)
|
|
131
|
+
const enrollment = await fetch(`${base}/v1/enroll`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret', 'content-type': 'application/json' }, body: JSON.stringify({ workerId: 'ez-cto', checkInSeconds: 10, graceSeconds: 0 }) })
|
|
132
|
+
assert.equal(enrollment.status, 201)
|
|
133
|
+
const { workerToken } = await enrollment.json() as { workerToken: string }
|
|
134
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: 'Bearer wrong', 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
|
|
135
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok","activity":"relay started"}' })).status, 200)
|
|
136
|
+
const rotation = await fetch(`${base}/v1/workers/ez-cto/rotate`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret' } })
|
|
137
|
+
assert.equal(rotation.status, 200)
|
|
138
|
+
const rotated = await rotation.json() as { workerToken: string }
|
|
139
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
|
|
140
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${rotated.workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 200)
|
|
141
|
+
} finally { await server.close(); await f.close() }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
for (const recoveredChannel of ['pagerduty', 'telegram'] as const) {
|
|
145
|
+
for (const relapse of ['terminal', 'failed', 'missed'] as const) {
|
|
146
|
+
test(`a ${relapse} relapse reopens only the recovered ${recoveredChannel} channel after restart`, async () => {
|
|
147
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-relapse-'))
|
|
148
|
+
let now = 0, failRecovery = true
|
|
149
|
+
const pages: string[] = [], notices: string[] = []
|
|
150
|
+
const options = { stateDir, enrollmentToken: 'synthetic', now: () => now,
|
|
151
|
+
page: async (event: { action: string }) => {
|
|
152
|
+
if (event.action === 'resolve' && recoveredChannel === 'telegram' && failRecovery) throw new Error('recovery unavailable')
|
|
153
|
+
pages.push(event.action)
|
|
154
|
+
},
|
|
155
|
+
notify: async (message: string) => {
|
|
156
|
+
if (message.includes('recovered') && recoveredChannel === 'pagerduty' && failRecovery) throw new Error('recovery unavailable')
|
|
157
|
+
notices.push(message.includes('recovered') ? 'resolve' : 'trigger')
|
|
158
|
+
} }
|
|
159
|
+
try {
|
|
160
|
+
let watch = new WorkforceWatch(options)
|
|
161
|
+
const worker = await watch.enroll('synthetic', { workerId: 'synthetic-worker', checkInSeconds: 10, graceSeconds: 0 })
|
|
162
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: true })
|
|
163
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
164
|
+
await assert.rejects(() => watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' }), /recovery unavailable/)
|
|
165
|
+
watch = new WorkforceWatch(options)
|
|
166
|
+
now = relapse === 'missed' ? 10_001 : 1
|
|
167
|
+
if (relapse === 'missed') await watch.evaluate()
|
|
168
|
+
else await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: relapse === 'terminal' })
|
|
169
|
+
assert.deepEqual(pages, recoveredChannel === 'pagerduty' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
|
|
170
|
+
assert.deepEqual(notices, recoveredChannel === 'telegram' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
|
|
171
|
+
failRecovery = false
|
|
172
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
173
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
174
|
+
assert.equal((await watch.inspect(worker.workerId) as { incident?: unknown }).incident, undefined)
|
|
175
|
+
assert.equal(pages.at(-1), 'resolve')
|
|
176
|
+
assert.equal(notices.at(-1), 'resolve')
|
|
177
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|