@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.
- package/README.md +185 -0
- package/dist/cli.js +49891 -0
- 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.
|