@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +4 -0
- package/.env.example +16 -1
- package/AGENTS.md +16 -4
- package/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +37 -4
- package/README.md +114 -9
- package/SECURITY.md +7 -1
- package/bin/ezenciel-agents-schedule +2 -0
- package/bin/ezenciel-agents-schedule.mjs +16 -0
- package/bin/ezenciel-agents-task +2 -0
- package/bin/ezenciel-agents-task.mjs +16 -0
- package/compose.yaml +10 -2
- package/docker/recovery.ts +2 -2
- package/docker/run.ts +2 -2
- package/docs/architecture/ai-selection.md +8 -0
- package/docs/architecture/authority-boundaries.md +137 -12
- package/docs/architecture/event-sources.md +12 -7
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/channel-backend.md +36 -0
- package/docs/docker-runtime.md +35 -0
- package/docs/host-service.md +19 -0
- package/docs/local-qa.md +45 -0
- package/docs/pagerduty.md +42 -0
- package/docs/plugin-catalog.md +71 -0
- package/docs/plugin-contributions.md +12 -0
- package/docs/plugins.md +61 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/scheduling.md +153 -0
- package/docs/selective-monitoring.md +114 -0
- package/docs/setup.md +46 -0
- package/docs/standalone-cli.md +62 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/package.json +12 -4
- package/scripts/generate-publish-caller.mjs +60 -0
- package/scripts/smoke-busy-reply.ts +58 -0
- package/scripts/smoke-scheduler.ts +90 -0
- package/scripts/stage-qa.mjs +42 -0
- package/scripts/trusted-beta.mjs +289 -0
- package/src/agent-guidance.ts +5 -0
- package/src/ai-cli.ts +2 -1
- package/src/ai.ts +15 -5
- package/src/channel-backend.ts +46 -0
- package/src/client-defaults.ts +29 -13
- package/src/codex-session.ts +98 -0
- package/src/config.ts +35 -2
- package/src/control-state.ts +24 -7
- package/src/desktop-bridge.ts +37 -12
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +25 -0
- package/src/executor.ts +97 -21
- package/src/failure.ts +32 -0
- package/src/host-executor.ts +48 -19
- package/src/identity.ts +8 -3
- package/src/inbox.ts +11 -3
- package/src/index.ts +315 -91
- package/src/install-tools.mjs +2 -2
- package/src/menu.ts +6 -4
- package/src/model-policy.ts +15 -0
- package/src/owner.ts +3 -3
- package/src/pagerduty.ts +109 -0
- package/src/plugins/exposure.mjs +13 -0
- package/src/plugins/manager.mjs +74 -20
- package/src/plugins/shared.mjs +76 -0
- package/src/process-tree.ts +33 -0
- package/src/repair-policy.ts +13 -0
- package/src/reply-context.ts +67 -0
- package/src/reply-executor.ts +54 -0
- package/src/reply-mcp.ts +23 -0
- package/src/runs.ts +63 -19
- package/src/schedule-cli.ts +98 -0
- package/src/schedule-time.ts +85 -0
- package/src/scheduler.ts +130 -0
- package/src/setup.ts +2 -1
- package/src/software-status.ts +5 -5
- package/src/source-cli.ts +1 -1
- package/src/task-cli.ts +16 -0
- package/src/task-executor.ts +65 -0
- package/src/task-mcp.ts +36 -0
- package/src/task-rpc.ts +45 -0
- package/src/task-workspace.ts +22 -0
- package/src/tasks.ts +210 -0
- package/src/telegram-source.ts +94 -0
- package/src/updates/artifact.mjs +16 -0
- package/src/updates/binding.mjs +4 -1
- package/src/updates/control.mjs +4 -4
- package/src/updates/runtime.mjs +3 -1
- package/src/updates/status.mjs +7 -1
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +60 -1
- package/templates/agent-guidance.md +13 -0
- package/templates/failure-review.md +9 -0
- package/templates/maintainer-purpose.md +15 -0
- package/templates/standalone-tools.md +20 -0
- package/templates/updates.md +2 -2
- package/test/agent-guidance.test.ts +110 -0
- package/test/ai-cli.test.ts +7 -6
- package/test/ai.test.ts +41 -0
- package/test/busy-reply-relay.test.ts +41 -0
- package/test/channel-backend.test.ts +100 -0
- package/test/client-defaults.test.ts +37 -5
- package/test/codex-context.test.ts +39 -1
- package/test/codex-session.test.ts +51 -0
- package/test/config.test.ts +31 -2
- package/test/desktop-bridge.test.ts +19 -0
- package/test/event-sources.test.ts +47 -11
- package/test/execution-authority.test.ts +42 -0
- package/test/executor.test.ts +53 -2
- package/test/failure.test.ts +250 -0
- package/test/group-owner.test.ts +36 -0
- package/test/helpers/owner-run.ts +13 -0
- package/test/host-executor.test.ts +47 -10
- package/test/intake-relay.test.ts +141 -4
- package/test/local-qa.test.mjs +38 -0
- package/test/model-policy.test.ts +61 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +73 -3
- package/test/relay.test.ts +2 -2
- package/test/repair-policy.test.ts +23 -0
- package/test/reply.test.ts +131 -0
- package/test/schedule-cli.test.ts +55 -0
- package/test/scheduler-host.test.ts +55 -0
- package/test/scheduler-relay.test.ts +67 -0
- package/test/scheduler.test.ts +104 -0
- package/test/shared-services.test.mjs +98 -0
- package/test/software-status.test.ts +5 -5
- package/test/task-native.test.ts +87 -0
- package/test/tasks.test.ts +187 -0
- package/test/telegram-source.test.ts +75 -0
- package/test/trusted-beta.test.mjs +224 -0
- package/test/updates.test.mjs +35 -3
|
@@ -0,0 +1,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
|
+
});
|