opencode-agentrouter-auth 0.0.0-stage → 0.1.6

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.
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { randomUUID } from "node:crypto";
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ import { setTimeout as sleep } from "node:timers/promises";
11
+
12
+ const DAY = 24 * 60 * 60 * 1000;
13
+ const REPO = path.dirname(fileURLToPath(import.meta.url));
14
+ const RESULTS = new Set(["claimed", "done", "failed", "ineligible"]);
15
+ class LocalError extends Error {
16
+ constructor(code) { super(code); this.code = code; }
17
+ }
18
+ const fail = (code) => { throw new LocalError(code); };
19
+ const inside = (parent, child) => {
20
+ const relative = path.relative(parent, child);
21
+ return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
22
+ };
23
+
24
+ export function defaultHome() {
25
+ return process.platform === "win32"
26
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "AgentRouterCheckin")
27
+ : path.join(os.homedir(), ".local", "share", "agentrouter-checkin");
28
+ }
29
+
30
+ async function outsideGit(directory) {
31
+ for (let current = directory; ; current = path.dirname(current)) {
32
+ try { await fs.lstat(path.join(current, ".git")); fail("UNSAFE_HOME"); }
33
+ catch (error) { if (error.code !== "ENOENT") throw error; }
34
+ if (current === path.dirname(current)) return;
35
+ }
36
+ }
37
+
38
+ async function windowsPermissions(home, initialize) {
39
+ const system = path.join(process.env.SystemRoot || "C:/Windows", "System32");
40
+ const exec = promisify(execFile);
41
+ const options = { timeout: 15000, windowsHide: true, maxBuffer: 64 * 1024 };
42
+ let temporary;
43
+ try {
44
+ const identity = await exec(path.join(system, "whoami.exe"), ["/user", "/fo", "csv", "/nh"], options);
45
+ const sid = identity.stdout.match(/S-1-5-(?:[0-9]+-)*[0-9]+/)?.[0];
46
+ if (!sid) fail("UNSAFE_WINDOWS_PERMISSIONS");
47
+ const icacls = path.join(system, "icacls.exe");
48
+ if (initialize) {
49
+ await exec(icacls, [home, "/grant:r", "*" + sid + ":(OI)(CI)F", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F", "/inheritance:r"], options);
50
+ }
51
+ // Native signed Windows utilities only; no execution-policy bypass or scripts.
52
+ // /save exports security descriptors, never file contents or credentials.
53
+ temporary = await fs.mkdtemp(path.join(os.tmpdir(), "ar-acl-"));
54
+ const saved = path.join(temporary, "permissions.txt");
55
+ const targets = [home];
56
+ for (const name of ["config.json", "github-cookie.txt", "state.json"]) {
57
+ const file = path.join(home, name);
58
+ try { await fs.lstat(file); targets.push(file); }
59
+ catch (error) { if (error.code !== "ENOENT") throw error; }
60
+ }
61
+ for (const target of targets) {
62
+ await fs.rm(saved, { force: true });
63
+ await exec(icacls, [target, "/save", saved], options);
64
+ const descriptor = (await fs.readFile(saved, "utf16le")).split(/\r?\n/).find(line => line.startsWith("D:"));
65
+ if (!descriptor) fail("UNSAFE_WINDOWS_PERMISSIONS");
66
+ let currentUserAllowed = false;
67
+ for (const match of descriptor.matchAll(/\(([^()]*)\)/g)) {
68
+ const [type, flags, rights, object, inherited, identity] = match[1].split(";");
69
+ if (type === "D") continue;
70
+ if (type !== "A" || object || inherited || ![sid, "BA", "SY", "S-1-5-18", "S-1-5-32-544"].includes(identity)) fail("UNSAFE_WINDOWS_PERMISSIONS");
71
+ if (identity === sid && rights === "FA" && !flags.includes("IO")) currentUserAllowed = true;
72
+ }
73
+ if (!currentUserAllowed) fail("UNSAFE_WINDOWS_PERMISSIONS");
74
+ }
75
+ } catch { fail("UNSAFE_WINDOWS_PERMISSIONS"); }
76
+ finally { if (temporary) await fs.rm(temporary, { recursive: true, force: true }); }
77
+ }
78
+
79
+ async function homePath(home, create = false) {
80
+ const resolved = path.resolve(home);
81
+ if (inside(REPO, resolved) || resolved === path.parse(resolved).root) fail("UNSAFE_HOME");
82
+ await outsideGit(resolved);
83
+ if (create) await fs.mkdir(resolved, { recursive: true, mode: 0o700 });
84
+ const stat = await fs.lstat(resolved);
85
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail("UNSAFE_HOME");
86
+ const canonical = await fs.realpath(resolved);
87
+ if (inside(await fs.realpath(REPO), canonical)) fail("UNSAFE_HOME");
88
+ await outsideGit(canonical); // Also checks a repository reached through a parent junction.
89
+ if (process.platform === "win32") await windowsPermissions(canonical, create);
90
+ else if (stat.mode & 0o077) fail("UNSAFE_HOME_PERMISSIONS");
91
+ return canonical;
92
+ }
93
+
94
+ async function readLocal(file, optional = false) {
95
+ let info;
96
+ try { info = await fs.lstat(file); }
97
+ catch (error) { if (optional && error.code === "ENOENT") return undefined; throw error; }
98
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail("UNSAFE_FILE");
99
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
100
+ try {
101
+ const stat = await handle.stat();
102
+ if (!stat.isFile() || stat.size > 128 * 1024) fail("INVALID_FILE");
103
+ return await handle.readFile("utf8");
104
+ } finally { await handle.close(); }
105
+ }
106
+
107
+ function parseJSON(text, code) {
108
+ try { return JSON.parse(text); } catch { fail(code); }
109
+ }
110
+
111
+ async function configuration(home) {
112
+ const config = parseJSON(await readLocal(path.join(home, "config.json")), "INVALID_CONFIG");
113
+ if (!config || config.version !== 1 || !["org", "air"].includes(config.site)
114
+ || typeof config.userId !== "string" || !/^[1-9][0-9]*$/.test(config.userId)
115
+ || Object.keys(config).some(k => !["version", "site", "userId"].includes(k))) fail("INVALID_CONFIG");
116
+ return config;
117
+ }
118
+
119
+ async function credentials(home) {
120
+ const config = await configuration(home);
121
+ const cookie = (await readLocal(path.join(home, "github-cookie.txt"))).trim();
122
+ if (!cookie) fail("NOT_CONFIGURED");
123
+ return { type: "api", metadata: { site: config.site, userId: config.userId, githubCookie: cookie } };
124
+ }
125
+
126
+ async function readState(home) {
127
+ const text = await readLocal(path.join(home, "state.json"), true);
128
+ if (text === undefined) return undefined;
129
+ const s = parseJSON(text, "INVALID_STATE");
130
+ if (!s || s.version !== 1 || typeof s.identity !== "string" || !/^(org|air):[1-9][0-9]*$/.test(s.identity)
131
+ || typeof s.paused !== "boolean" || !Number.isSafeInteger(s.lastAttemptAt) || s.lastAttemptAt < 0
132
+ || !Number.isSafeInteger(s.nextAttemptAt) || s.nextAttemptAt < 0
133
+ || (s.lastOutcome !== null && s.lastOutcome !== "interrupted" && !RESULTS.has(s.lastOutcome))
134
+ || (s.lastOutcome === null ? (s.lastAttemptAt !== 0 || s.nextAttemptAt !== 0)
135
+ : s.nextAttemptAt !== s.lastAttemptAt + DAY)) fail("INVALID_STATE");
136
+ return s;
137
+ }
138
+
139
+ async function writeState(home, state) {
140
+ // Never write the auth object, response body, OAuth code or exception text.
141
+ const temporary = path.join(home, ".state-" + randomUUID() + ".tmp");
142
+ try {
143
+ await fs.writeFile(temporary, JSON.stringify(state, null, 2) + "\n", { flag: "wx", mode: 0o600 });
144
+ await fs.rename(temporary, path.join(home, "state.json"));
145
+ } finally { await fs.rm(temporary, { force: true }); }
146
+ }
147
+
148
+ async function locked(home, action) {
149
+ const file = path.join(home, "run.lock");
150
+ let handle;
151
+ try { handle = await fs.open(file, "wx", 0o600); }
152
+ catch (error) { if (error.code === "EEXIST") return { outcome: "busy" }; throw error; }
153
+ const token = randomUUID();
154
+ try {
155
+ await handle.writeFile(JSON.stringify({ pid: process.pid, token }));
156
+ return await action();
157
+ } finally {
158
+ await handle.close();
159
+ const current = parseJSON(await readLocal(file), "LOCK_CHANGED");
160
+ if (current.token !== token) fail("LOCK_CHANGED");
161
+ await fs.unlink(file);
162
+ }
163
+ }
164
+
165
+ export async function initialize(home = defaultHome()) {
166
+ home = await homePath(home, true);
167
+ for (const [name, text] of [["config.json", JSON.stringify({ version: 1, site: "org", userId: "" }, null, 2) + "\n"], ["github-cookie.txt", ""]]) {
168
+ try { await fs.writeFile(path.join(home, name), text, { flag: "wx", mode: 0o600 }); }
169
+ catch (error) { if (error.code !== "EEXIST") throw error; await readLocal(path.join(home, name)); }
170
+ }
171
+ return { initialized: true, home };
172
+ }
173
+
174
+ export async function status(home = defaultHome()) {
175
+ home = await homePath(home);
176
+ let configured = false;
177
+ try { await credentials(home); configured = true; }
178
+ catch (error) { if (!(error instanceof LocalError) || !["NOT_CONFIGURED", "INVALID_CONFIG"].includes(error.code)) throw error; }
179
+ const state = await readState(home);
180
+ return { configured, paused: state?.paused ?? false, nextAttemptAt: state?.nextAttemptAt ?? null, lastOutcome: state?.lastOutcome ?? null };
181
+ }
182
+
183
+ async function defaultCheckin(auth) {
184
+ const { default: plugin } = await import("./index.mjs");
185
+ return (await plugin()).auth.checkin(async () => auth, {});
186
+ }
187
+
188
+ export async function runOnce(home = defaultHome(), { now = Date.now, checkin = defaultCheckin } = {}) {
189
+ home = await homePath(home);
190
+ return locked(home, async () => {
191
+ const auth = await credentials(home);
192
+ const identity = auth.metadata.site + ":" + auth.metadata.userId;
193
+ const state = await readState(home);
194
+ if (state && state.identity !== identity) return { outcome: "paused", reason: "ACCOUNT_CHANGED" };
195
+ if (state?.paused) return { outcome: "paused", reason: "MANUAL_RECOVERY_REQUIRED" };
196
+ const at = now();
197
+ if (!Number.isSafeInteger(at) || at < 0 || !Number.isSafeInteger(at + DAY)) fail("INVALID_CLOCK");
198
+ if (state && at < state.nextAttemptAt) return { outcome: "skipped", reason: "NOT_DUE" };
199
+ // Persist a fail-closed reservation BEFORE any network side effect.
200
+ const pending = { version: 1, identity, lastAttemptAt: at, nextAttemptAt: at + DAY, paused: true, lastOutcome: "interrupted" };
201
+ await writeState(home, pending);
202
+ let outcome = "failed";
203
+ try {
204
+ const result = await checkin(auth);
205
+ if (RESULTS.has(result?.outcome)) outcome = result.outcome;
206
+ } catch { /* Intentionally do not log exception text: it may contain secrets. */ }
207
+ await writeState(home, { ...pending, paused: outcome !== "claimed" && outcome !== "done", lastOutcome: outcome });
208
+ return { outcome };
209
+ });
210
+ }
211
+
212
+ export async function resume(home = defaultHome()) {
213
+ home = await homePath(home);
214
+ return locked(home, async () => {
215
+ const config = await configuration(home);
216
+ const previous = await readState(home); // Corrupt state must not be silently discarded.
217
+ const state = previous || { version: 1, lastAttemptAt: 0, nextAttemptAt: 0, lastOutcome: null };
218
+ await writeState(home, { ...state, identity: config.site + ":" + config.userId, paused: false });
219
+ return { resumed: true, networkRequested: false };
220
+ });
221
+ }
222
+
223
+ export async function watch(home = defaultHome(), { signal, report = r => console.log(JSON.stringify(r)), poll = ms => sleep(ms, undefined, { signal }) } = {}) {
224
+ let previous;
225
+ while (!signal?.aborted) {
226
+ const result = await runOnce(home);
227
+ const text = JSON.stringify(result);
228
+ if (text !== previous) report(result);
229
+ previous = text;
230
+ if (["failed", "ineligible", "paused", "busy"].includes(result.outcome)) return result;
231
+ try { await poll(60_000); }
232
+ catch (error) { if (!signal?.aborted) throw error; }
233
+ }
234
+ return { stopped: true };
235
+ }
236
+
237
+ const HELP = "Local HTTP only. No browser automation or browser-session extraction.\n" +
238
+ "Usage: node local-checkin.mjs <init|status|run|watch|resume> [--home ABSOLUTE_DIRECTORY]\n" +
239
+ "run/watch: at most one attempt per 24h; failure pauses until explicit resume.\n" +
240
+ "resume: after manual recovery only; keeps the 24h cooldown and makes no request.\n" +
241
+ "Never pass a Cookie on the command line or in chat. Data stays in your local user directory.";
242
+
243
+ async function main(argv) {
244
+ const [command = "help", ...rest] = argv;
245
+ if (["help", "--help", "-h"].includes(command)) { console.log(HELP); return; }
246
+ if (rest.length && (rest.length !== 2 || rest[0] !== "--home" || !path.isAbsolute(rest[1]))) fail("INVALID_ARGUMENTS");
247
+ const home = rest[1] || defaultHome();
248
+ const actions = { init: initialize, status, run: runOnce, resume };
249
+ let result;
250
+ if (command === "watch") {
251
+ const stop = new AbortController();
252
+ const onStop = () => stop.abort();
253
+ process.once("SIGINT", onStop);
254
+ process.once("SIGTERM", onStop);
255
+ try { result = await watch(home, { signal: stop.signal }); }
256
+ finally { process.off("SIGINT", onStop); process.off("SIGTERM", onStop); }
257
+ } else {
258
+ if (!Object.hasOwn(actions, command)) fail("INVALID_ARGUMENTS");
259
+ result = await actions[command](home);
260
+ console.log(JSON.stringify(result));
261
+ }
262
+ if (["failed", "ineligible", "paused", "busy"].includes(result?.outcome)) process.exitCode = 2;
263
+ }
264
+
265
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
266
+ main(process.argv.slice(2)).catch(error => {
267
+ console.error(JSON.stringify({ error: error instanceof LocalError ? error.code : "LOCAL_OPERATION_FAILED" }));
268
+ process.exitCode = 1;
269
+ });
270
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "opencode-agentrouter-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.6",
4
+ "description": "AgentRouter (agentrouter.org) provider for magpie and OpenCode: GitHub or API-key sign-in, configurable request headers, balance and the daily check-in",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": [
9
+ "index.mjs",
10
+ "response.mjs",
11
+ "request.mjs",
12
+ "body-inspection.mjs",
13
+ "github-cookie.mjs",
14
+ "content-filter.mjs",
15
+ "content-filter.example.json",
16
+ "local-checkin.mjs",
17
+ "LOCAL-CHECKIN.md",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ "docs/OPTIMIZATION.md",
21
+ "LICENSE"
22
+ ],
23
+ "keywords": [
24
+ "opencode",
25
+ "opencode-plugin",
26
+ "magpie",
27
+ "magpie-plugin",
28
+ "agentrouter",
29
+ "llm-gateway"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/ZhaoJun233/agentrouter-magpie-plugin.git"
35
+ },
36
+ "magpie": {
37
+ "maxConcurrency": 2
38
+ },
39
+ "scripts": {
40
+ "test": "node --test body-inspection.test.mjs usage.test.mjs loader-stream.test.mjs response-bounds.test.mjs checkin.test.mjs local-checkin.test.mjs local-checkin.integration.test.mjs request-replay.test.mjs content-filter.test.mjs auth-guidance.test.mjs auth-flow.test.mjs resource-compat.test.mjs auth-request-boundary.test.mjs response-preservation.test.mjs",
41
+ "test:headers": "python3 set-ar-headers.test.py"
42
+ }
6
43
  }
package/request.mjs ADDED
@@ -0,0 +1,94 @@
1
+ import { redactResponsesBody } from "./content-filter.mjs";
2
+ import { readCloneText } from "./body-inspection.mjs";
3
+
4
+ // Replay complete items inline instead of asking a load-balanced resource to
5
+ // retrieve them by an old item ID. Never remove call_id or opaque contents.
6
+ function isInlinePart(part) {
7
+ if (!part || typeof part !== "object") return false;
8
+ if (["input_text", "input_image", "input_file", "input_audio"].includes(part.type)) return true;
9
+ if (part.type !== "output_text" || typeof part.text !== "string") return false;
10
+ // The gateway also emits logprobs: [] on ordinary completed text. Empty
11
+ // probability metadata adds no content; populated or unknown data stays intact.
12
+ return Object.keys(part).every(key => ["type", "text", "annotations", "logprobs"].includes(key)) &&
13
+ (part.annotations == null || Array.isArray(part.annotations) && part.annotations.length === 0) &&
14
+ (!Object.hasOwn(part, "logprobs") || Array.isArray(part.logprobs) && part.logprobs.length === 0);
15
+ }
16
+
17
+ function hasInlineContent(item) {
18
+ if (!item || typeof item !== "object") return false;
19
+ switch (item.type) {
20
+ case undefined:
21
+ case "message":
22
+ return ["user", "assistant", "system", "developer"].includes(item.role) &&
23
+ Object.keys(item).every(key => ["type", "id", "role", "content", "status", "phase"].includes(key)) &&
24
+ (typeof item.content === "string" && item.content.length > 0 ||
25
+ Array.isArray(item.content) && item.content.length > 0 && item.content.every(isInlinePart));
26
+ case "function_call":
27
+ return typeof item.call_id === "string" && item.call_id.length > 0 &&
28
+ typeof item.name === "string" && item.name.length > 0 && typeof item.arguments === "string";
29
+ case "function_call_output":
30
+ return typeof item.call_id === "string" && item.call_id.length > 0 &&
31
+ (typeof item.output === "string" || Array.isArray(item.output));
32
+ // Reasoning IDs are required by the published input schema; encrypted
33
+ // state has no cross-resource portability guarantee. Preserve it intact.
34
+ case "reasoning":
35
+ return false;
36
+ default:
37
+ return false;
38
+ }
39
+ }
40
+
41
+ const MAX_BODY = 4 * 1024 * 1024;
42
+
43
+ export async function prepareResponsesRequest(input, init = {}) {
44
+ return prepareRequest(input, init);
45
+ }
46
+
47
+ export async function prepareRequest(input, init = {}, { portableResponses = true, textFilter = null } = {}) {
48
+ if (!portableResponses && !textFilter) return init;
49
+ const request = input instanceof Request ? input : null;
50
+ let pathname;
51
+ try { pathname = new URL(request ? request.url : input).pathname; } catch { return init; }
52
+ if (!pathname.endsWith("/responses") || (init.method ?? request?.method ?? "GET").toUpperCase() !== "POST") return init;
53
+ const headers = new Headers(request?.headers);
54
+ new Headers(init.headers ?? {}).forEach((v, k) => headers.set(k, v));
55
+ if (headers.has("content-encoding") && headers.get("content-encoding").toLowerCase() !== "identity") return init;
56
+ const mediaType = headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
57
+ if (mediaType && mediaType !== "application/json" && !mediaType.endsWith("+json")) return init;
58
+ let text = init.body;
59
+ if (text == null && request) text = await readCloneText(request, {
60
+ maxBytes: MAX_BODY, timeoutMs: 2000, signal: init.signal ?? request.signal,
61
+ });
62
+ if (typeof text !== "string" || text.length > MAX_BODY || new TextEncoder().encode(text).byteLength > MAX_BODY) return init;
63
+ let body;
64
+ try { body = JSON.parse(text); } catch { return init; }
65
+ if (!body || typeof body !== "object" || Array.isArray(body)) return init;
66
+ const filtered = redactResponsesBody(body, textFilter);
67
+ body = filtered.body;
68
+ const replayed = portableResponses && replayInline(body);
69
+ if (!filtered.replacements && !replayed) return init;
70
+ if (replayed) body.store = false;
71
+ for (const name of ["content-length", "content-md5", "digest", "content-digest"]) headers.delete(name);
72
+ return { ...init, body: JSON.stringify(body), headers };
73
+ }
74
+
75
+ function replayInline(body) {
76
+ // Pointers carry context we cannot retrieve. Filtering visible prose must not
77
+ // silently discard that context or turn a continuation into a new conversation.
78
+ if (!Array.isArray(body.input) || body.previous_response_id || body.conversation ||
79
+ body.input.some(item => item?.type === "item_reference")) return false;
80
+ let changed = false;
81
+ for (const item of body.input) {
82
+ if (!hasInlineContent(item) || !Object.hasOwn(item, "id")) continue;
83
+ delete item.id;
84
+ if (item.type === "message" || item.type == null) {
85
+ // Output-message input objects require an ID. Convert to the supported
86
+ // EasyInputMessage form, preserving phase and every actual text segment.
87
+ delete item.status;
88
+ if (Array.isArray(item.content)) item.content = item.content.map(part =>
89
+ part.type === "output_text" ? { type: "input_text", text: part.text } : part);
90
+ }
91
+ changed = true;
92
+ }
93
+ return changed;
94
+ }