@rien7/agent-center-host 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RieN7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,336 @@
1
- # Temporary Holding Version
1
+ # agent-host
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The process that runs agents for Agent Center. It connects out to Center with its own
4
+ credential, claims queued turns, answers them through a **driver** and streams the reply
5
+ back as `AgentChatTurnEvent`s.
6
+
7
+ - **OarDriver** runs a real agent through [oar](https://github.com/botiverse/oar)
8
+ (`@botiverse/oar`, pinned to an exact version): **Claude Code** by default, **pi**
9
+ (bundled, in-process, any OpenAI/Anthropic-compatible provider) per agent.
10
+ - **EchoDriver** plays a fixed script (`runtime: "echo"`), for testing the wire.
11
+ - **Target: Node 24.** Published to npm as
12
+ [`@rien7/agent-center-host`](https://www.npmjs.com/package/@rien7/agent-center-host): compiled ESM
13
+ in `dist/` (`tsc -p tsconfig.build.json`), Node >= 24 (oar's own engine). In this repo
14
+ the sources are plain TypeScript that Bun and Node 24 run directly (type stripping, so
15
+ only erasable syntax and `.ts` imports; the build rewrites them to `.js`).
16
+ - Server side: `domains/agent` (ops `agent.host.register`, `host.heartbeat`,
17
+ `turn.claim`, `turn.open`, `event.append`, `turn.finish`, `agent.upsert`,
18
+ `agent.issue_token`, `memory_file.*`; SSE `GET /v1/agent/events?hostId=`).
19
+
20
+ ## On a Mac (three steps)
21
+
22
+ You need Node 24 or later, and Claude Code installed and logged in (`claude`, then
23
+ `/login`; check with `claude auth status`).
24
+
25
+ ```sh
26
+ # 1. Log in: prints a pairing code; approve it in the Agent Center app.
27
+ npx @rien7/agent-center-host login --server https://agent-center.rien7.com
28
+
29
+ # 2. Check: Center login, Claude Code found and logged in, workdir writable.
30
+ npx @rien7/agent-center-host doctor
31
+
32
+ # 3. Run (keep it open; Ctrl-C stops it, the agents show offline in the app).
33
+ npx @rien7/agent-center-host run --name mac
34
+ ```
35
+
36
+ Or install it once (`npm i -g @rien7/agent-center-host`) and run `agent-center-host …` (also
37
+ installed as `agent-host`). From a checkout of this repo (after `bun install`), use
38
+ `bun packages/agent-host/src/cli.ts …` instead.
39
+
40
+ That's all: without a `host.json` agent list the host runs one agent, 「助手」, on Claude
41
+ Code, and gets its Center token from Center by itself (read everything, write memos,
42
+ reminders and calendar). The agent appears in the app's agent menu with a green dot while
43
+ the host runs. Its memory is in `~/.agent-center/work/agents/<id>/memory/`.
44
+
45
+ What `doctor` prints when all is well:
46
+
47
+ ```
48
+ ✓ login: https://agent-center.example as agent-host mac (agent:host)
49
+ ✓ runtime claude: /opt/homebrew/bin/claude 2.1.x
50
+ ✓ claude login: you@example.com, max, …
51
+ ✓ workdir: /Users/you/.agent-center/work
52
+ · agent 助手: claude, token issued by Center (/Users/you/.agent-center/agent-tokens.json)
53
+ · sub-agents: on
54
+ ```
55
+
56
+ When Claude Code isn't found, install it (`npm i -g @anthropic-ai/claude-code`) or point
57
+ `OAR_CLAUDE_BIN` at it; when it is logged out, run `claude` and `/login`. Logs go to
58
+ stderr: each turn (`answering`, `finished`), each session (`opened claude session …
59
+ (records …)`), the token (`助手: got its own Center token`).
60
+
61
+ ## Use
62
+
63
+ ```sh
64
+ # 1. Log in (RFC 8628 device flow, scope agent:host). Approve the request in the app
65
+ # (or via core.oauth_request.approve) with the pairing code it prints.
66
+ node packages/agent-host/bin/agent-host.js login --server https://agent-center.example
67
+ # → ~/.agent-center/host.json (0600; AGENT_CENTER_HOST_CONFIG overrides the path)
68
+
69
+ # 2. Optional: agents in host.json, next to the login (see "host.json" below). Without
70
+ # any the host runs one agent, 助手. Each agent's token comes from Center
71
+ # (agent.issue_token) unless host.json gives one made by hand:
72
+ # bun run cli principal:create --name 助手 --kind agent \
73
+ # --scopes '*:read,agent:read,memo:write,reminder:write,calendar:write'
74
+
75
+ # 3. Run. Agents in host.json are matched by id or name, created when missing.
76
+ node packages/agent-host/bin/agent-host.js run --name mac-studio
77
+ # --runtime echo: the echo driver (creates an agent 回声 when there are no agents)
78
+ ```
79
+
80
+ With Bun, replace `node` with `bun`; from npm, use `npx @rien7/agent-center-host` instead. Instead of logging in you can pass a static token,
81
+ for example a service principal from `bun run cli principal:create --name mac-studio
82
+ --kind service --scopes agent:host`:
83
+
84
+ ```sh
85
+ AGENT_CENTER_TOKEN=ac_… node packages/agent-host/bin/agent-host.js run --name mac-studio --server http://127.0.0.1:8800
86
+ ```
87
+
88
+ Then send a message as in `domains/agent/README.md` (`agent.turn.start`) or from the app.
89
+ With the echo driver the reply is a thought, one `echo` tool call, your text streamed
90
+ back as 「你说:…」, and a record card pointing at your own message.
91
+
92
+ ## host.json
93
+
94
+ ```jsonc
95
+ {
96
+ "server": "https://…", "accessToken": "ac_…", // the host's login (agent-host login)
97
+ "runtime": "claude", // default runtime: claude | pi | … | echo
98
+ "model": "sonnet", // default model; pi: "provider/model" from models.json
99
+ "workdir": "~/.agent-center/work",
100
+ "timeZone": "Asia/Shanghai", // 「今天」 and the [时间] line; default: the machine's
101
+ "idleMinutes": 15, // close an idle agent's oar session (resumed later)
102
+ "contextTools": "inline", // default; or "direct", see Tools
103
+ "piAgentDir": "~/.agent-center/pi", // pi's models.json / auth.json (OAR_PI_AGENT_DIR)
104
+ "subagents": true, // main agents get agent_spawn / send / wait / close
105
+ "agents": [ // default: [{ "name": "助手" }]
106
+ { "name": "助手", "role": "…", "runtime": "pi", "model": "airrouter/…",
107
+ "scopes": ["memo:write", "reminder:write"], // write scopes of its issued token
108
+ "token": "ac_…" } // optional, made by hand; or "tokenEnv": "AGENT_TOKEN_ASSISTANT"
109
+ ]
110
+ }
111
+ ```
112
+
113
+ On start the host checks every agent token with `core.principal.me` and refuses one
114
+ that carries `*`, `agent:host`, `agent:*`, `core:*` or `core:admin`.
115
+
116
+ **Models.** Claude Code uses its own login (`claude auth login`, or
117
+ `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`). pi needs a provider: an env key
118
+ (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, …) or a `models.json` provider in `piAgentDir`,
119
+ e.g. an OpenAI-compatible gateway:
120
+
121
+ ```json
122
+ {"providers":{"airrouter":{"baseUrl":"https://…/v1","api":"openai-completions","apiKey":"$AIRROUTER_KEY","models":[{"id":"…"}]}}}
123
+ ```
124
+
125
+ For development without any key, `test/fixtures/mock-openai.ts` is a scripted
126
+ OpenAI-compatible server (`bun packages/agent-host/test/fixtures/mock-openai.ts 18999`
127
+ prints the `models.json` to use; model `mock/mock-1`).
128
+
129
+ ## Tools
130
+
131
+ Core's `/mcp` has ~100 tools (~130 KB of schemas), too many to put in front of a model on
132
+ every request. The host serves its own small MCP server per agent instead
133
+ (oar `serveMcpTools`: loopback HTTP with a random bearer, attached to the session; chosen
134
+ over stdio because the handlers run in the host, which holds the agent token, and both
135
+ pi and Claude Code take HTTP MCP servers). The model sees:
136
+
137
+ | tool | what |
138
+ |---|---|
139
+ | `tools_search({query?, domain?})` | tool names + one line each; `domain` lists a whole domain, `query` searches (中文 or English) |
140
+ | `tool_describe({name})` | one tool's description and input schema |
141
+ | `tool_call({name, input})` | run it: proxied to core `/mcp` with the agent's token |
142
+ | `today_overview({date?})` | a day's events, reminders (plus overdue ones today) and visits |
143
+ | `agent_search_query` | full-text search over past conversations (core's own tool) |
144
+
145
+ plus the runtime's file tools for memory (pi `read/edit/write`; Claude Code
146
+ `Read/Edit/Write/…`; shell, web and sub-agent tools are disallowed).
147
+
148
+ - **Domain catalog.** The system prompt lists every domain the token can read, one line
149
+ each, built from `core.domain.list` and the tool list: `reminder 提醒:待办和提醒 —
150
+ item_list, item_create, …` (~400 tokens; long domains like ledger shrink to a count and
151
+ their common tools).
152
+ - **The context's domains.** When a message carries a `context` (the 今天 page →
153
+ reminder, calendar, location; a record → its domain), each of those domains' list/get
154
+ and its one common write (`reminder_item_create`, `calendar_event_create`,
155
+ `memo_note_create`) are expanded once per session; the domain's other tools are named
156
+ and stay a `tool_describe` away. By default (`contextTools: "inline"`) their parameters
157
+ are inlined in that turn's prefix (今天: ~1.7k tokens, once). With `"direct"` they're
158
+ added as MCP tools with full schemas instead (~2.8k tokens on every request; pi takes
159
+ the tool-list change fully, Claude Code takes additions).
160
+ - **Denylist.** Scopes are the real boundary; the host also keeps out sync/credential
161
+ plumbing (`core_sync_*`, `core_change_*`, `core_command_*`, …), `*_settings_update` and
162
+ every `agent_*` op but search and reads.
163
+ - **Titles.** Steps are titled by the inner tool in Chinese (`tool-titles.ts`):
164
+ `tool_call(reminder_item_create)` shows as 「新建提醒」 with the input's title as detail.
165
+ - **Record cards.** A write's result carries its `commandId`; the host reads
166
+ `core.command.get` + `core.entity.get` (agent token) and appends a `record` card per
167
+ changed entity (at most 3).
168
+
169
+ ## Memory and records
170
+
171
+ - `<workdir>/agents/<agentId>/` is the session's cwd. `memory/MEMORY.md` (index) and
172
+ `memory/notes/*.md` are mirrored with Center (`agent.memory_file.*`): pulled at start
173
+ (Center wins), pushed after each turn and on file changes with `ifVersion` (a conflict
174
+ takes Center's copy). A new agent gets a template MEMORY.md. MEMORY.md (≤8k characters)
175
+ is put in front of a turn's message whenever it changed since the session last saw it.
176
+ - Every turn's message starts with `[时间] <local time>` and, with a context,
177
+ `[上下文] 用户正在看「今天」页(…)`.
178
+ - oar's raw records go to `<workdir>/records/<agentId>/<sessionId>-<opened>.jsonl`.
179
+ - The oar session id is stored as the agent's `resumeSessionId`; after a restart or an
180
+ idle close the session is resumed (a fresh one when that fails). A turn that was
181
+ half-answered when the host died is run again once: the model gets
182
+ 「上一轮被打断,请继续。…」 and the user's message, the reply goes on after what it already
183
+ had (「(host 重启了,接着说)」). A second restart in the same turn fails it (turn ids
184
+ run again are kept in `<workdir>/resumed-turns.json`).
185
+ - Core runs one turn per agent at a time, so a second message waits for the first (the
186
+ driver also handles oar's steer/queue landings for when that changes).
187
+ - **Inactive history.** When the session compacts (oar `compaction_ended`, completed),
188
+ the host calls `agent.history.mark_inactive` with `beforeTurnId` (the tool calls of every
189
+ earlier turn of the agent) and the calls the current turn made so far; the messages stay
190
+ (the summary keeps them). When oar drops an accepted input (`input_dropped`; oar 0.86
191
+ has no other rollback record), that turn fails and its user message, reply and tool calls
192
+ are marked inactive. The mark waits for the turn's queued events to reach Center.
193
+ `search.query` and `message.list` leave inactive records out unless `includeInactive`.
194
+
195
+ `Ctrl-C` flushes queued events, marks the host's agents offline and leaves any running
196
+ turn running. The next `run` resumes it.
197
+
198
+ ## Sub-agents
199
+
200
+ A main agent can hand a self-contained job to a sub-agent (`subagents: true`, the
201
+ default). Four host-served tools, on top of oar's crew (`@botiverse/oar/agents`):
202
+
203
+ | tool | what |
204
+ |---|---|
205
+ | `agent_spawn({name, task, writeScopes?})` 「开子 agent」 | a new core agent: `parentAgentId` = the parent, this host, the parent's runtime and model; its own token (read-only, plus `writeScopes`, at most the parent's own write scopes); its own workdir and memory. The task is a `turn.open` turn in the child's new conversation (context 「「助手」交给的任务」), the child's session is opened by the crew with it. The parent's reply gets a card 「子 agent「…」开始了」 into that conversation. |
206
+ | `agent_send({agent, message})` 「给子 agent 发消息」 | a follow-up as another `turn.open` turn in the child's conversation; refused while the child is busy |
207
+ | `agent_wait({agents?, timeoutSeconds?})` 「等子 agent 的报告」 | the reports that came in (waits up to 120 s by default); each gets a card |
208
+ | `agent_close({agent})` 「结束子 agent」 | closes its session; the agent and its conversation stay (status offline); a later message reopens it |
209
+
210
+ - **Reports.** Every turn the parent started ends in a report (the child's reply, which
211
+ is already in the child's conversation). It goes to a waiting `agent_wait`, else to
212
+ the parent as a `turn.open` turn in the conversation the task came from: the stored
213
+ message is 「子 agent「…」的报告:…」 (context: the child's conversation; oar origin
214
+ `notification`), the reply starts with a card 「子 agent「…」完成了」 whose uri is
215
+ `agent:conversation/<child conversation>`; the app opens that conversation. While the
216
+ parent is busy, reports wait for its turn to end.
217
+ - **Talking to a child.** It is in `agent.list` like any agent; the user's turns are
218
+ claimed and answered by the same host. Those turns don't report to the parent.
219
+ - **Limits.** Children don't get the four tools (depth 2). At most 4 children run at
220
+ once, 8 open per parent. After a restart the host takes its children back
221
+ (`parentAgentId` one of its agents) with their saved tokens and resumes their sessions.
222
+
223
+ ## Presence
224
+
225
+ The host says `host.heartbeat` every 20 s. Center marks a host's agents offline when its
226
+ event stream closes (5 s grace) or after 60 s without any call, and online again on the
227
+ next one; `Ctrl-C` marks them offline at once. The app shows the state in the agent menu
228
+ and, for an offline agent, 「…现在离线,消息会排队,等它上线后回复」: turns stay queued and
229
+ are answered when the host is back.
230
+
231
+ ## Tokens
232
+
233
+ The host token carries `agent:host`. It can claim turns and write replies for every agent
234
+ assigned to the host (and for unassigned ones), but it can't read the owner's data.
235
+ **Per-agent tokens, the credentials an agent's tools use to call Center, must never
236
+ carry `agent:host`.** An agent holding it could claim other agents' turns and write their
237
+ replies. `agent:host` is an explicit scope: `agent:write`, `*:write` or `agent:*` don't
238
+ grant it. Never hand the host's own token to a runtime or its tools either; it stays in
239
+ the host process.
240
+
241
+ **Issued agent tokens.** An agent without a token in host.json gets one from Center: the
242
+ host calls `agent.agent.issue_token({agentId, scopes})` with its own credential. Center
243
+ allows it only for agents assigned to the calling host, gives `*:read agent:read` plus
244
+ the write scopes asked for, and refuses `*`, `agent:host`, anything implying
245
+ `agent:write` and `core:*` beyond `core:read`. The agent keeps one principal (kind
246
+ agent, `agent.tokenPrincipalId`); calling again replaces its scopes and revokes its
247
+ earlier tokens. The host keeps the tokens in `agent-tokens.json` next to host.json
248
+ (0600, per server) and asks for a new one when Center rejects the saved one or the
249
+ scopes changed. Why a host op rather than giving the host `core:token`/`core:admin`:
250
+ those reach every principal, while this op can only mint restricted tokens for the
251
+ host's own agents, so a stolen host token still can't read the owner's data or act as
252
+ the owner.
253
+
254
+ ## Server
255
+
256
+ `deploy/agent-host.Dockerfile` (Node 24 + oar + Claude Code) and the opt-in
257
+ `deploy/compose.agent-host.yaml` (next to `api`, MCP via `http://api:8800`, a volume for
258
+ `/data`).
259
+
260
+ ## Shape
261
+
262
+ ```
263
+ src/core-client.ts CoreClient interface + HttpCoreClient (ops, SSE parsing, 401 → refresh)
264
+ src/driver.ts Driver interface (start, run(turn, sink, signal), stop) + EchoDriver
265
+ src/oar-driver.ts OarDriver: oar sessions per agent, event mapping, cards, resume, idle
266
+ src/center-tools.ts the agent's Center tools (catalog, search, describe, call, overview)
267
+ src/tool-titles.ts Chinese step titles
268
+ src/prompt.ts system prompt + per-turn prefix
269
+ src/memory.ts memory files ↔ Center
270
+ src/host.ts AgentHost: register, listen, claim, batch, stop, reconnect, resume
271
+ src/config.ts host.json settings (runtime, model, workdir, agents)
272
+ src/credentials.ts host.json login, TokenSource (OAuth refresh)
273
+ src/login.ts device login
274
+ src/crew-tools.ts agent_spawn / agent_send / agent_wait / agent_close (sub-agents, in oar-driver.ts)
275
+ src/agent-tokens.ts the agents' issued tokens (agent-tokens.json)
276
+ src/cli.ts agent-host login | run | doctor
277
+ ```
278
+
279
+ - **Claiming.** The host claims (`resume: true`, only its own agents) on start, on every
280
+ stream `ready`, on each `turn` push with state `queued`, after each finished turn (an
281
+ agent's next queued turn isn't pushed again), and on a 30 s safety poll.
282
+ - **Batching.** Events are appended every 100 ms, and immediately on `done`/`failed`, one
283
+ request at a time. Each batch's idempotency key is `ev-<turnId>-<seq>`, so a retry
284
+ after a network error is applied once.
285
+ - **Stop.** A `turn` push with state `stopped` (or a 409/403 on append) aborts the
286
+ runtime and drops its queued events.
287
+ - **Reconnect.** When the stream ends, errors, or goes 45 s without even a heartbeat, the
288
+ host reconnects with jittered exponential backoff (0.5 s up to 30 s) and re-claims.
289
+ - **Resume.** A running turn claimed back after a restart carries `eventCount`. The
290
+ driver gets it as `appended`: EchoDriver is deterministic and skips that many events;
291
+ OarDriver can't replay and runs the turn again once (see Memory and records).
292
+ - **Host-opened turns.** A driver may add agents (`addAgent`) and open turns
293
+ (`openTurn` → `turn.open`, already running and claimed by this host); they run
294
+ through the same batching as claimed ones.
295
+
296
+ ## Test
297
+
298
+ ```sh
299
+ bun test packages/agent-host # core in-process over real HTTP + the host loop;
300
+ # oar-driver.test.ts: pi + the mock model, end to end
301
+ # subagents.test.ts: issued tokens, memory notes,
302
+ # sub-agents, presence, interrupted turns
303
+ # inactive.test.ts: compaction / dropped input →
304
+ # inactive history (fake oar session)
305
+ bun x tsc -p packages/agent-host # typecheck against Node types
306
+ ```
307
+
308
+ ## Publishing
309
+
310
+ Releases go out from GitHub Actions (`.github/workflows/agent-host-publish.yml`) with npm
311
+ trusted publishing (OIDC, no npm token):
312
+
313
+ 1. Bump `version` in `packages/agent-host/package.json`, commit and merge to `main`.
314
+ 2. Tag that commit and push the tag:
315
+
316
+ ```sh
317
+ git tag agent-host-v0.1.1 && git push origin agent-host-v0.1.1
318
+ ```
319
+
320
+ The workflow checks that the tag is on `main` and matches the package version, runs the
321
+ tests and typecheck, builds, smoke-tests the packed tarball and runs `npm publish
322
+ --access public`.
323
+
324
+ One-time setup (the package has to exist on npm before a trusted publisher can be
325
+ added):
326
+
327
+ ```sh
328
+ cd packages/agent-host
329
+ npm login
330
+ npm publish --access public # prepublishOnly builds dist/ first
331
+ ```
332
+
333
+ then on npmjs.com → @rien7/agent-center-host → Settings → Trusted publishing → GitHub Actions:
334
+ owner `rien7`, repository `agent-center`, workflow `agent-host-publish.yml`, no
335
+ environment, allowed action **npm publish**. The first CI publish must happen within
336
+ 2 days of adding it. `npm pack --dry-run` shows what ships: `dist/` and this README.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The agents' own Center tokens, issued by Center to the host (`agent.agent.issue_token`)
3
+ * and kept next to the host's login in `agent-tokens.json` (0600, per server). An agent
4
+ * token is restricted: read-only by default plus the write scopes asked for, never
5
+ * `agent:host`, `agent:write` or core beyond read (Center refuses those).
6
+ *
7
+ * A token written by hand in host.json (`agents[].token` / `tokenEnv`) still wins.
8
+ */
9
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+ import { configPath } from "./credentials.js";
12
+ /** What a new agent may write by default (on top of *:read, agent:read). */
13
+ export const defaultAgentScopes = [
14
+ "memo:write",
15
+ "reminder:write",
16
+ "calendar:write",
17
+ ];
18
+ export const agentTokensPath = (env = process.env) => env.AGENT_CENTER_AGENT_TOKENS ??
19
+ join(dirname(configPath(env)), "agent-tokens.json");
20
+ export class AgentTokens {
21
+ path;
22
+ server;
23
+ client;
24
+ file = null;
25
+ chain = Promise.resolve();
26
+ /** `path: null` keeps the tokens in memory only (tests, read-only setups). */
27
+ constructor(options) {
28
+ this.client = options.client;
29
+ this.server = options.server;
30
+ this.path = options.path === undefined ? agentTokensPath() : options.path;
31
+ }
32
+ async load() {
33
+ if (this.file)
34
+ return this.file;
35
+ let file = {};
36
+ if (this.path)
37
+ try {
38
+ file = JSON.parse(await readFile(this.path, "utf8"));
39
+ }
40
+ catch (error) {
41
+ if (error.code !== "ENOENT")
42
+ throw error;
43
+ }
44
+ this.file = file;
45
+ return file;
46
+ }
47
+ save() {
48
+ const path = this.path;
49
+ const file = this.file;
50
+ if (!path || !file)
51
+ return Promise.resolve();
52
+ this.chain = this.chain.then(async () => {
53
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
+ const tmp = `${path}.${process.pid}.tmp`;
55
+ await writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`, {
56
+ mode: 0o600,
57
+ });
58
+ await chmod(tmp, 0o600);
59
+ await rename(tmp, path);
60
+ });
61
+ return this.chain;
62
+ }
63
+ /** What is saved for an agent (its token and the scopes asked for), if anything. */
64
+ async entry(agentId) {
65
+ return (await this.load())[this.server]?.[agentId] ?? null;
66
+ }
67
+ /** The saved token of an agent, if it has one with these scopes. */
68
+ async saved(agentId, scopes) {
69
+ const entry = (await this.load())[this.server]?.[agentId];
70
+ return entry && sameScopes(entry.scopes, scopes) ? entry.token : null;
71
+ }
72
+ /** Ask Center for a (new) token and keep it; earlier tokens of the agent are revoked. */
73
+ async issue(agentId, scopes) {
74
+ if (!this.client.issueToken)
75
+ throw new Error("this Center client cannot issue agent tokens");
76
+ const issued = await this.client.issueToken(agentId, scopes);
77
+ const file = await this.load();
78
+ file[this.server] ??= {};
79
+ file[this.server][agentId] = {
80
+ token: issued.token,
81
+ scopes,
82
+ principalId: issued.principalId,
83
+ };
84
+ await this.save();
85
+ return issued.token;
86
+ }
87
+ async forget(agentId) {
88
+ const file = await this.load();
89
+ if (file[this.server]?.[agentId]) {
90
+ delete file[this.server][agentId];
91
+ await this.save();
92
+ }
93
+ }
94
+ /**
95
+ * A working token for the agent: the saved one when `check` accepts it, else a newly
96
+ * issued one. `check` returns false when Center rejects the token (401).
97
+ */
98
+ async get(agentId, scopes, check) {
99
+ const saved = await this.saved(agentId, scopes);
100
+ if (saved && (await check(saved)))
101
+ return { token: saved, issued: false };
102
+ return { token: await this.issue(agentId, scopes), issued: true };
103
+ }
104
+ }
105
+ const sameScopes = (a, b) => a.length === b.length && [...a].sort().join() === [...b].sort().join();