@jc_stack/ez-agents 0.1.0-beta.12

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 (147) hide show
  1. package/.dockerignore +24 -0
  2. package/.env.example +26 -0
  3. package/AGENTS.md +84 -0
  4. package/CHANGELOG.md +39 -0
  5. package/CONTRIBUTING.md +73 -0
  6. package/Dockerfile +16 -0
  7. package/LICENSE +21 -0
  8. package/README.md +134 -0
  9. package/SECURITY.md +26 -0
  10. package/THIRD_PARTY_NOTICES.md +14 -0
  11. package/bin/ezenciel-agents +2 -0
  12. package/bin/ezenciel-agents-ai +2 -0
  13. package/bin/ezenciel-agents-ai.mjs +5 -0
  14. package/bin/ezenciel-agents-approval +2 -0
  15. package/bin/ezenciel-agents-approval.mjs +16 -0
  16. package/bin/ezenciel-agents-create +12 -0
  17. package/bin/ezenciel-agents-docker +6 -0
  18. package/bin/ezenciel-agents-host +5 -0
  19. package/bin/ezenciel-agents-install +2 -0
  20. package/bin/ezenciel-agents-message +2 -0
  21. package/bin/ezenciel-agents-message.mjs +16 -0
  22. package/bin/ezenciel-agents-owner +2 -0
  23. package/bin/ezenciel-agents-owner.mjs +18 -0
  24. package/bin/ezenciel-agents-react +2 -0
  25. package/bin/ezenciel-agents-react.mjs +16 -0
  26. package/bin/ezenciel-agents-setup.mjs +18 -0
  27. package/bin/ezenciel-agents-source +2 -0
  28. package/bin/ezenciel-agents-source.mjs +16 -0
  29. package/bin/ezenciel-agents-tools.mjs +3 -0
  30. package/bin/ezenciel-agents.mjs +37 -0
  31. package/compose.whatsapp.yaml +12 -0
  32. package/compose.yaml +40 -0
  33. package/default-plugins.json +1 -0
  34. package/docker/entrypoint.sh +15 -0
  35. package/docker/healthcheck.mjs +8 -0
  36. package/docker/plugin-smoke.mjs +48 -0
  37. package/docker/pnpm-lock.yaml +415 -0
  38. package/docker/recovery.ts +11 -0
  39. package/docker/run.ts +52 -0
  40. package/docker/smoke.mjs +47 -0
  41. package/docker/status-smoke.mjs +30 -0
  42. package/docker/upgrade-smoke.mjs +58 -0
  43. package/docs/architecture/ai-selection.md +37 -0
  44. package/docs/architecture/authority-boundaries.md +14 -0
  45. package/docs/architecture/event-sources.md +34 -0
  46. package/docs/architecture/telegram-intake.md +29 -0
  47. package/docs/development-and-testing.md +18 -0
  48. package/docs/docker-runtime.md +118 -0
  49. package/docs/host-service.md +80 -0
  50. package/docs/plugin-contributions.md +34 -0
  51. package/docs/plugins.md +181 -0
  52. package/docs/releasing.md +71 -0
  53. package/docs/setup.md +234 -0
  54. package/docs/upgrades.md +193 -0
  55. package/package.json +106 -0
  56. package/scripts/assert-local-registry.mjs +22 -0
  57. package/scripts/release-check.mjs +14 -0
  58. package/scripts/smoke.ts +102 -0
  59. package/src/agent-install.ts +96 -0
  60. package/src/ai-cli.ts +22 -0
  61. package/src/ai.ts +88 -0
  62. package/src/approval-cli.ts +59 -0
  63. package/src/approval.ts +119 -0
  64. package/src/audio.ts +184 -0
  65. package/src/client-defaults.ts +101 -0
  66. package/src/config.ts +48 -0
  67. package/src/control-state.ts +350 -0
  68. package/src/desktop-bridge.ts +284 -0
  69. package/src/event-sources.ts +112 -0
  70. package/src/executor.ts +335 -0
  71. package/src/files.ts +75 -0
  72. package/src/format.ts +57 -0
  73. package/src/host-executor-client.ts +46 -0
  74. package/src/host-executor-protocol.ts +2 -0
  75. package/src/host-executor.ts +129 -0
  76. package/src/identity.ts +17 -0
  77. package/src/inbox.ts +171 -0
  78. package/src/index.ts +812 -0
  79. package/src/install-config.ts +56 -0
  80. package/src/install-tools.mjs +98 -0
  81. package/src/menu.ts +123 -0
  82. package/src/message-send.ts +57 -0
  83. package/src/message.ts +68 -0
  84. package/src/owner-args.ts +4 -0
  85. package/src/owner.ts +30 -0
  86. package/src/plugins/manager.mjs +272 -0
  87. package/src/react.ts +33 -0
  88. package/src/reaction.ts +32 -0
  89. package/src/read-request.ts +72 -0
  90. package/src/reply.ts +13 -0
  91. package/src/runs.ts +445 -0
  92. package/src/service.ts +28 -0
  93. package/src/setup.ts +180 -0
  94. package/src/software-status.ts +23 -0
  95. package/src/source-cli.ts +18 -0
  96. package/src/update-attention.ts +18 -0
  97. package/src/updates/artifact.mjs +83 -0
  98. package/src/updates/binding.mjs +27 -0
  99. package/src/updates/control.mjs +131 -0
  100. package/src/updates/launch.mjs +13 -0
  101. package/src/updates/runtime.mjs +140 -0
  102. package/src/updates/status.mjs +49 -0
  103. package/src/updates/supervisor.mjs +102 -0
  104. package/src/version.ts +4 -0
  105. package/src/workspace.ts +32 -0
  106. package/templates/agent/AGENTS.md +49 -0
  107. package/templates/agent/SOUL.md +11 -0
  108. package/templates/agent/TOOLS.md +46 -0
  109. package/templates/agent/USER.md +5 -0
  110. package/templates/updates.md +45 -0
  111. package/test/agent-install.test.ts +48 -0
  112. package/test/ai-cli.test.ts +28 -0
  113. package/test/ai.test.ts +105 -0
  114. package/test/approval.test.ts +40 -0
  115. package/test/audio.test.ts +77 -0
  116. package/test/client-defaults.test.ts +64 -0
  117. package/test/codex-context.test.ts +33 -0
  118. package/test/config.test.ts +32 -0
  119. package/test/control-state.test.ts +58 -0
  120. package/test/desktop-bridge.test.ts +159 -0
  121. package/test/docker-runtime.test.ts +23 -0
  122. package/test/event-sources.test.ts +113 -0
  123. package/test/executor.test.ts +135 -0
  124. package/test/files.test.ts +50 -0
  125. package/test/format.test.ts +41 -0
  126. package/test/host-executor.test.ts +149 -0
  127. package/test/inbox-burst.test.ts +66 -0
  128. package/test/inbox.test.ts +102 -0
  129. package/test/install-config.test.ts +75 -0
  130. package/test/install-tools.test.mjs +59 -0
  131. package/test/intake-relay.test.ts +337 -0
  132. package/test/owner-help.test.mjs +10 -0
  133. package/test/plugin-manager.test.mjs +157 -0
  134. package/test/publish-guard.test.ts +21 -0
  135. package/test/reaction.test.ts +122 -0
  136. package/test/read-request.test.ts +134 -0
  137. package/test/relay.test.ts +254 -0
  138. package/test/release-entrypoints.test.mjs +26 -0
  139. package/test/runs.test.ts +116 -0
  140. package/test/security.test.ts +85 -0
  141. package/test/setup.test.ts +68 -0
  142. package/test/software-status.test.ts +31 -0
  143. package/test/update-attention.test.ts +21 -0
  144. package/test/updates.test.mjs +282 -0
  145. package/test/upgrade-pause.test.ts +70 -0
  146. package/test/workspace.test.ts +80 -0
  147. package/tsconfig.json +19 -0
@@ -0,0 +1,193 @@
1
+ # Agent-owned software upgrades
2
+
3
+ Available in this beta. Earlier main upgrade/rollback VM QA passed; final-release
4
+ fresh-host/reboot and live plugin upgrade acceptance remain pending. npm
5
+ publication is not required to test this feature. Stable releases are the default
6
+ automatic channel. The existing owner may select beta or manual policy per target.
7
+ The main target and installed plugins version independently.
8
+
9
+ The agent owns release review, policy decisions and communication. The host
10
+ supervisor owns interruption-safe replacement. There is one host service per
11
+ agent, not a second agent or an additional updater daemon. Its replaceable child
12
+ runs the normal CLI transport. It checks npm every six hours while running and
13
+ queues an owner-bound maintenance turn only when an automatic channel changes.
14
+ No owner means no maintenance executor. Normal user work and maintenance share
15
+ one serial queue. Checks use the installed scoped npm identity; failures are
16
+ visible in `updates check` and private `tools/updates/available.json`.
17
+
18
+ ## Installation and scope
19
+
20
+ Use normal setup and initialize the registry with this deployment's
21
+ `host-executor.json`. This binds `ez updates`, the active package root and the
22
+ Software updates guidance in TOOLS.md. Start `ezenciel-agents-host` using the
23
+ normal OS service template. The host service must use the existing user's Node,
24
+ pnpm (or Corepack) and Docker access. Never put tokens in its environment. Keep the original
25
+ package directory: its small bootstrap remains the service entry point and loads
26
+ the active supervisor on each restart. It must not be moved or garbage-collected.
27
+
28
+ Older beta installations have no supervisor or release contract. Their initial
29
+ migration to this candidate is installer-owned: stop the old host/relay, install
30
+ and build this candidate, retain the deployment, and use its tools entry point:
31
+
32
+ ```sh
33
+ node /absolute/candidate/package/bin/ezenciel-agents-tools.mjs enable-updates \
34
+ --home /absolute/deployment/tools \
35
+ --host-config /absolute/deployment/host-executor.json
36
+ ```
37
+
38
+ Point the host service and relay Compose source/image at the candidate, then
39
+ restart and verify. Do not recreate the agent or its registry. Subsequent
40
+ compatible upgrades are agent-owned. Noncanonical/multi-agent host configuration
41
+ files require migration to one canonical deployment per service first.
42
+
43
+ ## Package-manager preflight and repair
44
+
45
+ Before installing candidate dependencies or stopping services, the supervisor
46
+ checks `pnpm --version` in its own service environment. It requires the exact
47
+ `packageManager` version declared by the candidate. If that launcher is missing,
48
+ broken or reports another version, it tries `corepack pnpm@<exact-version>`
49
+ directly and verifies the result. Corepack may download/cache that pinned version;
50
+ it does not need a global pnpm shim. The selected command/version is in the job
51
+ receipt. Both routes retain `--frozen-lockfile --ignore-scripts`.
52
+
53
+ If neither route works, the job fails before dependency installation or service
54
+ replacement, with both errors and repair guidance. The agent should inspect the
55
+ host service's PATH and existing launchers first, including the Node installation
56
+ and any known pnpm installation directory. Reuse them; do not install a second
57
+ manager just because the interactive shell sees a different PATH. If provisioning
58
+ is actually necessary, install the pinned pnpm version using an available package
59
+ manager (npm can provision pnpm). Do not run `npm install` on the Ez candidate:
60
+ that would replace the pnpm lockfile contract. Do not source arbitrary shell
61
+ profiles or copy secrets into the service environment.
62
+
63
+ For an existing beta.3 supervisor reporting `spawn pnpm ENOENT`, a newer candidate
64
+ cannot repair the updater that must install it. The VM agent must first expose a
65
+ real pnpm launcher in that supervisor's PATH. With working Corepack it can use
66
+ `corepack enable pnpm --install-directory /absolute/user-owned/bin` and include
67
+ that directory in the service PATH. Verify the pinned version under the same user,
68
+ PATH and working directory as the service. Schedule any needed service restart
69
+ after the requesting turn ends, then verify its fresh heartbeat and Telegram reply.
70
+ Keep the deployment, accounts, images and job receipts intact.
71
+
72
+ Read `ez updates status` after repair. For `failed` or `rolled-back`, prepare the
73
+ same candidate again and apply the new job; do not edit the old receipt. Only
74
+ `recovery-required` uses `ez updates recover <job-id>` to restore the prior runtime.
75
+ Finish the requesting turn after queuing either operation. Verify the main relay
76
+ before requesting a separate plugin upgrade.
77
+
78
+ ## Agent interface
79
+
80
+ Telegram `/status` shows the relay's loaded version, fresh host version and
81
+ installed plugin versions alongside work/queue health. The host heartbeat carries
82
+ only plugin IDs/versions; the relay gets no plugin registry or Docker access.
83
+ Plugin versions in Telegram are explicitly installed versions, not live-provider
84
+ verification. Stale or legacy host metadata is shown as unavailable/unknown.
85
+
86
+ Use `ez status` (also `ez updates status`) for `{main, plugins, jobs}`. Status now
87
+ returns an object; the previous bare job array is under `jobs`. It is read-only
88
+ and does not contact npm, start services or require an upgrade job to exist.
89
+
90
+ `main.installedVersion` comes from the active package binding. `runningVersion`
91
+ comes from a fresh polling heartbeat written by the running relay; `main.host`
92
+ reports the host transport separately. Versions are captured when each process
93
+ loads, so an older running process is not relabeled by a newer installation.
94
+ Old releases without heartbeat versions, stale/offline services and unreadable
95
+ evidence report `runningVersion: null`; never infer a running version from the
96
+ installed version alone.
97
+
98
+ Each plugin includes `installedVersion`, runtime state and service health.
99
+ `runningVersion` is the registered plugin version only when all declared services
100
+ are running with image IDs matching the registered deployment's expected images.
101
+ This is deployment-image evidence, not a provider account/delivery check. Stopped,
102
+ partial or mismatched deployments report a null running version; Docker failures
103
+ report unknown while keeping installed versions visible. Plugins need not be Node
104
+ packages inside their containers: status does not execute commands inside them.
105
+ `ez plugins status <id>` also includes its registered `installedVersion`.
106
+
107
+ Check both installed and running versions after an upgrade before reporting it as
108
+ live. A healthy container alone does not prove a Telegram or plugin reply.
109
+
110
+ ```sh
111
+ ez updates check
112
+ ez updates policy main # defaults: automatic, stable
113
+ ez updates policy whatsapp beta # only under owner authorization
114
+ ez updates policy main manual # disable unattended upgrades
115
+ ez updates prepare main --version 0.1.0-beta.4
116
+ # Or a local candidate, independently of npm:
117
+ ez updates prepare whatsapp --file /absolute/candidates/whatsapp.tgz
118
+ ez updates apply <returned-job-id> # explicit owner-requested candidate
119
+ # Automatic registry release within saved policy:
120
+ ez updates apply <returned-job-id> --automatic
121
+ ez updates status
122
+ ```
123
+
124
+ Examples are candidate version placeholders, not a claim of published versions.
125
+ `prepare` downloads from npm or reads a local tarball, checks integrity, rejects
126
+ unsafe archive entries, and records identity/version/hash. It does not stop a
127
+ service. Local candidates never qualify for `--automatic`; an explicit request
128
+ is required. Stable automation excludes prereleases, major changes and 0.x minor
129
+ changes. Beta policy permits compatible prereleases. Downgrades are rejected;
130
+ rollback is a recovery operation for the previous installation only.
131
+
132
+ `apply` records one durable queued job. **The agent must finish its turn after
133
+ queuing it.** It must not wait for completion in that same turn. The supervisor
134
+ pauses new run admission, drains already-started work, then consumes the job.
135
+ One job at a time; an unresolved recovery blocks new upgrades. The agent processes
136
+ multiple requested packages over successive maintenance turns.
137
+
138
+ The exact archive is reverified and re-extracted before execution; editing a
139
+ prepared tree cannot substitute code. Main dependencies install with the frozen
140
+ lock and lifecycle scripts disabled. Images build before services stop. The host
141
+ pins the previous relay image, saves a private backup and replaces the runtime
142
+ and host child. Plugin replacements use the registry lock and canonical project
143
+ and volumes. Running plugins restart with a health check; stopped plugins stay
144
+ stopped and report `runtimeVerified:false`. Upgrading never links an account.
145
+
146
+ ## Recovery and limits
147
+
148
+ Job receipts live privately under `tools/updates/<job-id>/`. `status` omits private
149
+ rollback configuration. States are prepared, queued, applying, completed, failed,
150
+ rolled-back and recovery-required. A service restart recovers an interrupted
151
+ applying job before resuming normal work. A failed build leaves the old runtime
152
+ running. Failed activation restores the previous code/configuration and checks it.
153
+ If recovery itself fails, inspect and fix the reported infrastructure problem,
154
+ then use `ez updates recover <job-id>` and finish the turn. This retries only the
155
+ saved code/configuration recovery, not provider operations.
156
+ Completion or failure wakes the agent to inspect the receipt and report naturally.
157
+
158
+ Backups contain credentials and must stay private. Main backups cover the
159
+ canonical mind/control and deployment files. Plugins back up existing named
160
+ volumes while their services are stopped, using the previous image's `tar` and
161
+ read-only volume mounts; archive output is written by the host with mode 0600.
162
+ No backup deletes live data or revokes credentials. Retain old package roots and
163
+ images until QA and any recovery window are complete; there is no automatic GC.
164
+
165
+ The updater accepts only matching state-schema and protocol contracts and an
166
+ unchanged deployment layout. Changes to privileges, services, volumes or Compose
167
+ configuration fail before replacement, even for an explicit candidate. These
168
+ need a separately reviewed migration, not an override flag. Release authors must
169
+ truthfully declare schema compatibility. Code rollback **does not rewind private
170
+ state**, provider cursors or operation receipts; uncertain actions are never
171
+ replayed. Docker health is not proof of live provider identity or delivery.
172
+
173
+ ## Contributor acceptance
174
+
175
+ Every main/plugin release must declare `package.json.ezRelease`:
176
+
177
+ ```json
178
+ {"protocol":1,"kind":"plugin","stateSchema":1,"mainProtocol":1}
179
+ ```
180
+
181
+ Use `kind: "main"` for the main package. `stateSchema` is the compatibility epoch
182
+ for persisted state: keep it only when the previous release can safely read state
183
+ written by the new one. Increment it for incompatible writes; this updater will
184
+ refuse that migration. `mainProtocol` identifies the supported updater/registry
185
+ contract, currently 1. Plugin package and manifest versions must match.
186
+
187
+ Verify upgrade from the previous supported artifact, retained identity/state,
188
+ failed-health rollback, and rejection of incompatible candidates. Main runtime
189
+ changes also need supervisor restart and requesting-process-exit tests. Run
190
+ `pnpm verify`, `npm run release:check`, packed Docker builds and
191
+ `node docker/upgrade-smoke.mjs` with a local `EZ_WHATSAPP_SOURCE` containing the
192
+ WhatsApp fixture. The smoke uses synthetic transport only. VM installation,
193
+ agent-led upgrades, restart and real account acceptance remain separate QA gates.
package/package.json ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "name": "@jc_stack/ez-agents",
3
+ "version": "0.1.0-beta.12",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "A minimal Telegram-to-CLI executor relay.",
7
+ "packageManager": "pnpm@10.30.3",
8
+ "bin": {
9
+ "ezenciel-agents-install": "bin/ezenciel-agents-install",
10
+ "ezenciel-agents": "bin/ezenciel-agents.mjs",
11
+ "ezenciel-agents-owner": "bin/ezenciel-agents-owner.mjs",
12
+ "ezenciel-agents-message": "bin/ezenciel-agents-message.mjs",
13
+ "ezenciel-agents-react": "bin/ezenciel-agents-react.mjs",
14
+ "ezenciel-agents-approval": "bin/ezenciel-agents-approval.mjs",
15
+ "ezenciel-agents-setup": "bin/ezenciel-agents-setup.mjs",
16
+ "ezenciel-agents-source": "bin/ezenciel-agents-source.mjs",
17
+ "ezenciel-agents-docker": "bin/ezenciel-agents-docker",
18
+ "ezenciel-agents-create": "bin/ezenciel-agents-create",
19
+ "ezenciel-agents-host": "bin/ezenciel-agents-host",
20
+ "ezenciel-agents-tools": "bin/ezenciel-agents-tools.mjs",
21
+ "ezenciel-agents-ai": "bin/ezenciel-agents-ai.mjs"
22
+ },
23
+ "files": [
24
+ "default-plugins.json",
25
+ "bin",
26
+ "src",
27
+ "docs",
28
+ "templates",
29
+ ".env.example",
30
+ "AGENTS.md",
31
+ "README.md",
32
+ "SECURITY.md",
33
+ "CONTRIBUTING.md",
34
+ "Dockerfile",
35
+ "compose.yaml",
36
+ "docker",
37
+ "compose.whatsapp.yaml",
38
+ "scripts/smoke.ts",
39
+ "LICENSE",
40
+ "CHANGELOG.md",
41
+ "THIRD_PARTY_NOTICES.md",
42
+ "scripts/release-check.mjs",
43
+ "test",
44
+ "tsconfig.json",
45
+ "scripts/assert-local-registry.mjs",
46
+ ".dockerignore"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "tag": "beta"
51
+ },
52
+ "scripts": {
53
+ "setup": "tsx --env-file-if-exists=.env src/setup.ts",
54
+ "dev": "EZ_DEVELOPMENT=1 tsx watch --clear-screen=false --exclude 'agent/**' --exclude 'test/**' --env-file=.env src/index.ts",
55
+ "start": "./bin/ezenciel-agents-docker up -d --wait relay",
56
+ "owner": "tsx --env-file=.env src/owner.ts",
57
+ "message": "tsx src/message.ts",
58
+ "react": "tsx src/react.ts",
59
+ "build": "tsc --noEmit",
60
+ "test": "tsx --test test/*.test.ts test/*.test.mjs",
61
+ "verify": "pnpm test && pnpm build",
62
+ "smoke": "./bin/ezenciel-agents-docker run --rm --no-deps relay smoke",
63
+ "prepublishOnly": "npm run verify",
64
+ "registry:up": "docker compose -f registry/docker-compose.yml up -d",
65
+ "registry:down": "docker compose -f registry/docker-compose.yml down",
66
+ "publish:local": "node scripts/publish-local.mjs",
67
+ "publish:watch": "node scripts/publish-local.mjs --watch",
68
+ "smoke:docker": "node docker/smoke.mjs",
69
+ "host:executor": "tsx src/host-executor.ts",
70
+ "smoke:plugins": "node docker/plugin-smoke.mjs",
71
+ "release:check": "node scripts/release-check.mjs"
72
+ },
73
+ "engines": {
74
+ "node": ">=22"
75
+ },
76
+ "dependencies": {
77
+ "grammy": "^1.39.3",
78
+ "tsx": "^4.21.0"
79
+ },
80
+ "devDependencies": {
81
+ "@types/node": "^24.10.1",
82
+ "typescript": "^5.9.3"
83
+ },
84
+ "license": "MIT",
85
+ "keywords": [
86
+ "ai-agent",
87
+ "cli",
88
+ "docker",
89
+ "telegram",
90
+ "automation"
91
+ ],
92
+ "repository": {
93
+ "type": "git",
94
+ "url": "git+https://github.com/jdorado/ez-agents.git"
95
+ },
96
+ "homepage": "https://github.com/jdorado/ez-agents#readme",
97
+ "bugs": {
98
+ "url": "https://github.com/jdorado/ez-agents/issues"
99
+ },
100
+ "ezRelease": {
101
+ "protocol": 1,
102
+ "kind": "main",
103
+ "stateSchema": 1,
104
+ "mainProtocol": 1
105
+ }
106
+ }
@@ -0,0 +1,22 @@
1
+ const registry = process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY || ''
2
+ const allowed = process.env.EZ_LOCAL_REGISTRY || 'http://127.0.0.1:4873/'
3
+
4
+ const allowedHosts = new Set(['127.0.0.1:4873', 'localhost:4873'])
5
+ try {
6
+ allowedHosts.add(new URL(allowed).host)
7
+ } catch {
8
+ throw new Error(`EZ_LOCAL_REGISTRY is not a URL: ${allowed}`)
9
+ }
10
+
11
+ let host = ''
12
+ try {
13
+ host = new URL(registry).host
14
+ } catch {
15
+ host = ''
16
+ }
17
+
18
+ if (!host || !allowedHosts.has(host)) {
19
+ throw new Error(
20
+ `Refusing to publish outside the local ez registry. Received ${registry || '(no registry)'}. Use pnpm publish:local.`,
21
+ )
22
+ }
@@ -0,0 +1,14 @@
1
+ // Read-only package boundary check; no credentials or provider access.
2
+ import {execFileSync} from 'node:child_process';
3
+ import {readFileSync} from 'node:fs';
4
+ import assert from 'node:assert/strict';
5
+ assert.equal(readFileSync('docker/pnpm-lock.yaml','utf8'),readFileSync('pnpm-lock.yaml','utf8'),'Refresh docker/pnpm-lock.yaml after dependency changes');
6
+ const p=JSON.parse(readFileSync('package.json','utf8'));
7
+ const [pack]=JSON.parse(execFileSync('npm',['pack','--dry-run','--ignore-scripts','--json'],{encoding:'utf8'}));
8
+ const names=pack.files.map(f=>f.path);
9
+ for(const required of ['LICENSE','README.md','SECURITY.md','CONTRIBUTING.md','CHANGELOG.md','THIRD_PARTY_NOTICES.md','Dockerfile','.dockerignore','docker/pnpm-lock.yaml']) assert(names.includes(required),`Missing ${required}`);
10
+ for(const name of names) assert(!/^agent\//.test(name) && !/(^|\/)(node_modules|\.git|\.private|todo\.md|principles\.md|backlog\.md|sprints\.md)(\/|$)|(^|\/)\.env$|\.(tgz|log)$|(^|\/)(qa|plugin-manager-qa|spec-benchmark)\.md$/.test(name),`Private/internal package entry: ${name}`);
11
+ for(const entry of p.files) assert(names.some(name=>name===entry || name.startsWith(entry+'/')),`Declared package entry missing: ${entry}`);
12
+ for(const bin of Object.values(p.bin||{})) assert(names.includes(bin),`Missing binary ${bin}`);
13
+ if(names.includes('ez-plugin.json')) assert.equal(JSON.parse(readFileSync('ez-plugin.json')).version,p.version);
14
+ console.log(JSON.stringify({name:p.name,version:p.version,files:names,unpackedSize:pack.unpackedSize},null,2));
@@ -0,0 +1,102 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { loadConfig } from '../src/config.js'
6
+ import { ControlStore } from '../src/control-state.js'
7
+ import { startExecutorJob, terminateJob } from '../src/executor.js'
8
+ import { RunStore } from '../src/runs.js'
9
+ import { createRelay } from '../src/index.js'
10
+
11
+ // Outbound integration test, not proof of Telegram intake or UI interactions.
12
+ // Run while the polling relay is stopped; the owner must already be paired.
13
+ async function main() {
14
+ const config = loadConfig()
15
+ let prompt = 'Smoke test: use the messaging tool to send exactly "Smoke test passed ✅".'
16
+ let resume = false
17
+ const args = process.argv.slice(2).filter((arg) => arg !== '--')
18
+ for (let i = 0; i < args.length; i++) {
19
+ if (args[i] === '--resume') resume = true
20
+ else if (args[i] === '--cli' && args[i + 1]) config.executorCli = args[++i]
21
+ else if (args[i] === '--prompt' && args[i + 1]) prompt = args[++i]
22
+ else throw new Error('Usage: pnpm smoke [--cli grok] [--resume] [--prompt "..."]')
23
+ }
24
+ const relay = createRelay(config)
25
+ await relay.bot.api.getMe()
26
+ const state = await new ControlStore(config.controlDir, config.pairingTtlMs).status()
27
+ const owner = state.owner
28
+ const choice = state.ai?.presets.find(p => p.id === state.ai!.selectedId && p.cli === config.executorCli)
29
+ const session = resume ? state.activeSession : undefined
30
+ if (resume && (!session?.hasStarted || session.cli !== config.executorCli))
31
+ throw new Error('Resume smoke requires an existing started session for the selected CLI')
32
+ if (!owner) throw new Error('Pair an owner before running smoke')
33
+ const runs = new RunStore(config.controlDir)
34
+ if ((await runs.running()) || (await runs.nextQueued()))
35
+ throw new Error('Stop the relay and finish queued work before smoke')
36
+ const run = await runs.create({
37
+ chatId: owner.telegramChatId,
38
+ telegramUserId: owner.telegramUserId,
39
+ texts: [prompt],
40
+ })
41
+ await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
42
+ const job = await startExecutorJob(run.texts, {
43
+ workspace: config.workspace,
44
+ timeoutMs: config.executorTimeoutMs,
45
+ runId: run.id,
46
+ controlDir: config.controlDir,
47
+ binDir: join(dirname(fileURLToPath(import.meta.url)), '..', 'bin'),
48
+ cli: config.executorCli,
49
+ model: choice?.model,
50
+ effort: choice?.effort,
51
+ sessionId: session?.nativeSessionId ?? session?.sessionId ?? randomUUID(),
52
+ isResume: resume,
53
+ }).catch(async (error) => {
54
+ await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
55
+ throw error
56
+ })
57
+ const stop = () => terminateJob(job.child)
58
+ process.once('SIGINT', stop)
59
+ process.once('SIGTERM', stop)
60
+ const finished = new Promise<number>((resolve) => job.child.once('close', (code) => resolve(code ?? 1)))
61
+ let drain: Promise<void> = Promise.resolve()
62
+ const timer = setInterval(() => {
63
+ drain = drain.then(() => relay.drainOutbox(run.id))
64
+ }, 500)
65
+ try {
66
+ await runs.patch(run.id, { pid: job.child.pid })
67
+ const code = await finished
68
+ clearInterval(timer)
69
+ await drain
70
+ await relay.drainOutbox(run.id)
71
+ await runs.patch(run.id, {
72
+ status: code === 0 ? 'completed' : 'failed',
73
+ endedAt: new Date().toISOString(),
74
+ })
75
+ const names = (await readdir(join(config.controlDir, 'outbox'))).filter((name) =>
76
+ name.startsWith(run.id + '_'),
77
+ )
78
+ const receipts = []
79
+ for (const name of names.filter((name) => name.endsWith('.sent.json'))) {
80
+ const item = JSON.parse(await readFile(join(config.controlDir, 'outbox', name), 'utf8'))
81
+ if (item.receipt) receipts.push(item.receipt)
82
+ }
83
+ console.log(
84
+ JSON.stringify({ runId: run.id, executor: config.executorCli, model: choice?.model, effort: choice?.effort, exitCode: code, receipts }, null, 2),
85
+ )
86
+ if (code !== 0 || !receipts.length || names.some((name) => !name.endsWith('.sent.json'))) {
87
+ throw new Error('Smoke failed: executor failure, missing receipt, or undelivered outbox item')
88
+ }
89
+ console.log('Outbound smoke passed. Telegram intake and button/media UI still require a live chat test.')
90
+ } finally {
91
+ clearInterval(timer)
92
+ process.removeListener('SIGINT', stop)
93
+ process.removeListener('SIGTERM', stop)
94
+ await job.cleanup()
95
+ await relay.stop()
96
+ }
97
+ }
98
+
99
+ main().catch((error) => {
100
+ console.error(error.message)
101
+ process.exitCode = 1
102
+ })
@@ -0,0 +1,96 @@
1
+ import { mkdir, readFile, writeFile, rm, readdir } from 'node:fs/promises'
2
+ import { isAbsolute, join, resolve, dirname } from 'node:path'
3
+ import { parseArgs } from 'node:util'
4
+ import { executorKey } from './executor.js'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ // Recorded by the installing CLI once; agent creation inherits this binding.
8
+ export const installationCli = async (root: string, installerCli?: string): Promise<string> => {
9
+ const file = join(root, 'installation.json')
10
+ let saved: string | undefined
11
+ try {
12
+ const value = JSON.parse(await readFile(file, 'utf8')).cli
13
+ if (typeof value !== 'string' || !value.trim()) throw new Error('Installation CLI record is invalid')
14
+ saved = executorKey(value)
15
+ }
16
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
17
+ const requested = installerCli?.trim() ? executorKey(installerCli) : undefined
18
+ if (saved) {
19
+ if (requested && requested !== saved) throw new Error('Use the CLI already selected for this host installation')
20
+ return saved
21
+ }
22
+ if (!requested) throw new Error('The installing agent must record its CLI during package installation; no default is guessed.')
23
+ await mkdir(root, {recursive:true, mode:0o700})
24
+ try { await writeFile(file, JSON.stringify({cli:requested})+'\n', {mode:0o600,flag:'wx'}) }
25
+ catch(error) {
26
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
27
+ return installationCli(root, requested)
28
+ }
29
+ return requested
30
+ }
31
+
32
+ export const createAgent = async (options: {
33
+ root: string; hostRoot: string; composeFile: string; name: string; purpose: string; token: string; cli?: string; image?: string
34
+ }) => {
35
+ const { root, hostRoot, composeFile, name, purpose, token } = options
36
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(name)) throw new Error('Use an agent name of 1–40 lowercase letters, digits or hyphens, starting with a letter.')
37
+ if (![root, hostRoot, composeFile].every(p => isAbsolute(p) && !/[\r\n\0']/.test(p)))
38
+ throw new Error('Installation paths must be absolute and contain no newline or single quote.')
39
+ if (!purpose.trim() || purpose.length > 12000) throw new Error('Supply a purpose of 1–12000 characters.')
40
+ if (!/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token)) throw new Error('Supply the BotFather token through stdin.')
41
+ const image=options.image||'ezenciel-agents:local'
42
+ if(!/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image))throw new Error('Invalid relay image reference')
43
+ await mkdir(root, {recursive:true, mode:0o700})
44
+ const cli = await installationCli(root, options.cli)
45
+ const directory = join(root, name), deploymentDir = join(hostRoot, name)
46
+ // Exclusive directory creation: a repeated name never overwrites another agent.
47
+ await mkdir(directory, {mode:0o700})
48
+ try {
49
+ const project = `ez-agent-${name}`
50
+ const values = {
51
+ COMPOSE_PROJECT_NAME: project,
52
+ COMPOSE_FILE: composeFile,
53
+ EZ_RELAY_IMAGE: image,
54
+ EZ_RELAY_ENV_FILE: join(deploymentDir, 'relay.env'),
55
+ EZ_AGENT_PURPOSE_FILE: join(deploymentDir, 'purpose.md'),
56
+ EZ_EXECUTOR_CLI: cli,
57
+ EZ_AGENT_WORKSPACE: join(deploymentDir, 'mind'),
58
+ EZ_CONTROL_DIR: join(deploymentDir, 'control'),
59
+ EZ_WHATSAPP_IPC_VOLUME: `${project}-whatsapp-ipc`,
60
+ EZ_WHATSAPP_CLIENT_VOLUME: `${project}-whatsapp-client`,
61
+ }
62
+ await writeFile(join(directory, 'docker.env'), Object.entries(values).map(([k,v]) => `${k}='${v}'\n`).join(''), {mode:0o600, flag:'wx'})
63
+ await writeFile(join(directory, 'relay.env'), `TELEGRAM_BOT_TOKEN=${token}\n`, {mode:0o600, flag:'wx'})
64
+ await writeFile(join(directory, 'purpose.md'), purpose.trim()+'\n', {mode:0o644, flag:'wx'})
65
+ await mkdir(join(directory,'mind'),{mode:0o700})
66
+ await mkdir(join(directory,'control'),{mode:0o700})
67
+ const agent = {name, project, deploymentDir, purpose:purpose.trim(), executor:cli}
68
+ const host = {cli,agents:[{name,workspace:join(deploymentDir,'mind'),controlDir:join(deploymentDir,'control'),binDir:join(dirname(composeFile),'bin')}]}
69
+ await writeFile(join(directory,'host-executor.json'),JSON.stringify(host,null,2)+'\n',{mode:0o600,flag:'wx'})
70
+ await writeFile(join(directory, 'agent.json'), JSON.stringify(agent,null,2)+'\n', {mode:0o600, flag:'wx'})
71
+ return agent
72
+ } catch (error) { await rm(directory, {recursive:true, force:true}); throw error }
73
+ }
74
+
75
+ export const listAgents = async (root: string) => {
76
+ const agents = []
77
+ for (const entry of await readdir(root, {withFileTypes:true})) {
78
+ if (!entry.isDirectory()) continue
79
+ try { agents.push(JSON.parse(await readFile(join(root,entry.name,'agent.json'),'utf8'))) }
80
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
81
+ }
82
+ return agents
83
+ }
84
+
85
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
86
+ try {
87
+ const {values:v} = parseArgs({options:{name:{type:'string'},purpose:{type:'string'},'host-root':{type:'string'},'compose-file':{type:'string'},'relay-image':{type:'string'},list:{type:'boolean'},cli:{type:'string'},'register-cli':{type:'string'}}})
88
+ if (v['register-cli']) console.log(JSON.stringify({cli:await installationCli('/installations',v['register-cli'])}))
89
+ else if (v.list) console.log(JSON.stringify(await listAgents('/installations')))
90
+ else {
91
+ let token=''
92
+ for await (const chunk of process.stdin) { token+=chunk; if(token.length>512) throw new Error('Token input is too long.') }
93
+ console.log(JSON.stringify(await createAgent({root:'/installations',hostRoot:v['host-root']||'',composeFile:v['compose-file']||'',name:v.name||'',purpose:v.purpose||'',token:token.trim(),cli:v.cli||'',image:v['relay-image']})))
94
+ }
95
+ } catch (error) { console.error((error as Error).message); process.exitCode=1 }
96
+ }
package/src/ai-cli.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { parseArgs } from 'node:util'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { readModels, validateSelection, type AiPreset } from './ai.js'
4
+ import { ControlStore } from './control-state.js'
5
+
6
+ const {values,positionals}=parseArgs({allowPositionals:true,options:{cli:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
7
+ if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
8
+ const catalog=await readModels()
9
+ if(positionals[0]==='list')console.log(JSON.stringify(catalog))
10
+ else if(positionals[0]==='select'){
11
+ const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',model:values.model,effort:values.effort}
12
+ await validateSelection(preset,catalog)
13
+ const control=new ControlStore(process.env.EZ_CONTROL_DIR,900000)
14
+ const state=await control.status()
15
+ if(!state.ai)throw new Error('Agent AI settings are not initialized')
16
+ const existing=state.ai.presets.find(p=>p.cli===preset.cli&&p.model===preset.model&&p.effort===preset.effort)
17
+ const selected=existing||preset
18
+ if(!existing)await control.savePreset(selected)
19
+ const current=state.ai.presets.find(p=>p.id===state.ai!.selectedId)!
20
+ await control.selectPreset(selected.id,state.activeSession?.sessionId??null,current.cli!==selected.cli||Boolean(state.activeSession&&!state.activeSession.cli))
21
+ console.log(JSON.stringify({selected,defaultUnchanged:true,applies:'subsequent messages; queued work keeps its captured choice'}))
22
+ }else throw new Error('Use list or select --cli <installed-cli> [--model <model>] [--effort <effort>]')
package/src/ai.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { access, readFile } from 'node:fs/promises'
2
+ import { constants } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join, delimiter } from 'node:path'
5
+ import { executorKey, resolveExecutor } from './executor.js'
6
+ import { desktopCodexPath } from './desktop-bridge.js'
7
+
8
+ export type AiPreset = { id: string; name: string; cli: string; model?: string; effort?: string }
9
+ export type ExecutionChoice = { sessionId: string; preset: AiPreset }
10
+ export type ModelChoice = { cli: string; model?: string; name: string; efforts: string[] }
11
+ const safe = (s: unknown): s is string => typeof s === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(s)
12
+ export const isPreset = (p: unknown): p is AiPreset => {
13
+ if (!p || typeof p !== 'object') return false
14
+ const v = p as AiPreset
15
+ return safe(v.id) && typeof v.name === 'string' && v.name.length > 0 && v.name.length <= 80 &&
16
+ ['grok', 'codex', 'codex-gui', 'claude', 'opencode', 'agy'].includes(v.cli) &&
17
+ (v.model === undefined || safe(v.model)) && (v.effort === undefined || safe(v.effort))
18
+ }
19
+ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
20
+ const c = v as ExecutionChoice | undefined
21
+ return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
22
+ }
23
+ export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
24
+ export const initialPreset = (cli: string): AiPreset => {
25
+ const key = executorKey(cli)
26
+ return {
27
+ id: 'initial', name: `${resolveExecutor(key).name} · current setup`, cli: key,
28
+ ...(key === 'opencode'
29
+ ? { model: process.env.OPENCODE_MODEL || 'opencode/nemotron-3.5-lightning-free' } : {}),
30
+ }
31
+ }
32
+
33
+ export const installed = async (cli: string): Promise<boolean> => {
34
+ if (cli === 'codex-gui') return Boolean(await desktopCodexPath())
35
+ for (const directory of (process.env.PATH || '').split(delimiter)) {
36
+ try { await access(join(directory, cli), constants.X_OK); return true } catch {}
37
+ }
38
+ return false
39
+ }
40
+
41
+ // Read only metadata from native client catalogs. Never import prompts, credentials,
42
+ // provider configuration, or model instructions into relay context.
43
+ export const readModels = async (home = homedir(), available = installed): Promise<ModelChoice[]> => {
44
+ const models: ModelChoice[] = []
45
+ const record = (value: unknown): Record<string, unknown> =>
46
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
47
+ const efforts = (value: unknown, key: string): string[] =>
48
+ (Array.isArray(value) ? value : []).map((e: unknown) => record(e)[key]).filter(safe)
49
+ const json = async (file: string) => {
50
+ try { return record(JSON.parse(await readFile(join(home, file), 'utf8'))) } catch { return {} }
51
+ }
52
+ if (await available('grok')) {
53
+ const cache = await json('.grok/models_cache.json')
54
+ for (const entry of Object.values(record(cache.models))) {
55
+ const info = record(record(entry).info)
56
+ if (info.hidden || !safe(info.id)) continue
57
+ models.push({ cli: 'grok', model: info.id, name: String(info.name || info.id).slice(0, 80),
58
+ efforts: efforts(info.reasoning_efforts, 'value') })
59
+ }
60
+ }
61
+ if (await available('codex')) {
62
+ const cache = await json('.codex/models_cache.json')
63
+ for (const entry of Array.isArray(cache.models) ? cache.models : []) {
64
+ const info = record(entry)
65
+ if (info.visibility !== 'list' || !safe(info.slug)) continue
66
+ models.push({ cli: 'codex', model: info.slug, name: String(info.display_name || info.slug).slice(0, 80),
67
+ efforts: efforts(info.supported_reasoning_levels, 'effort') })
68
+ }
69
+ }
70
+ if (await available('codex-gui')) {
71
+ const desktop = models.filter((model) => model.cli === 'codex').map((model) => ({
72
+ ...model, cli: 'codex-gui', name: `codex-gui · ${model.name}`.slice(0, 80),
73
+ }))
74
+ models.push(...(desktop.length ? desktop : [{ cli: 'codex-gui', name: 'codex-gui · desktop', efforts: [] }]))
75
+ }
76
+ // Other adapters expose the authenticated client's default, not a guessed catalog.
77
+ for (const cli of ['claude', 'opencode', 'agy'])
78
+ if (await available(cli)) models.push({ cli, name: `${cli} · client default`, efforts: [] })
79
+ return models
80
+ }
81
+
82
+ export const validateSelection = async (p: AiPreset, catalog: ModelChoice[], available = installed): Promise<void> => {
83
+ if (!isPreset(p) || !(await available(p.cli))) throw new Error('This CLI is not installed.')
84
+ if (!p.model && !p.effort && p.cli !== 'agy') return
85
+ const model = catalog.find((m) => m.cli === p.cli && m.model === p.model)
86
+ if (!model || (p.effort && !model.efforts.includes(p.effort)))
87
+ throw new Error('This model/effort is not in the installed client catalog. Refresh the client and try again; no fallback was selected.')
88
+ }