@evident-ai/runner-synchroniser 0.1.0

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 (3) hide show
  1. package/README.md +185 -0
  2. package/dist/cli.js +49891 -0
  3. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # runner-synchroniser
2
+
3
+ Keeps an agent runner's model credentials alive across task replacement.
4
+
5
+ The Evident runner is a container that can be stopped and replaced at any time — it
6
+ scales to zero when idle, and its host task is disposable. Anything that lives only on
7
+ its disk is lost with it, including the two things that are expensive to recreate: the
8
+ credentials a human bootstrapped interactively (a Claude subscription login, an OpenCode
9
+ provider auth) and the agent's session history.
10
+
11
+ This package is the credential half of that durability story: it restores both
12
+ credential stores from an object store at boot, uploads them again whenever they change,
13
+ and answers the "does this runner have any way to authenticate a model?" question that
14
+ the boot script gates on. It also renders the `litestream.yml` that covers the session
15
+ database, so both halves read their bucket, prefix and paths from one config module
16
+ instead of two.
17
+
18
+ It is a CLI, not a library: the runner's `entrypoint.sh` invokes it as a single
19
+ self-contained bundle (`node cli.js <command>`).
20
+
21
+ ## Install
22
+
23
+ The package is published to npm as `@evident-ai/runner-synchroniser`, exposing one
24
+ `runner-synchroniser` binary and requiring Node >= 22:
25
+
26
+ ```bash
27
+ npx @evident-ai/runner-synchroniser@dev env
28
+ ```
29
+
30
+ `dist/cli.js` is a single self-contained ESM bundle with **no runtime dependencies** —
31
+ the AWS SDK is bundled at build time, so installing it pulls nothing else in.
32
+
33
+ > Publishing is being rolled out (#612). The `dev` tag is published on every merge to
34
+ > `main` that touches this package; the first publish is a human bootstrap (#694), and
35
+ > the `latest`-on-release path is not wired up yet. Nothing in this repo installs the
36
+ > package: the runner image and the MicroVM still build the bundle from source, and will
37
+ > keep doing so until that flip lands.
38
+
39
+ ## Commands
40
+
41
+ | Command | stdout | Exit codes |
42
+ | ------------------------------ | --------------------------------------- | -------------------------------------------------------- |
43
+ | `env` | resolved config as shell-eval'able vars | `0` = ran; non-zero = tool broken |
44
+ | `litestream-config` | the generated `litestream.yml` | `0` = ran; non-zero = tool broken |
45
+ | `restore <claude\|opencode>` | — | `0` = ran; non-zero = tool broken |
46
+ | `sync-once <claude\|opencode>` | — | `0` = ran; non-zero = tool broken |
47
+ | `model-auth-ready` | — | `0` = ready, `10` = not ready; other = tool broken |
48
+ | `self-stop` | — | `0` = stopped, `20` = keep the task; other = tool broken |
49
+
50
+ `self-stop` scales this agent's own ECS service to `desiredCount=0` on a clean idle
51
+ exit, reading `CLUSTER`, `SERVICE` and (optionally) `EVIDENT_SELFSTOP_ROLE_ARN` from the
52
+ environment. It exits `0` **only** on a confirmed `desiredCount` of 0.
53
+
54
+ Everything the operator needs to read goes to **stderr**, prefixed `[auth-persistence]`,
55
+ so a caller can safely capture stdout. The prefix now covers more than credentials — the
56
+ ECS scale-to-zero messages carry it too. It is deliberately **not** renamed to something
57
+ broader: `src/logger.ts` marks it verbatim because operator greps and CloudWatch filters
58
+ match on that exact string, so widening what it labels is cheaper than breaking every
59
+ saved query.
60
+
61
+ ### Why the contract is asymmetric
62
+
63
+ `restore` and `sync-once` **never** report a domain outcome through the exit code. A
64
+ missing remote object, a corrupt local file, a failed upload, an IAM denial — all of
65
+ those are logged and still exit `0`. None of them should abort a boot: a runner with no
66
+ credentials yet is a runner a human can still log into.
67
+
68
+ The consequence is the point: **any non-zero status from `restore`/`sync-once` means the
69
+ tool itself broke** — bad arguments (`2`), an uncaught throw (`1`), or a bundle that
70
+ would not run at all. The shell needs no case analysis to know something is wrong, so
71
+ its "credential persistence is DEGRADED" error lives in exactly one helper.
72
+
73
+ The **two predicates** — `model-auth-ready` and `self-stop` — each need to distinguish
74
+ "the answer is no" from "the tool is broken", so each answers no with its own dedicated
75
+ code rather than a bare `1`: `10` for "no model auth", `20` for "not safe to stop, keep
76
+ the task". That lets the caller treat _any other_ non-zero as the fail-safe answer.
77
+
78
+ The fail-safe direction is the same for both, and deliberate. `model-auth-ready` holds a
79
+ container alive for an operator, so a broken bundle must never convince a healthy runner
80
+ that it has no auth. `self-stop` decides whether a task may go away, so a broken bundle
81
+ must never kill one: `entrypoint.sh` reads any non-zero exactly like `20` and keeps the
82
+ task, and only a `0` — returned solely on a confirmed `desiredCount` of 0 — lets it stop.
83
+
84
+ `src/shell-contract.json` is the machine-checked source of truth for the command list, and
85
+ `shell-contract.test.ts` holds **every** shell that speaks it to it — Fargate's
86
+ `packages/runner-image/entrypoint.sh` and the MicroVM's
87
+ `infrastructure/evident-microvm/docker/hooks` (#608), discovered by grep so a third one
88
+ cannot go unchecked. Each shell must: call only subcommands the CLI implements (and, for
89
+ `entrypoint.sh`, call all of them); route every call through one `run_synchroniser`; report
90
+ a broken tool; and take each answer code the commands it calls can return **silently and by
91
+ value**. That last obligation is derived from the subcommands the shell actually calls, so
92
+ a shell that never self-stops is not made to know about `20`, and it is checked by
93
+ **running** the real helper against a stubbed CLI rather than by pattern-matching it — the
94
+ two shells spell the same intent differently, and a text check strict enough to catch a
95
+ real violation also rejects correct rewrites.
96
+
97
+ ## Configuration
98
+
99
+ All configuration is environment variables, resolved once in `src/config.ts`.
100
+
101
+ | Variable | Meaning |
102
+ | -------------------------------------- | -------------------------------------------------------------------------------------------- |
103
+ | `HOME` | **Required.** Every credential and database path is derived from it; unset is a fatal error. |
104
+ | `LITESTREAM_BUCKET` | Object-store bucket. |
105
+ | `LITESTREAM_PREFIX` | Key prefix within the bucket. |
106
+ | `AWS_REGION` | Region for the S3 client. Unset leaves it to the SDK's own resolution. |
107
+ | `CREDS_SYNC_INTERVAL` | Seconds between sync ticks, reported by `env` for the caller's loop. Defaults to `60`. |
108
+ | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | Presence alone counts as configured model auth (see `model-auth-ready`). |
109
+
110
+ **Persistence is enabled only when both `LITESTREAM_BUCKET` and `LITESTREAM_PREFIX` are
111
+ set.** With either missing, `restore` and `sync-once` become no-ops and `env` warns
112
+ loudly that credentials will be lost when the task is replaced — half a location can
113
+ never produce a malformed key.
114
+
115
+ The paths and keys that follow from that:
116
+
117
+ | Store | Local path | Object key |
118
+ | ----------- | ----------------------------------------- | ----------------------------------- |
119
+ | Claude | `$HOME/.claude/.credentials.json` | `<prefix>/claude/credentials.json` |
120
+ | OpenCode | `$HOME/.local/share/opencode/auth.json` | `<prefix>/opencode/auth.json` |
121
+ | OpenCode DB | `$HOME/.local/share/opencode/opencode.db` | `<prefix>/opencode.db` (litestream) |
122
+
123
+ The session database is replicated by litestream itself; this package only generates its
124
+ config. `env` emits `CREDS_BUCKET`, `CLAUDE_CREDS`, `OPENCODE_DB_PATH` and
125
+ `CREDS_SYNC_INTERVAL`, each single-quoted so a value containing shell metacharacters is
126
+ inert when `eval`'d.
127
+
128
+ ## Behaviour worth knowing
129
+
130
+ - **A non-empty local file that is not valid JSON is protected**, never overwritten and
131
+ never uploaded: it may be unreadable to us and still be the operator's only copy. An
132
+ _empty_ one holds nothing to protect, so it does not block a restore.
133
+ - **Valid JSON, not mere existence, is the readiness test** — for both stores, and it is
134
+ re-read on every call, because OpenCode provider auth can be bootstrapped long after
135
+ boot.
136
+ - **Restores are atomic**: the object is written to an exclusively-created temporary
137
+ file, `chmod`ed before the secret lands in it, validated, then renamed into place. The
138
+ temporary file is removed on every failure path. Directories end up `700`, credential
139
+ files `600`.
140
+ - **Nothing throws out of `restore`/`sync`.** `sync-once` runs on an unsupervised timer,
141
+ where an unguarded failure would silently stop persisting credentials for the rest of
142
+ the task's life. Failures degrade to a warning and are retried on the next tick.
143
+ - **`sync-once` uploads only on change.** Each store's last uploaded hash (a sha256, not
144
+ the credentials) is kept in a state file next to it, so every invocation is a
145
+ self-contained process.
146
+ - **Not-found is distinguished from failed.** A missing object is reported as "nothing to
147
+ restore"; an access or network error is reported as a failure, because that tells the
148
+ operator their persisted credentials may still be recoverable.
149
+
150
+ ## The `ObjectStore` port
151
+
152
+ `restore` and `sync` talk to a two-method interface — `get(key)` returning `null` when
153
+ the object is absent, and `put(key, body)`. S3 vocabulary (`@aws-sdk/client-s3`, the
154
+ typed `NoSuchKey`/`NotFound` errors) is confined to the single adapter in
155
+ `src/s3-object-store.ts`, so the storage backend can be swapped without touching the
156
+ restore/sync logic.
157
+
158
+ The S3 adapter is the only one that exists. It uses no static credentials — auth is
159
+ whatever the ambient AWS credential chain resolves (on the Evident runner, the ECS task
160
+ role).
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ pnpm install
166
+ pnpm build # tsup -> dist/cli.js, a single self-contained ESM bundle
167
+ pnpm test # vitest
168
+ pnpm typecheck
169
+ pnpm lint
170
+ ```
171
+
172
+ From the repository root, prefix with `pnpm --filter @evident-ai/runner-synchroniser`.
173
+
174
+ The bundle must stay a **single file**: the runner image copies exactly one artifact out
175
+ of its builder stage, so code splitting would ship a `cli.js` importing chunks that are
176
+ not there. `tsup.config.ts` disables splitting and bundles the AWS SDK, and the
177
+ Dockerfile asserts the output is one file.
178
+
179
+ The restore/sync/CLI tests need neither AWS nor a real filesystem — rather than shimming
180
+ a fake `aws` binary onto `PATH`, they inject two ports: `InMemoryObjectStore` (in
181
+ `src/object-store.ts`) and `FakeFileOps` (in `src/test-support.ts`). The `FileOps` port
182
+ exists specifically for failure injection: the suite runs as root, where a real
183
+ `chmod 000` is a no-op, so "a `chmod` that fails must warn and not abort" and "a hash
184
+ failure must skip one tick, not kill the loop" are otherwise not reproducible. The real
185
+ `nodeFileOps` implementation is tested against a temporary directory.