@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.
Files changed (132) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +16 -1
  3. package/AGENTS.md +16 -4
  4. package/CHANGELOG.md +71 -0
  5. package/CONTRIBUTING.md +37 -4
  6. package/README.md +114 -9
  7. package/SECURITY.md +7 -1
  8. package/bin/ezenciel-agents-schedule +2 -0
  9. package/bin/ezenciel-agents-schedule.mjs +16 -0
  10. package/bin/ezenciel-agents-task +2 -0
  11. package/bin/ezenciel-agents-task.mjs +16 -0
  12. package/compose.yaml +10 -2
  13. package/docker/recovery.ts +2 -2
  14. package/docker/run.ts +2 -2
  15. package/docs/architecture/ai-selection.md +8 -0
  16. package/docs/architecture/authority-boundaries.md +137 -12
  17. package/docs/architecture/event-sources.md +12 -7
  18. package/docs/architecture/telegram-intake.md +1 -1
  19. package/docs/channel-backend.md +36 -0
  20. package/docs/docker-runtime.md +35 -0
  21. package/docs/host-service.md +19 -0
  22. package/docs/local-qa.md +45 -0
  23. package/docs/pagerduty.md +42 -0
  24. package/docs/plugin-catalog.md +71 -0
  25. package/docs/plugin-contributions.md +12 -0
  26. package/docs/plugins.md +61 -1
  27. package/docs/releasing.md +20 -9
  28. package/docs/repair.md +41 -0
  29. package/docs/scheduling.md +153 -0
  30. package/docs/selective-monitoring.md +114 -0
  31. package/docs/setup.md +46 -0
  32. package/docs/standalone-cli.md +62 -0
  33. package/docs/trusted-publishing.md +140 -0
  34. package/docs/upgrades.md +24 -4
  35. package/package.json +12 -4
  36. package/scripts/generate-publish-caller.mjs +60 -0
  37. package/scripts/smoke-busy-reply.ts +58 -0
  38. package/scripts/smoke-scheduler.ts +90 -0
  39. package/scripts/stage-qa.mjs +42 -0
  40. package/scripts/trusted-beta.mjs +289 -0
  41. package/src/agent-guidance.ts +5 -0
  42. package/src/ai-cli.ts +2 -1
  43. package/src/ai.ts +15 -5
  44. package/src/channel-backend.ts +46 -0
  45. package/src/client-defaults.ts +29 -13
  46. package/src/codex-session.ts +98 -0
  47. package/src/config.ts +35 -2
  48. package/src/control-state.ts +24 -7
  49. package/src/desktop-bridge.ts +37 -12
  50. package/src/event-sources.ts +2 -1
  51. package/src/execution-authority.ts +25 -0
  52. package/src/executor.ts +97 -21
  53. package/src/failure.ts +32 -0
  54. package/src/host-executor.ts +48 -19
  55. package/src/identity.ts +8 -3
  56. package/src/inbox.ts +11 -3
  57. package/src/index.ts +315 -91
  58. package/src/install-tools.mjs +2 -2
  59. package/src/menu.ts +6 -4
  60. package/src/model-policy.ts +15 -0
  61. package/src/owner.ts +3 -3
  62. package/src/pagerduty.ts +109 -0
  63. package/src/plugins/exposure.mjs +13 -0
  64. package/src/plugins/manager.mjs +74 -20
  65. package/src/plugins/shared.mjs +76 -0
  66. package/src/process-tree.ts +33 -0
  67. package/src/repair-policy.ts +13 -0
  68. package/src/reply-context.ts +67 -0
  69. package/src/reply-executor.ts +54 -0
  70. package/src/reply-mcp.ts +23 -0
  71. package/src/runs.ts +63 -19
  72. package/src/schedule-cli.ts +98 -0
  73. package/src/schedule-time.ts +85 -0
  74. package/src/scheduler.ts +130 -0
  75. package/src/setup.ts +2 -1
  76. package/src/software-status.ts +5 -5
  77. package/src/source-cli.ts +1 -1
  78. package/src/task-cli.ts +16 -0
  79. package/src/task-executor.ts +65 -0
  80. package/src/task-mcp.ts +36 -0
  81. package/src/task-rpc.ts +45 -0
  82. package/src/task-workspace.ts +22 -0
  83. package/src/tasks.ts +210 -0
  84. package/src/telegram-source.ts +94 -0
  85. package/src/updates/artifact.mjs +16 -0
  86. package/src/updates/binding.mjs +4 -1
  87. package/src/updates/control.mjs +4 -4
  88. package/src/updates/runtime.mjs +3 -1
  89. package/src/updates/status.mjs +7 -1
  90. package/templates/agent/AGENTS.md +10 -2
  91. package/templates/agent/TOOLS.md +60 -1
  92. package/templates/agent-guidance.md +13 -0
  93. package/templates/failure-review.md +9 -0
  94. package/templates/maintainer-purpose.md +15 -0
  95. package/templates/standalone-tools.md +20 -0
  96. package/templates/updates.md +2 -2
  97. package/test/agent-guidance.test.ts +110 -0
  98. package/test/ai-cli.test.ts +7 -6
  99. package/test/ai.test.ts +41 -0
  100. package/test/busy-reply-relay.test.ts +41 -0
  101. package/test/channel-backend.test.ts +100 -0
  102. package/test/client-defaults.test.ts +37 -5
  103. package/test/codex-context.test.ts +39 -1
  104. package/test/codex-session.test.ts +51 -0
  105. package/test/config.test.ts +31 -2
  106. package/test/desktop-bridge.test.ts +19 -0
  107. package/test/event-sources.test.ts +47 -11
  108. package/test/execution-authority.test.ts +42 -0
  109. package/test/executor.test.ts +53 -2
  110. package/test/failure.test.ts +250 -0
  111. package/test/group-owner.test.ts +36 -0
  112. package/test/helpers/owner-run.ts +13 -0
  113. package/test/host-executor.test.ts +47 -10
  114. package/test/intake-relay.test.ts +141 -4
  115. package/test/local-qa.test.mjs +38 -0
  116. package/test/model-policy.test.ts +61 -0
  117. package/test/pagerduty.test.ts +104 -0
  118. package/test/plugin-manager.test.mjs +73 -3
  119. package/test/relay.test.ts +2 -2
  120. package/test/repair-policy.test.ts +23 -0
  121. package/test/reply.test.ts +131 -0
  122. package/test/schedule-cli.test.ts +55 -0
  123. package/test/scheduler-host.test.ts +55 -0
  124. package/test/scheduler-relay.test.ts +67 -0
  125. package/test/scheduler.test.ts +104 -0
  126. package/test/shared-services.test.mjs +98 -0
  127. package/test/software-status.test.ts +5 -5
  128. package/test/task-native.test.ts +87 -0
  129. package/test/tasks.test.ts +187 -0
  130. package/test/telegram-source.test.ts +75 -0
  131. package/test/trusted-beta.test.mjs +224 -0
  132. 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
+ });
@@ -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 stable; prepared local candidates need explicit authority and a live supervisor',async t=>{
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);await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
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
- assert((await read(path.join(f.agent.controlDir,'update-attention.json'))).id);
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
+ });