@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.28

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 (75) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +23 -0
  4. package/CONTRIBUTING.md +28 -0
  5. package/Dockerfile +1 -0
  6. package/README.md +79 -8
  7. package/bin/ezenciel-agents-application +2 -0
  8. package/bin/ezenciel-agents-application.mjs +16 -0
  9. package/compose.yaml +8 -0
  10. package/docker/entrypoint.sh +20 -2
  11. package/docker/healthcheck.mjs +1 -1
  12. package/docker/run.ts +3 -3
  13. package/docker/smoke.mjs +41 -2
  14. package/docs/application-channel.md +366 -0
  15. package/docs/docker-runtime.md +20 -0
  16. package/docs/managed-applications.md +68 -0
  17. package/docs/plugin-catalog.md +1 -0
  18. package/docs/plugin-connection.md +76 -0
  19. package/docs/plugins.md +40 -4
  20. package/docs/upgrades.md +11 -1
  21. package/package.json +7 -2
  22. package/src/application-channel.ts +308 -0
  23. package/src/application-cli.ts +41 -0
  24. package/src/application-client.mjs +87 -0
  25. package/src/application-origin.ts +15 -0
  26. package/src/codex-session.ts +5 -3
  27. package/src/config.ts +20 -2
  28. package/src/control-state.ts +256 -15
  29. package/src/conversation-menu.ts +89 -0
  30. package/src/delivery-context.d.mts +5 -0
  31. package/src/delivery-context.mjs +25 -0
  32. package/src/event-sources.ts +2 -2
  33. package/src/execution-authority.ts +2 -0
  34. package/src/executor.ts +10 -4
  35. package/src/host-executor.ts +7 -1
  36. package/src/identity.ts +11 -3
  37. package/src/index.ts +149 -54
  38. package/src/menu.ts +26 -9
  39. package/src/message-history.ts +52 -0
  40. package/src/message.ts +48 -7
  41. package/src/owner.ts +7 -1
  42. package/src/plugins/connection-artifacts.mjs +31 -0
  43. package/src/plugins/connection.mjs +124 -0
  44. package/src/plugins/manager.mjs +63 -18
  45. package/src/plugins/native-tasks.d.mts +4 -0
  46. package/src/plugins/native-tasks.mjs +66 -0
  47. package/src/plugins/workspace-lease.d.mts +3 -0
  48. package/src/plugins/workspace-lease.mjs +44 -0
  49. package/src/runs.ts +67 -9
  50. package/src/schedule-cli.ts +11 -6
  51. package/src/scheduler.ts +17 -7
  52. package/src/updates/control.mjs +4 -0
  53. package/src/web-launcher.ts +19 -0
  54. package/templates/agent-guidance.md +58 -2
  55. package/templates/deployments.md +24 -0
  56. package/test/application-channel.test.ts +283 -0
  57. package/test/application-client.test.mjs +84 -0
  58. package/test/application-controls.test.ts +224 -0
  59. package/test/application-only.test.ts +100 -0
  60. package/test/channel-delivery.test.ts +63 -0
  61. package/test/channel-owner.test.ts +161 -0
  62. package/test/codex-session.test.ts +8 -5
  63. package/test/config.test.ts +15 -0
  64. package/test/connection-artifacts.test.mjs +32 -0
  65. package/test/conversation-menu.test.ts +67 -0
  66. package/test/conversations.test.ts +84 -0
  67. package/test/executor.test.ts +56 -0
  68. package/test/host-executor.test.ts +28 -0
  69. package/test/intake-relay.test.ts +126 -5
  70. package/test/message-history.test.ts +127 -0
  71. package/test/native-tasks.test.ts +36 -0
  72. package/test/plugin-connection.test.mjs +124 -0
  73. package/test/plugin-manager.test.mjs +34 -0
  74. package/test/runtime-identity.test.mjs +18 -0
  75. package/test/updates.test.mjs +39 -0
package/.env.example CHANGED
@@ -44,3 +44,12 @@ EZ_REPAIR_ENABLED=true
44
44
  # monitor-only credentials; do not put them in an agent workspace.
45
45
  # EZ_WATCH_ENROLL_TOKEN=
46
46
  # EZ_WATCH_TELEGRAM_CHAT_ID=
47
+
48
+ # Optional native application-only runtime: no Telegram bot or token.
49
+ # Requires EZ_APPLICATION_PORT; administrator bootstrap uses the real operator ID.
50
+ # EZ_TELEGRAM_ENABLED=false
51
+ # Private application-only local container isolation (optional; default workspace-write).
52
+ # EZ_CODEX_SANDBOX=external
53
+
54
+ # Optional Mini App launcher (public URL, no credentials).
55
+ # EZ_TELEGRAM_WEB_APP={"command":"voice","label":"Voice","url":"https://voice.example.com/"}
package/AGENTS.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  Ez supports two independent, composable uses: an autonomous Telegram agent and
4
4
  workspace-scoped plugins called by your existing local CLI/GUI executor.
5
+ The autonomous agent also supports authenticated application channels without
6
+ Telegram; see docs/application-channel.md. One installation owner can link several
7
+ channels. Channel credentials and reply destinations do not create new owners.
5
8
  For CLI-only requests, follow [standalone setup](docs/standalone-cli.md): no Telegram
6
9
  pairing, relay or host executor is required. The main-first Telegram onboarding
7
10
  rules below apply only when installing the autonomous relay. When both are
@@ -53,6 +56,23 @@ This package will be published as an open-source, lightweight Telegram-to-CLI re
53
56
  - `/goal do my daily routine` is literal task-prompt text for the engine. ez does not parse it, construct a native objective or own its workflow.
54
57
  - Keep changes focused. State what was removed, why anything added is necessary, and which observed outcome proves the simpler system works. Preserve authority and uncertain-delivery safeguards. Unchanged, non-actionable maintenance stops quietly; queued updates and passing tests alone do not prove an installed fix.
55
58
 
59
+ ## Fast KISS iterations
60
+
61
+ Deliver the smallest useful product increment and verify its main user path.
62
+ Once that works within the architecture and authority boundaries, complete the
63
+ authorized delivery instead of spending disproportionate effort on rare,
64
+ low-impact edge cases. Prefer fast feedback and a focused follow-up fix over
65
+ speculative abstractions, fallback layers or exhaustive test matrices.
66
+
67
+ Scale validation to likelihood, impact and reversibility: test the changed
68
+ behavior and relevant failure boundaries, run required checks, then stop when
69
+ they pass. Broaden testing only for a concrete unresolved risk or new failure.
70
+ Architecture violations, authorization/secret exposure, data loss and uncertain
71
+ external writes remain blockers even when rare; minor recoverable limitations
72
+ can be stated briefly and deferred. Reviewers distinguish those blockers from
73
+ optional follow-ups and do not hold a working increment for hypothetical polish.
74
+ Measure progress by usable outcomes and feedback, not code or test volume.
75
+
56
76
  ## 3. Crash-Safe Atomic Disk State
57
77
  - All persistent stores (`ControlStore`, `RunStore`, outbox queue) must be disk-backed JSON files.
58
78
  - **Atomic write pattern:** Never write directly to a state file. Always write to a temporary file (`${target}.${process.pid}.tmp`) with mode `0o600`, then atomically `rename` it over the destination.
@@ -100,7 +120,11 @@ Own the complete engineering/release handoff in CONTRIBUTING.md. Once ready,
100
120
  proactively request only missing merge/release authority, then ship and verify;
101
121
  do not leave the maintainer to discover ready drafts or operate the release.
102
122
 
103
- - In channel-backend mode, the application owns native sessions and actions. Forward normalized inputs with stable run IDs, recover only by idempotent backend submission, and deliver replies through the existing outbox. Never launch a fallback CLI or pass relay credentials into an executor.
123
+ For every core or plugin PR you author, revise or review, apply CONTRIBUTING.md's
124
+ architecture check and the README's engine/application boundary. A symptom fix
125
+ and green tests do not justify a conflicting execution or context owner. Existing
126
+ legacy channel-backend code is a documented limitation, not a pattern for new
127
+ integrations; applications submit agent turns through the native Ez execution path.
104
128
 
105
129
  A coding-task handoff must include the pushed commit and draft PR URL, checks,
106
130
  independent-review status and remaining QA. If PR creation is blocked, state the
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.0-beta.28
6
+
7
+ - Hide empty conversation placeholders from history and update the same menu
8
+ when navigating Back, rather than adding another menu message.
9
+ - Keep the conversation list to names only; show Archive/Restore for the selected
10
+ conversation. Label older chats from saved owner messages instead of opaque IDs.
11
+ - Add named Telegram conversations with switching, renaming, archiving and
12
+ restoring. Preserve native engine bindings and queued work across switches.
13
+ - Add the authenticated application channel to the standard runtime, including
14
+ botless deployments, explicit owner sharing, native conversation continuity,
15
+ isolated non-root containers and writable-folder grants.
16
+ - Add owner-bound persistent plugin connections with installed-tool discovery,
17
+ native task access and revocation-safe Telegram delivery receipts.
18
+ - Add explicit loopback-only `tools serve`, live read-only owner discovery and an
19
+ optional authenticated private-chat Mini App launcher for plugin-owned web UIs.
20
+ - Fix packaged application command availability and private-plugin update checks.
21
+ - Document agent-owned maintenance for separately deployed applications and add
22
+ owner-bound, on-demand Telegram delivery-history lookup across native sessions.
23
+ - Clarify the native-engine, transport and plugin ownership boundaries and the
24
+ proportionate KISS contribution/review standard.
25
+
3
26
  ## 0.1.0-beta.27
4
27
 
5
28
  - Pass literal task input and `/goal` requests to the selected engine; remove
package/CONTRIBUTING.md CHANGED
@@ -36,6 +36,34 @@ should explain the user problem and a small acceptance example. Security reports
36
36
  follow SECURITY.md. Release and new-plugin requirements: docs/releasing.md and
37
37
  docs/plugin-contributions.md.
38
38
 
39
+ ## Proportional scope and validation
40
+
41
+ Follow AGENTS.md's fast KISS iteration principle. Define the smallest useful
42
+ increment, verify the main user path and relevant failure boundaries, and run
43
+ required checks. Once these pass and the architecture is preserved, finish the
44
+ authorized delivery. Do not expand scope or repeat tests to cover hypothetical,
45
+ low-impact edge cases. No new tests are needed merely to mirror wording or an
46
+ obvious reversible edit. Broaden verification only when evidence warrants it.
47
+ Reviewers must distinguish material blockers from optional follow-ups; briefly
48
+ record real deferred limitations without making them conditions of this PR.
49
+
50
+ ## Architecture check for every PR
51
+
52
+ Authors and reviewers apply the [engine and application boundaries](README.md#engine-and-application-boundaries)
53
+ to every proposed or revised core/plugin change. State the failing boundary,
54
+ what can be removed or simplified, which component owns each remaining
55
+ responsibility, and the focused evidence for the fix. Evaluate instructions,
56
+ retries and lifecycle owners as implementation complexity too.
57
+
58
+ The independent reviewer must confirm that the final diff preserves this boundary;
59
+ passing CI or fixing the immediate symptom is insufficient. Reject replacement
60
+ conversation/prompt assembly, competing agent-turn runners or queues, and domain
61
+ workflows in transport. Preserve necessary authorization, persistence and delivery
62
+ safeguards, standard controls, and the intentional engine-decided chat guidance.
63
+ Resolve violations before approval or merge. Report existing out-of-scope problems
64
+ with sanitized evidence and a next action in an existing issue when available;
65
+ do not disguise them as supported architecture or start a duplicate repair.
66
+
39
67
  ## Isolated work and review
40
68
 
41
69
  - One coherent change, one branch, one dedicated Git worktree, one PR per
package/Dockerfile CHANGED
@@ -10,6 +10,7 @@ FROM dependencies AS test
10
10
  RUN pnpm verify
11
11
  FROM dependencies AS runtime
12
12
  RUN chmod +x docker/entrypoint.sh bin/ezenciel-agents* && mkdir -p /state/control /state/home /workspace && chown node:node /state/control /state/home /workspace
13
+ RUN node -e 'for (const [name, target] of Object.entries(require("./package.json").bin)) require("node:fs").symlinkSync("/app/" + target, "/usr/local/bin/" + name)'
13
14
  ENV HOME=/state/home EZ_AGENT_WORKSPACE=/workspace EZ_CONTROL_DIR=/state/control EZ_EXECUTOR_CLI=grok PATH=/app/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
14
15
  WORKDIR /workspace
15
16
  ENTRYPOINT ["/app/docker/entrypoint.sh"]
package/README.md CHANGED
@@ -10,27 +10,70 @@ following up on an agreed task. Each assistant has its own working context and
10
10
  responsibilities. The tools and permissions you configure determine what it can
11
11
  do.
12
12
 
13
- An AI harness is the client that lets a model reason, use tools and carry out
14
- work. Ez uses your chosen host CLI and its existing login. The harness does the
15
- reasoning; Ez connects it to a persistent workspace, messaging, plugins and
16
- explicit authority boundaries. Telegram is the current owner interface.
13
+ ## Engine and application boundaries
14
+
15
+ Ez is a thin wrapper and transport layer around the native agent CLI engine.
16
+ The engine owns inference, native sessions, context management, tool execution,
17
+ goals, delegation and continuation. Ez supplies channel delivery, scheduling,
18
+ standard runtime controls, authorization, secret isolation, session binding,
19
+ durable admission, cancellation and delivery receipts.
20
+
21
+ User text passes through unchanged, with only minimal additional metadata or
22
+ instructions needed for channel-specific elements such as attachments, quoted
23
+ messages and reply delivery. Ez may adapt media through transcription or speech
24
+ synthesis. Its deliberate, narrow workflow opinion is chat responsiveness:
25
+ instructions encourage the engine to move lengthy work into an Ez-scheduled task
26
+ or use native delegation when appropriate. The engine decides whether and how
27
+ to do so; Ez does not enforce a business workflow or manage native goals.
28
+
29
+ Ez must not reconstruct conversation history for automatic prompt replay,
30
+ assemble a conventional LLM/API conversation prompt or wrap each message in a
31
+ replacement system prompt. The native engine maintains its session and context.
32
+ Native workspace instructions, plugin discovery and explicit agent-requested
33
+ access to delivery receipts or task context complement that context without
34
+ replacing it.
35
+
36
+ Applications integrate through public or purpose-built private plugins exposing
37
+ documented CLI commands to the agent. Agent-initiated application actions run
38
+ through those commands, which enforce authentication, authorization, validation
39
+ and canonical persistence directly or through authoritative application services.
40
+ Authenticated application interfaces and separate human approval flows may
41
+ access those services directly.
42
+
43
+ Application interfaces submit agent turns through the same Ez execution path,
44
+ without separate agent runners, application-owned model-routing layers,
45
+ conversation engines or competing execution queues for those turns.
46
+ Deterministic plugin services, provider connections, domain jobs, UI transcripts
47
+ and delivery queues are permitted when they do not take ownership of agent
48
+ execution.
49
+
50
+ Preserve standard Ez commands and behavior across channels, including scheduling
51
+ and runtime controls; document any current capability gaps explicitly.
52
+ Application-specific behavior belongs in plugin instructions and CLI commands,
53
+ not transport branches or replacement runtime controls.
54
+
55
+ These are architecture requirements. The current application-only runtime accepts
56
+ foreground application turns; background scheduling with application delivery is
57
+ not implemented. The legacy channel-backend mode delegates execution and changes
58
+ standard controls, so it does not meet this integration boundary. New integrations
59
+ use the [native application channel](docs/application-channel.md).
17
60
 
18
61
  ## What makes an Ez assistant
19
62
 
20
63
  | Part | What it contributes |
21
64
  |---|---|
22
65
  | **Workspace** | Markdown instructions, business context and working files that persist across conversations and changes of AI. |
23
- | **Harness** | The existing AI client that plans, chooses tools and executes work. See [AI selection](docs/architecture/ai-selection.md). |
66
+ | **Native engine** | Inference, native sessions, context management and tool execution. See [AI selection](docs/architecture/ai-selection.md). |
24
67
  | **Authority** | A verified owner and explicit limits on delegated work. Receiving a message or installing a tool does not grant permission to act. |
25
- | **Plugins** | Separately maintained tools and connections, with instructions the agent can discover and use. Provider authentication and receipts belong to the plugin. |
26
- | **Messaging and continuity** | A way to reach the assistant, return results and continue work in its own environment. |
68
+ | **Plugins** | Documented CLI commands and agent instructions for application capabilities. Commands enforce authentication, authorization, validation and canonical persistence directly or through authoritative application services. |
69
+ | **Ez transport and runtime** | Channel delivery, scheduling and standard runtime controls around the native engine. |
27
70
 
28
71
  The design starts with one assistant, one mind and clear responsibilities.
29
72
  Business instructions live in the workspace; reusable capabilities live in tools
30
73
  and plugins. The agent decides how to do the work using those instructions and
31
74
  the capabilities available to it.
32
75
 
33
- Use the harness's native capabilities when they meet the need. Add a standalone
76
+ Use the engine's native capabilities when they meet the need. Add a standalone
34
77
  plugin when a missing integration earns the maintenance. Ez keeps the surrounding
35
78
  runtime small, so your files, tools and business knowledge remain useful as AI
36
79
  clients improve.
@@ -211,6 +254,34 @@ optional. Installed plugin snippets and skill paths come from `ez tools list --d
211
254
  Credentials and control state stay outside the mind. File separation is not
212
255
  OS isolation against a process running as the same user.
213
256
 
257
+ In Telegram, **Conversations** (or `/chats`) lists this agent's saved Ez
258
+ conversations by name. Tap a name to continue its native engine session and
259
+ restore its AI choice. Names start with the first message; `/rename Client launch`
260
+ sets a custom name for the current conversation. **New conversation** keeps the
261
+ old one available. **Archive this conversation**, shown after selecting a name, hides a conversation without deleting its engine
262
+ history; **Archived conversations → select a name → Restore conversation** brings it back. Archiving the current conversation
263
+ leaves the next message to start a fresh one. Running and queued work stays bound
264
+ to its original conversation; archive does not cancel work.
265
+
266
+ Agents can inspect what was sent to the bound Telegram chat across conversations
267
+ with `ezenciel-agents-message history` (latest 8 deliveries), `--limit N` (1–50),
268
+ or `--message-id ID` for a specific message, including a chunk of a longer report.
269
+ This read-only command requires an active owner Telegram run and returns confirmed
270
+ local receipts with message IDs, delivery times and source run/session references.
271
+ It does not change the selected conversation or inject history into prompts.
272
+ Only retained receipts from the current owner binding are available; Telegram
273
+ edits/deletions and attachment contents are not fetched.
274
+
275
+ Existing conversations without a title show their first saved message and date when
276
+ available. Empty routing placeholders are omitted from history; + New conversation
277
+ is the create action. Back updates the current menu without creating a conversation. Use /rename for a custom name. Older
278
+ records without a saved model reuse a known choice for their original CLI.
279
+ Antigravity's latest-only resume cannot switch back to an older conversation.
280
+ The list covers sessions already tracked by this Ez agent; it does not import
281
+ unrelated GUI/CLI history. Application-backed channels keep session controls in
282
+ the connected application. Shared workspace files still persist across topics.
283
+
284
+
214
285
  Telegram intake is owner-gated. The executor sends replies with the messaging
215
286
  CLI; stdout is not a reply. A process exit code is not delivery proof.
216
287
  Voice/media needs its additional providers and tools; defer optional setup
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec node "$(dirname "$0")/ezenciel-agents-application.mjs" "$@"
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url))
8
+ const require = createRequire(import.meta.url)
9
+ const tsx = require.resolve('tsx')
10
+ const entry = join(here, '..', 'src', 'application-cli.ts')
11
+ const forwarded = process.argv.slice(2).filter((arg) => arg !== '--')
12
+ const child = spawn(process.execPath, ['--import', tsx, entry, ...forwarded], { stdio: 'inherit' })
13
+ child.on('exit', (code, signal) => {
14
+ if (signal) process.kill(process.pid, signal)
15
+ process.exit(code ?? 1)
16
+ })
package/compose.yaml CHANGED
@@ -12,13 +12,21 @@ services:
12
12
  # still removes every capability from the relay and its executor children.
13
13
  cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID, SETPCAP, KILL]
14
14
  environment:
15
+ EZ_RUNTIME_UID: ${EZ_RUNTIME_UID:-1000}
16
+ EZ_RUNTIME_GID: ${EZ_RUNTIME_GID:-1000}
17
+ EZ_RELAY_UID: ${EZ_RELAY_UID:-1001}
15
18
  EZ_REPAIR_ENABLED: ${EZ_REPAIR_ENABLED:-true}
16
19
  EZ_AGENT_PURPOSE_FILE: /run/agent-purpose.md
17
20
  EZ_EXECUTOR_TRANSPORT: ${EZ_EXECUTOR_TRANSPORT:-host}
18
21
  EZ_CHANNEL_BACKEND_URL: ${EZ_CHANNEL_BACKEND_URL:-}
22
+ EZ_TELEGRAM_WEB_APP: ${EZ_TELEGRAM_WEB_APP:-}
23
+ EZ_TELEGRAM_ENABLED: ${EZ_TELEGRAM_ENABLED:-true}
24
+ EZ_APPLICATION_PORT: ${EZ_APPLICATION_PORT:-}
25
+ EZ_APPLICATION_HOST: ${EZ_APPLICATION_HOST:-127.0.0.1}
19
26
  EZ_AGENT_WORKSPACE: ${EZ_AGENT_WORKSPACE:?Set this agent workspace}
20
27
  EZ_CONTROL_DIR: ${EZ_CONTROL_DIR:?Set this agent control directory}
21
28
  EZ_EXECUTOR_CLI: ${EZ_EXECUTOR_CLI:?Set the host installation CLI}
29
+ EZ_CODEX_SANDBOX: ${EZ_CODEX_SANDBOX:-}
22
30
  EZ_CODEX_AUTO_COMPACT_TOKENS: ${EZ_CODEX_AUTO_COMPACT_TOKENS:-64000}
23
31
  EZ_EXECUTOR_TIMEOUT_SECONDS: ${EZ_EXECUTOR_TIMEOUT_SECONDS:-300}
24
32
  EZ_PAGERDUTY_STOCKS_HEALTH_URL: ${EZ_PAGERDUTY_STOCKS_HEALTH_URL:-}
@@ -1,13 +1,31 @@
1
1
  #!/bin/sh -p
2
2
  set -eu
3
3
  umask 077
4
+ runtime_uid=${EZ_RUNTIME_UID:-1000}
5
+ runtime_gid=${EZ_RUNTIME_GID:-1000}
6
+ relay_uid=${EZ_RELAY_UID:-1001}
7
+ for runtime_id in "$runtime_uid" "$runtime_gid" "$relay_uid"; do
8
+ case "$runtime_id" in
9
+ ''|*[!0-9]*|0*) echo 'Runtime UID/GID values must be positive decimal IDs' >&2; exit 64 ;;
10
+ esac
11
+ if [ "${#runtime_id}" -gt 10 ] || [ "$runtime_id" -gt 2147483647 ]; then
12
+ echo 'Runtime UID/GID values must not exceed 2147483647' >&2; exit 64
13
+ fi
14
+ done
15
+ if [ "$runtime_uid" = "$relay_uid" ]; then
16
+ echo 'EZ_RELAY_UID must differ from EZ_RUNTIME_UID to isolate relay secrets' >&2
17
+ exit 64
18
+ fi
4
19
  if [ "$(id -u)" = 0 ]; then
5
20
  mkdir -p /state/control /state/home /workspace /run/secrets
6
21
  chmod 700 /run/secrets
7
- chown 1000:1000 /state/control /state/home /workspace
22
+ chown "$runtime_uid:$runtime_gid" /state/control /state/home /workspace
8
23
  if [ -f /run/secrets/relay_env ]; then exec 3</run/secrets/relay_env; else exec 3</dev/null; fi
9
- exec setpriv --ruid=1001 --euid=1000 --regid=1000 --clear-groups --bounding-set=-all --no-new-privs /app/docker/entrypoint.sh "$@"
24
+ exec setpriv --ruid="$relay_uid" --euid="$runtime_uid" --regid="$runtime_gid" --clear-groups --bounding-set=-all --no-new-privs /app/docker/entrypoint.sh "$@"
10
25
  fi
26
+ # Reserve the private environment descriptor before Node can use it for libuv.
27
+ # Root startup already opened it; preserve that inherited secret descriptor.
28
+ ( : <&3 ) 2>/dev/null || exec 3</dev/null
11
29
  case "${1:-start}" in
12
30
  start|smoke|exec|setup)
13
31
  exec flock --no-fork -n -E 73 /state/control/relay.lock node --import /app/node_modules/tsx/dist/loader.mjs /app/docker/run.ts "$@" ;;
@@ -4,7 +4,7 @@ const fail = code => { process.stderr.write(`EZ_HEALTH_${code}\n`); process.exit
4
4
  let value;
5
5
  try { value = JSON.parse(readFileSync(relayControl + '/heartbeat.json', 'utf8')); }
6
6
  catch { fail('RELAY_UNREADABLE'); }
7
- if (!value.polling) fail('RELAY_NOT_POLLING');
7
+ if (!value.polling && !(process.env.EZ_TELEGRAM_ENABLED === 'false' && value.applicationOnly === true)) fail('RELAY_NOT_POLLING');
8
8
  if (!Number.isFinite(value.at) || Date.now() - value.at > 20000) fail('RELAY_STALE');
9
9
 
10
10
  if (process.env.EZ_EXECUTOR_TRANSPORT === 'host') {
package/docker/run.ts CHANGED
@@ -32,7 +32,7 @@ if (command === 'start') {
32
32
  const heartbeat = '/state/control/heartbeat.json'
33
33
  await rm(heartbeat, { force: true })
34
34
  const timer = setInterval(() => {
35
- if (relay.bot.isRunning()) void writeFile(heartbeat, JSON.stringify({ at: Date.now(), polling: true, version: packageVersion }), { mode: 0o600 })
35
+ if (relay.isRunning()) void writeFile(heartbeat, JSON.stringify({ at: Date.now(), polling: Boolean(relay.bot?.isRunning()), applicationOnly: process.env.EZ_TELEGRAM_ENABLED === 'false', version: packageVersion }), { mode: 0o600 })
36
36
  }, 5000)
37
37
  for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { clearInterval(timer); void relay.stop() })
38
38
  try { await relay.start() } finally { clearInterval(timer); await rm(heartbeat, { force: true }) }
@@ -47,6 +47,6 @@ if (command === 'start') {
47
47
  child.on('exit', (code) => { process.exitCode = code ?? 1 })
48
48
  } else if (command === 'setup') {
49
49
  await (await import('../src/setup.js')).runCli()
50
- } else if (['owner', 'source', 'message', 'react'].includes(command)) {
51
- await import(`../src/${command === 'source' ? 'source-cli' : command}.js`)
50
+ } else if (['owner', 'source', 'application', 'message', 'react'].includes(command)) {
51
+ await import(`../src/${command === 'source' ? 'source-cli' : command === 'application' ? 'application-cli' : command}.js`)
52
52
  } else throw new Error('Use start, smoke, exec, setup, owner, source, message or react')
package/docker/smoke.mjs CHANGED
@@ -7,10 +7,37 @@ const image = process.env.EZ_RELAY_IMAGE || 'ezenciel-agents:local';
7
7
  const dir = mkdtempSync(join(tmpdir(), 'ez-docker-qa-'));
8
8
  const holder = `ez-main-lock-qa-${process.pid}`;
9
9
  const volume = `${holder}-control`;
10
+ const application = `${holder}-application`;
10
11
  const marker = 'qa-private-secret-never-in-executor';
11
12
  writeFileSync(join(dir, 'relay.env'), `TELEGRAM_BOT_TOKEN=${marker}\n`, { mode: 0o600 });
13
+ writeFileSync(join(dir, 'node'), `#!/bin/sh\nIFS= read -r value <&3\n[ "$value" = "TELEGRAM_BOT_TOKEN=${marker}" ] || exit 91\nexec /usr/local/bin/node "$@"\n`, { mode: 0o555 });
12
14
  const run = (args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 60000 });
13
15
  try {
16
+ const inherited = run(['run','--rm','--mount',`type=bind,src=${join(dir,'relay.env')},dst=/run/secrets/relay_env,readonly`,
17
+ '--mount',`type=bind,src=${join(dir,'node')},dst=/qa/node,readonly`,'-e','PATH=/qa:/usr/local/bin:/usr/bin:/bin',image,'application','--help']);
18
+ assert.equal(inherited.status,0,inherited.stderr);
19
+ const nonroot = ['--user','20000:20000','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges',
20
+ '--tmpfs','/tmp:mode=1777','--tmpfs','/state/control:uid=20000,gid=20000,mode=700',
21
+ '--tmpfs','/state/home:uid=20000,gid=20000,mode=700','--tmpfs','/workspace:uid=20000,gid=20000,mode=700',
22
+ '-e','EZ_TELEGRAM_ENABLED=false','-e','EZ_APPLICATION_PORT=8110','-e','EZ_EXECUTOR_TRANSPORT=local','-e','EZ_EXECUTOR_CLI=codex'];
23
+ const help = run(['run','--rm',...nonroot,image,'application','--help']);
24
+ assert.equal(help.status,0,help.stderr);
25
+ assert.match(help.stdout,/ezenciel-agents-application/);
26
+ const login = run(['run','--rm',...nonroot,'--entrypoint','/bin/bash',image,'-lc',
27
+ 'command -v ezenciel-agents-application && command -v ezenciel-agents-message && ezenciel-agents-message --help']);
28
+ assert.equal(login.status,0,login.stderr);
29
+ assert.match(login.stdout,/\/usr\/local\/bin\/ezenciel-agents-application/);
30
+ assert.match(login.stdout,/\/usr\/local\/bin\/ezenciel-agents-message/);
31
+ const started = run(['run','-d','--name',application,...nonroot,image,'start']);
32
+ assert.equal(started.status,0,started.stderr);
33
+ let ready = false;
34
+ for (let n=0;n<60;n++) {
35
+ if (run(['exec',application,'node','/app/docker/healthcheck.mjs']).status===0) { ready=true; break; }
36
+ await new Promise(resolve=>setTimeout(resolve,200));
37
+ }
38
+ assert.ok(ready,run(['logs',application]).stderr);
39
+ assert.equal(run(['stop','--time','10',application]).status,0);
40
+ assert.equal(run(['inspect','--format','{{.State.ExitCode}}',application]).stdout.trim(),'0');
14
41
  const probe = `
15
42
  const fs = require('fs'), assert = require('assert/strict');
16
43
  assert.equal(process.getuid(), 1000);
@@ -28,6 +55,18 @@ try {
28
55
  const result = run(['run', '--rm', '--mount', `type=bind,src=${join(dir,'relay.env')},dst=/run/secrets/relay_env,readonly`, image, 'exec', 'node', '-e', probe, literal]);
29
56
  assert.equal(result.status, 0, result.stderr);
30
57
  assert.deepEqual(JSON.parse(result.stdout).argv, [literal]);
58
+ const customProbe = probe.replace('process.getuid(), 1000', 'process.getuid(), 20001') + `
59
+ assert.equal(process.geteuid(),20001);
60
+ assert.equal(process.getgid(),20002);
61
+ assert.equal(process.getegid(),20002);
62
+ for (const directory of ['/state/control','/state/home','/workspace']) {
63
+ const info=fs.statSync(directory); assert.equal(info.uid,20001); assert.equal(info.gid,20002);
64
+ }
65
+ `;
66
+ const custom = run(['run','--rm','-e','EZ_RUNTIME_UID=20001','-e','EZ_RUNTIME_GID=20002','-e','EZ_RELAY_UID=20003',
67
+ '--mount',`type=bind,src=${join(dir,'relay.env')},dst=/run/secrets/relay_env,readonly`,image,'exec','node','-e',customProbe]);
68
+ assert.equal(custom.status,0,custom.stderr);
69
+ assert.equal(JSON.parse(custom.stdout).uid,20001);
31
70
  const failed = run(['run','--rm',image,'exec','node','-e','process.exit(23)']);
32
71
  assert.equal(failed.status, 23, failed.stderr);
33
72
  const held = run(['run','-d','--name',holder,'-v',`${volume}:/state/control`,image,'exec','node','-e',"require('fs').writeFileSync('/state/control/ready','yes');setInterval(()=>{},1000)"]);
@@ -43,5 +82,5 @@ try {
43
82
  assert.equal(run(['kill',holder]).status,0);
44
83
  const recovered=run(['run','--rm','-v',`${volume}:/state/control`,image,'exec','node','-e','process.exit(0)']);
45
84
  assert.equal(recovered.status,0,recovered.stderr);
46
- console.log('Docker smoke passed: non-root executor, private secret mount/environment, literal argv, exit code, no Docker socket, duplicate writer rejection and crash lock release.');
47
- } finally { run(['rm','-f',holder]); run(['volume','rm',volume]); rmSync(dir, {recursive:true, force:true}); }
85
+ console.log('Docker smoke passed: direct non-root application help/start/health/stop, inherited private descriptor, non-root executor, private secret isolation, literal argv, exit code, no Docker socket, duplicate writer rejection and crash lock release.');
86
+ } finally { run(['rm','-f',holder,application]); run(['volume','rm',volume]); rmSync(dir, {recursive:true, force:true}); }