@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/dist/cli.js ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ /**
11
+ * agent-host CLI.
12
+ *
13
+ * agent-host login [--server URL] [--name NAME]
14
+ * agent-host run [--name NAME] [--server URL] [--agent AGENT_NAME] [--runtime ID]
15
+ * agent-host doctor [--runtime ID]
16
+ *
17
+ * Credentials: ~/.agent-center/host.json (AGENT_CENTER_HOST_CONFIG overrides the path),
18
+ * or a static token in AGENT_CENTER_TOKEN (e.g. a service principal with agent:host from
19
+ * `bun run cli principal:create`). Never give the host token to an agent's tools.
20
+ * Settings (runtime, model, workdir, agents and their tokens): config.ts. The agents'
21
+ * own tokens are issued by Center to the host (agent-tokens.ts) unless host.json has one.
22
+ */
23
+ import { mkdir, rm, writeFile } from "node:fs/promises";
24
+ import { hostname } from "node:os";
25
+ import { join } from "node:path";
26
+ import { parseArgs } from "node:util";
27
+ import { agentTokensPath } from "./agent-tokens.js";
28
+ import { defaultAgent, loadHostConfig } from "./config.js";
29
+ import { HttpCoreClient } from "./core-client.js";
30
+ import { configPath, loadCredentials, saveCredentials, TokenSource, } from "./credentials.js";
31
+ import { EchoDriver } from "./driver.js";
32
+ import { AgentHost } from "./host.js";
33
+ import { deviceLogin, normalizeServer } from "./login.js";
34
+ import { OarDriver } from "./oar-driver.js";
35
+ const usage = `Usage:
36
+ agent-host login [--server URL] [--name NAME]
37
+ Device login: shows a pairing code to approve in the Agent Center app
38
+ (scope agent:host), then stores the token in ${configPath()} (0600).
39
+ agent-host run [--name NAME] [--server URL] [--runtime ID] [--agent AGENT_NAME]
40
+ Register as host NAME (default: host.json name, else the machine name), listen
41
+ for turns and answer them. The runtime comes from host.json (default claude;
42
+ pi, … or echo). The agents are host.json's agents[] (default one, 助手); each
43
+ gets its own restricted Center token from Center (kept in
44
+ ${agentTokensPath()}) unless host.json gives one.
45
+ With --runtime echo and no agents, AGENT_NAME is created (default 回声).
46
+ agent-host doctor [--runtime ID]
47
+ Check the login, Center, the runtime (installed? logged in?) and the workdir.
48
+
49
+ Environment: AGENT_CENTER_TOKEN (static token instead of the login file),
50
+ AGENT_CENTER_SERVER (default server), AGENT_CENTER_HOST_CONFIG (file path),
51
+ AGENT_HOST_RUNTIME, AGENT_HOST_MODEL, AGENT_HOST_WORKDIR (override host.json),
52
+ OAR_PI_AGENT_DIR (pi's models.json / auth.json).`;
53
+ const { positionals, values } = parseArgs({
54
+ allowPositionals: true,
55
+ options: {
56
+ server: { type: "string" },
57
+ name: { type: "string" },
58
+ agent: { type: "string" },
59
+ runtime: { type: "string" },
60
+ help: { type: "boolean", short: "h" },
61
+ },
62
+ });
63
+ const fail = (message) => {
64
+ console.error(message);
65
+ process.exit(1);
66
+ };
67
+ async function login() {
68
+ const saved = await loadCredentials().catch(() => null);
69
+ const server = normalizeServer(values.server ??
70
+ process.env.AGENT_CENTER_SERVER ??
71
+ saved?.server ??
72
+ "http://127.0.0.1:8800");
73
+ const credentials = await deviceLogin({
74
+ server,
75
+ clientName: `agent-host ${values.name ?? hostname()}`,
76
+ clientId: saved?.server === server ? saved.clientId : undefined,
77
+ prompt: ({ userCode, verificationUri }) => console.log(`Approve the login request in the Agent Center app with pairing code ${userCode}\n(${verificationUri}). Waiting…`),
78
+ });
79
+ await saveCredentials(credentials);
80
+ console.log(`Logged in to ${server}; token saved to ${configPath()}`);
81
+ }
82
+ async function run() {
83
+ const config = await loadHostConfig();
84
+ if (values.runtime)
85
+ config.runtime = values.runtime;
86
+ const name = values.name ?? config.name ?? hostname();
87
+ const env = process.env.AGENT_CENTER_TOKEN;
88
+ let credentials;
89
+ if (env)
90
+ credentials = {
91
+ server: normalizeServer(values.server ??
92
+ process.env.AGENT_CENTER_SERVER ??
93
+ "http://127.0.0.1:8800"),
94
+ accessToken: env,
95
+ };
96
+ else {
97
+ credentials = await loadCredentials();
98
+ if (!credentials)
99
+ return fail(`Not logged in: run agent-host login (or set AGENT_CENTER_TOKEN)`);
100
+ if (values.server && normalizeServer(values.server) !== credentials.server)
101
+ return fail(`${configPath()} holds a login for ${credentials.server}; run agent-host login --server ${values.server}`);
102
+ }
103
+ const tokens = new TokenSource(credentials, {
104
+ save: env ? undefined : (c) => saveCredentials(c),
105
+ });
106
+ const driver = config.runtime === "echo"
107
+ ? new EchoDriver()
108
+ : new OarDriver({ config, server: credentials.server });
109
+ if (driver instanceof OarDriver && !config.agents.length)
110
+ config.agents = [{ ...defaultAgent }];
111
+ const host = new AgentHost({
112
+ client: new HttpCoreClient(tokens),
113
+ driver,
114
+ name,
115
+ agents: config.agents.map((a) => ({
116
+ ...(a.id ? { id: a.id } : {}),
117
+ name: a.name,
118
+ ...(a.role !== undefined ? { role: a.role } : {}),
119
+ runtime: a.runtime ?? config.runtime,
120
+ model: a.model ?? config.model ?? null,
121
+ })),
122
+ agentName: values.agent,
123
+ });
124
+ let stopping = false;
125
+ const shutdown = () => {
126
+ if (stopping)
127
+ process.exit(130);
128
+ stopping = true;
129
+ host.log("shutting down");
130
+ host.stop().then(() => process.exit(0));
131
+ };
132
+ process.on("SIGINT", shutdown);
133
+ process.on("SIGTERM", shutdown);
134
+ await host.start();
135
+ }
136
+ /** What `run` needs, checked one by one; exits 1 when something is missing. */
137
+ async function doctor() {
138
+ const config = await loadHostConfig();
139
+ if (values.runtime)
140
+ config.runtime = values.runtime;
141
+ let ok = true;
142
+ const line = (good, what, detail = "") => {
143
+ if (good === false)
144
+ ok = false;
145
+ console.log(`${good === null ? "·" : good ? "✓" : "✗"} ${what}${detail ? `: ${detail}` : ""}`);
146
+ };
147
+ // Center and the host's login.
148
+ const env = process.env.AGENT_CENTER_TOKEN;
149
+ const credentials = env
150
+ ? {
151
+ server: normalizeServer(values.server ??
152
+ process.env.AGENT_CENTER_SERVER ??
153
+ "http://127.0.0.1:8800"),
154
+ accessToken: env,
155
+ }
156
+ : await loadCredentials().catch(() => null);
157
+ if (!credentials)
158
+ line(false, "login", `none in ${configPath()}; run agent-host login`);
159
+ else {
160
+ const tokens = new TokenSource(credentials, {
161
+ save: env ? undefined : (c) => saveCredentials(c),
162
+ });
163
+ try {
164
+ const response = await fetch(`${credentials.server}/v1/ops/core.principal.me`, {
165
+ method: "POST",
166
+ headers: {
167
+ authorization: `Bearer ${await tokens.token()}`,
168
+ "content-type": "application/json",
169
+ },
170
+ body: "{}",
171
+ });
172
+ const body = (await response.json().catch(() => ({})));
173
+ const scopes = body.data?.principal?.scopes ?? [];
174
+ if (!response.ok)
175
+ line(false, "login", `${credentials.server} answered ${response.status}; run agent-host login`);
176
+ else
177
+ line(scopes.includes("agent:host"), "login", `${credentials.server} as ${body.data?.principal?.name ?? "?"} (${scopes.join(" ") || "no scopes"}${scopes.includes("agent:host") ? "" : "; needs agent:host"})`);
178
+ }
179
+ catch (error) {
180
+ line(false, "Center", `${credentials.server} unreachable (${error.message})`);
181
+ }
182
+ }
183
+ // The runtime.
184
+ if (config.runtime === "echo")
185
+ line(true, "runtime", "echo (no model)");
186
+ else
187
+ try {
188
+ const module = (await import(__rewriteRelativeImportExtension(`@botiverse/oar/runtimes/${config.runtime}`)));
189
+ const runtime = module[`${config.runtime}Runtime`];
190
+ if (!runtime)
191
+ throw new Error(`oar has no runtime ${config.runtime}`);
192
+ const installation = await runtime.installation?.();
193
+ if (!installation || installation.kind !== "available")
194
+ line(false, `runtime ${config.runtime}`, installation?.kind === "unsupported"
195
+ ? `unsupported (${installation.reason})`
196
+ : `not found${config.runtime === "claude" ? " (install Claude Code: npm i -g @anthropic-ai/claude-code, or set OAR_CLAUDE_BIN)" : ""}`);
197
+ else {
198
+ line(true, `runtime ${config.runtime}`, installation.via === "executable"
199
+ ? `${installation.command}${installation.version ? ` ${installation.version}` : ""}`
200
+ : "bundled");
201
+ if (runtime.authStatus) {
202
+ const status = await runtime.authStatus(installation);
203
+ line(status.kind === "logged_in", `${config.runtime} login`, status.kind === "logged_in"
204
+ ? [status.account?.email, status.account?.plan, status.source]
205
+ .filter(Boolean)
206
+ .join(", ")
207
+ : `logged out (${status.detail ?? status.source})${config.runtime === "claude" ? "; run claude and /login" : ""}`);
208
+ }
209
+ else
210
+ line(null, `${config.runtime} login`, "the runtime can't tell");
211
+ }
212
+ }
213
+ catch (error) {
214
+ line(false, `runtime ${config.runtime}`, error.message);
215
+ }
216
+ // The workdir (agents' cwd and memory).
217
+ try {
218
+ const probe = join(config.workdir, "agents", ".doctor");
219
+ await mkdir(probe, { recursive: true });
220
+ await writeFile(join(probe, "probe.md"), "ok\n");
221
+ await rm(probe, { recursive: true, force: true });
222
+ line(true, "workdir", config.workdir);
223
+ }
224
+ catch (error) {
225
+ line(false, "workdir", `${config.workdir} (${error.message})`);
226
+ }
227
+ const agents = config.agents.length ? config.agents : [defaultAgent];
228
+ for (const a of agents)
229
+ line(null, `agent ${a.name}`, `${a.runtime ?? config.runtime}${(a.model ?? config.model) ? ` ${a.model ?? config.model}` : ""}, token ${a.token ? "from host.json" : `issued by Center (${agentTokensPath()})`}`);
230
+ line(null, "sub-agents", config.subagents ? "on" : "off");
231
+ process.exit(ok ? 0 : 1);
232
+ }
233
+ if (values.help)
234
+ console.log(usage);
235
+ else if (positionals[0] === "login")
236
+ await login();
237
+ else if (positionals[0] === "run")
238
+ await run();
239
+ else if (positionals[0] === "doctor")
240
+ await doctor();
241
+ else
242
+ fail(usage);
package/dist/config.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Host settings, read from the same file as the credentials (`~/.agent-center/host.json`):
3
+ *
4
+ * ```json
5
+ * {
6
+ * "server": "https://…", "accessToken": "ac_…", // the host's own login
7
+ * "runtime": "claude", // default oar runtime: claude (default), pi, … or echo
8
+ * "model": "sonnet", // default model (runtime-specific, e.g. pi "provider/id")
9
+ * "workdir": "~/.agent-center/work",
10
+ * "timeZone": "Asia/Shanghai", // for 「今天」 and the time line in prompts
11
+ * "idleMinutes": 15, // close an agent's session after this long without turns
12
+ * "contextTools": "inline", // the context's domain tools: inlined once (default) or "direct" MCP tools
13
+ * "piAgentDir": "…", // pi's agent dir (models.json, auth.json); OAR_PI_AGENT_DIR
14
+ * "subagents": true, // main agents may spawn sub-agents (agent_spawn …)
15
+ * "agents": [ // default: one agent 「助手」
16
+ * { "name": "助手", "role": "…", "runtime": "pi", "model": "…",
17
+ * "scopes": ["memo:write"], // write scopes of its auto-issued token (default memo, reminder, calendar)
18
+ * "token": "ac_…", // …or a token made by hand (kind=agent, never agent:host)
19
+ * "tokenEnv": "AGENT_TOKEN_ASSISTANT" } // …or the env var holding it
20
+ * ]
21
+ * }
22
+ * ```
23
+ */
24
+ import { readFile } from "node:fs/promises";
25
+ import { homedir } from "node:os";
26
+ import { join, resolve } from "node:path";
27
+ import { configPath } from "./credentials.js";
28
+ /** The agent a host runs when host.json names none. */
29
+ export const defaultAgent = { name: "助手" };
30
+ const expand = (path) => path === "~" || path.startsWith("~/")
31
+ ? join(homedir(), path.slice(1))
32
+ : resolve(path);
33
+ export const defaultTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
34
+ export function parseHostConfig(value, env = process.env) {
35
+ const str = (key) => typeof value[key] === "string" && value[key]
36
+ ? value[key]
37
+ : undefined;
38
+ const agents = Array.isArray(value.agents) ? value.agents : [];
39
+ return {
40
+ name: str("name"),
41
+ runtime: env.AGENT_HOST_RUNTIME ?? str("runtime") ?? "claude",
42
+ model: env.AGENT_HOST_MODEL ?? str("model"),
43
+ workdir: expand(env.AGENT_HOST_WORKDIR ?? str("workdir") ?? "~/.agent-center/work"),
44
+ timeZone: str("timeZone") ?? defaultTimeZone(),
45
+ idleMinutes: typeof value.idleMinutes === "number" && value.idleMinutes > 0
46
+ ? value.idleMinutes
47
+ : 15,
48
+ contextTools: value.contextTools === "direct" ? "direct" : "inline",
49
+ subagents: value.subagents !== false,
50
+ piAgentDir: env.OAR_PI_AGENT_DIR ??
51
+ (str("piAgentDir") ? expand(str("piAgentDir")) : undefined),
52
+ agents: agents.map((raw, index) => {
53
+ const a = raw;
54
+ if (typeof a?.name !== "string" || !a.name)
55
+ throw new Error(`agents[${index}] needs a name`);
56
+ const token = (typeof a.tokenEnv === "string" ? env[a.tokenEnv] : undefined) ??
57
+ (typeof a.token === "string" ? a.token : undefined);
58
+ return {
59
+ ...(typeof a.id === "string" ? { id: a.id } : {}),
60
+ name: a.name,
61
+ ...(typeof a.role === "string" || a.role === null
62
+ ? { role: a.role }
63
+ : {}),
64
+ ...(typeof a.runtime === "string" ? { runtime: a.runtime } : {}),
65
+ ...(typeof a.model === "string" ? { model: a.model } : {}),
66
+ ...(token ? { token } : {}),
67
+ ...(Array.isArray(a.scopes) ? { scopes: a.scopes.map(String) } : {}),
68
+ ...(Array.isArray(a.disallowedTools)
69
+ ? { disallowedTools: a.disallowedTools.map(String) }
70
+ : {}),
71
+ };
72
+ }),
73
+ };
74
+ }
75
+ /** Settings from host.json (missing file: defaults, no agents). */
76
+ export async function loadHostConfig(path = configPath(), env = process.env) {
77
+ let value = {};
78
+ try {
79
+ value = JSON.parse(await readFile(path, "utf8"));
80
+ }
81
+ catch (error) {
82
+ if (error.code !== "ENOENT")
83
+ throw error;
84
+ }
85
+ return parseHostConfig(value, env);
86
+ }
@@ -0,0 +1,151 @@
1
+ /** An error response from Center (`{error:{code,message,details}}`). */
2
+ export class CoreError extends Error {
3
+ status;
4
+ code;
5
+ details;
6
+ constructor(status, code, message, details) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ this.details = details;
11
+ }
12
+ }
13
+ export class HttpCoreClient {
14
+ tokens;
15
+ fetcher;
16
+ constructor(tokens, options = {}) {
17
+ this.tokens = tokens;
18
+ this.fetcher = options.fetch ?? fetch;
19
+ }
20
+ async send(path, init) {
21
+ for (let attempt = 0;; attempt++) {
22
+ const response = await this.fetcher(`${this.tokens.server}${path}`, {
23
+ ...init,
24
+ headers: {
25
+ ...init.headers,
26
+ authorization: `Bearer ${await this.tokens.token()}`,
27
+ },
28
+ });
29
+ if (response.status === 401 && attempt === 0) {
30
+ await response.body?.cancel();
31
+ if (await this.tokens.rejected())
32
+ continue;
33
+ }
34
+ return response;
35
+ }
36
+ }
37
+ async op(name, input, idempotencyKey) {
38
+ const response = await this.send(`/v1/ops/agent.${name}`, {
39
+ method: "POST",
40
+ headers: {
41
+ "content-type": "application/json",
42
+ ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
43
+ },
44
+ body: JSON.stringify(input),
45
+ });
46
+ const body = (await response.json().catch(() => ({})));
47
+ if (!response.ok || body.data === undefined)
48
+ throw new CoreError(response.status, body.error?.code ?? "http", body.error?.message ?? `agent.${name} failed with ${response.status}`, body.error?.details);
49
+ return body.data;
50
+ }
51
+ register(name) {
52
+ return this.op("host.register", { name });
53
+ }
54
+ upsertAgent(input) {
55
+ return this.op("agent.upsert", input);
56
+ }
57
+ heartbeat() {
58
+ return this.op("host.heartbeat", {});
59
+ }
60
+ issueToken(agentId, scopes = []) {
61
+ return this.op("agent.issue_token", { agentId, scopes });
62
+ }
63
+ openTurn(input) {
64
+ return this.op("turn.open", input, `open-${input.id}`);
65
+ }
66
+ markInactive(input) {
67
+ return this.op("history.mark_inactive", input);
68
+ }
69
+ async claim(input) {
70
+ return (await this.op("turn.claim", input)).items;
71
+ }
72
+ append(turnId, events, seq) {
73
+ return this.op("event.append", { turnId, events }, `ev-${turnId}-${seq}`);
74
+ }
75
+ finish(turnId, state, error) {
76
+ return this.op("turn.finish", { turnId, state, ...(error ? { error } : {}) });
77
+ }
78
+ async memoryList(agentId) {
79
+ return (await this.op("memory_file.list", {
80
+ agentId,
81
+ })).items;
82
+ }
83
+ memoryGet(agentId, path) {
84
+ return this.op("memory_file.get", { agentId, path });
85
+ }
86
+ memoryPut(input) {
87
+ return this.op("memory_file.put", input);
88
+ }
89
+ async *events(hostId, signal) {
90
+ const response = await this.send(`/v1/agent/events?hostId=${encodeURIComponent(hostId)}`, { headers: { accept: "text/event-stream" }, signal });
91
+ if (!response.ok || !response.body) {
92
+ const body = (await response.json().catch(() => ({})));
93
+ throw new CoreError(response.status, body.error?.code ?? "http", body.error?.message ?? `events stream failed with ${response.status}`);
94
+ }
95
+ for await (const frame of parseSse(response.body))
96
+ yield frame.event === "comment"
97
+ ? { type: "heartbeat" }
98
+ : JSON.parse(frame.data);
99
+ }
100
+ }
101
+ /**
102
+ * Server-Sent Events framing. A chunk of only comments (the server's `: heartbeat`)
103
+ * yields `{event: "comment"}` so callers can tell the connection is alive; `retry:` and
104
+ * empty chunks are skipped.
105
+ */
106
+ export async function* parseSse(body) {
107
+ const reader = body.getReader();
108
+ const decoder = new TextDecoder();
109
+ let buffer = "";
110
+ try {
111
+ for (;;) {
112
+ const { value, done } = await reader.read();
113
+ if (done)
114
+ return;
115
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
116
+ let cut = buffer.indexOf("\n\n");
117
+ while (cut >= 0) {
118
+ const chunk = buffer.slice(0, cut);
119
+ buffer = buffer.slice(cut + 2);
120
+ const frame = { event: "message", data: "" };
121
+ const data = [];
122
+ let comment = false;
123
+ for (const line of chunk.split("\n")) {
124
+ if (line.startsWith(":"))
125
+ comment = true;
126
+ if (!line || line.startsWith(":"))
127
+ continue;
128
+ const colon = line.indexOf(":");
129
+ const field = colon < 0 ? line : line.slice(0, colon);
130
+ const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
131
+ if (field === "event")
132
+ frame.event = value;
133
+ else if (field === "id")
134
+ frame.id = value;
135
+ else if (field === "data")
136
+ data.push(value);
137
+ }
138
+ frame.data = data.join("\n");
139
+ if (data.length)
140
+ yield frame;
141
+ else if (comment)
142
+ yield { event: "comment", data: "" };
143
+ cut = buffer.indexOf("\n\n");
144
+ }
145
+ }
146
+ }
147
+ finally {
148
+ reader.releaseLock();
149
+ await body.cancel().catch(() => { });
150
+ }
151
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Host credentials in `~/.agent-center/host.json` (mode 0600, directory 0700).
3
+ *
4
+ * The token is the host's own credential and carries `agent:host` (claim turns, write
5
+ * replies, keep memory files). Per-agent tokens (`agents[].token` in the same file, see
6
+ * config.ts: what an agent's tools use to call Center) are separate, narrower credentials
7
+ * and must never carry `agent:host`: an agent that could act as a host could claim other
8
+ * agents' turns and write their replies.
9
+ */
10
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ export function configPath(env = process.env) {
14
+ return (env.AGENT_CENTER_HOST_CONFIG ??
15
+ join(homedir(), ".agent-center", "host.json"));
16
+ }
17
+ export async function loadCredentials(path = configPath()) {
18
+ let text;
19
+ try {
20
+ text = await readFile(path, "utf8");
21
+ }
22
+ catch (error) {
23
+ if (error.code === "ENOENT")
24
+ return null;
25
+ throw error;
26
+ }
27
+ const value = JSON.parse(text);
28
+ if (typeof value.server !== "string" || typeof value.accessToken !== "string")
29
+ throw new Error(`${path} is not a host credential file`);
30
+ return value;
31
+ }
32
+ /**
33
+ * Atomic write; the file is created 0600 and never exists with wider permissions. Other
34
+ * settings in the file (runtime, agents, … see config.ts) are kept.
35
+ */
36
+ export async function saveCredentials(credentials, path = configPath()) {
37
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
38
+ let existing = {};
39
+ try {
40
+ existing = JSON.parse(await readFile(path, "utf8"));
41
+ }
42
+ catch { }
43
+ const merged = { ...existing, ...credentials };
44
+ const temporary = `${path}.${process.pid}.tmp`;
45
+ await writeFile(temporary, `${JSON.stringify(merged, null, 2)}\n`, {
46
+ mode: 0o600,
47
+ });
48
+ await chmod(temporary, 0o600);
49
+ await rename(temporary, path);
50
+ }
51
+ export const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
52
+ /**
53
+ * Hands out a valid bearer token. OAuth credentials are refreshed shortly before they
54
+ * expire (and once on a 401); rotated tokens are persisted through `save`.
55
+ */
56
+ export class TokenSource {
57
+ credentials;
58
+ refreshing = null;
59
+ save;
60
+ fetcher;
61
+ constructor(credentials, options = {}) {
62
+ this.credentials = credentials;
63
+ this.save = options.save ?? (async () => { });
64
+ this.fetcher = options.fetch ?? fetch;
65
+ }
66
+ get server() {
67
+ return this.credentials.server;
68
+ }
69
+ get canRefresh() {
70
+ return !!(this.credentials.refreshToken && this.credentials.clientId);
71
+ }
72
+ async token() {
73
+ const expires = this.credentials.accessExpiresAt;
74
+ if (this.canRefresh &&
75
+ expires !== undefined &&
76
+ expires - Date.now() < 60_000)
77
+ await this.refresh();
78
+ return this.credentials.accessToken;
79
+ }
80
+ /** After a 401: refresh if possible. Returns whether a retry may help. */
81
+ async rejected() {
82
+ if (!this.canRefresh)
83
+ return false;
84
+ await this.refresh();
85
+ return true;
86
+ }
87
+ refresh() {
88
+ this.refreshing ??= this.doRefresh().finally(() => {
89
+ this.refreshing = null;
90
+ });
91
+ return this.refreshing;
92
+ }
93
+ async doRefresh() {
94
+ const c = this.credentials;
95
+ const response = await this.fetcher(`${c.server}/oauth/token`, {
96
+ method: "POST",
97
+ headers: { "content-type": "application/x-www-form-urlencoded" },
98
+ body: new URLSearchParams({
99
+ grant_type: "refresh_token",
100
+ client_id: c.clientId ?? "",
101
+ refresh_token: c.refreshToken ?? "",
102
+ }).toString(),
103
+ });
104
+ const data = (await response.json().catch(() => ({})));
105
+ if (!response.ok ||
106
+ typeof data.access_token !== "string" ||
107
+ typeof data.refresh_token !== "string")
108
+ throw new Error(`Token refresh failed (${String(data.error ?? response.status)}); run agent-host login again`);
109
+ this.credentials = {
110
+ ...c,
111
+ accessToken: data.access_token,
112
+ refreshToken: data.refresh_token,
113
+ accessExpiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000,
114
+ };
115
+ await this.save(this.credentials);
116
+ }
117
+ }