opencode-anthropic-fix 2.1.1 → 2.2.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/lib/accounts.mjs CHANGED
@@ -120,6 +120,8 @@ export class AccountManager {
120
120
  #config;
121
121
  /** @type {ReturnType<typeof setTimeout> | null} */
122
122
  #saveTimeout = null;
123
+ #disposed = false;
124
+ #pendingSaves = new Set();
123
125
  /**
124
126
  * Pending stats deltas per account id, merged into disk values on save.
125
127
  * @type {Map<string, StatsDelta>}
@@ -790,6 +792,7 @@ export class AccountManager {
790
792
  * Each call resets the debounce timer so the latest state is always persisted.
791
793
  */
792
794
  requestSaveToDisk() {
795
+ if (this.#disposed) return;
793
796
  if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
794
797
  this.#saveTimeout = setTimeout(() => {
795
798
  this.#saveTimeout = null;
@@ -797,6 +800,38 @@ export class AccountManager {
797
800
  }, 1000);
798
801
  }
799
802
 
803
+ /** Transfer unsaved usage after the old manager has stopped its writes.
804
+ * Disk remains authoritative for membership, enabled state and credentials.
805
+ * @param {AccountManager | null} previous
806
+ */
807
+ adoptPendingUsageFrom(previous) {
808
+ if (!previous) return;
809
+ for (const [id, delta] of previous.#statsDeltas) {
810
+ const account = this.#accounts.find((candidate) => candidate.id === id);
811
+ if (!account) continue;
812
+ this.#statsDeltas.set(id, { ...delta });
813
+ for (const key of ["requests", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"]) {
814
+ account.stats[key] = (delta.isReset ? 0 : account.stats[key]) + delta[key];
815
+ }
816
+ if (delta.isReset) account.stats.lastReset = delta.resetTimestamp ?? account.stats.lastReset;
817
+ this.requestSaveToDisk();
818
+ }
819
+ previous.#statsDeltas.clear();
820
+ }
821
+
822
+ /** Flush pending account updates and cancel the debounce when a host unloads. */
823
+ async dispose({ flush = true } = {}) {
824
+ if (this.#disposed) return;
825
+ this.#disposed = true;
826
+ const pending = this.#saveTimeout !== null;
827
+ if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
828
+ this.#saveTimeout = null;
829
+ const saves = await Promise.allSettled(this.#pendingSaves);
830
+ const failure = saves.find((result) => result.status === "rejected");
831
+ if (pending && flush) await this.#persistToDisk();
832
+ if (failure) throw failure.reason;
833
+ }
834
+
800
835
  /**
801
836
  * Persist current state to disk immediately.
802
837
  * Stats use merge-on-save: read disk values, add this instance's deltas,
@@ -805,6 +840,17 @@ export class AccountManager {
805
840
  * @returns {Promise<void>}
806
841
  */
807
842
  async saveToDisk() {
843
+ if (this.#disposed) return;
844
+ const pending = this.#persistToDisk();
845
+ this.#pendingSaves.add(pending);
846
+ try {
847
+ await pending;
848
+ } finally {
849
+ this.#pendingSaves.delete(pending);
850
+ }
851
+ }
852
+
853
+ async #persistToDisk() {
808
854
  // Read current disk state to merge stats
809
855
  /** @type {Map<string, import('./storage.mjs').AccountMetadata> | null} */
810
856
  let diskAccountsById = null;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Parse command arguments with minimal quote support.
3
+ * Examples: a b "c d" -> ["a", "b", "c d"]; a 'c d' -> ["a", "c d"].
4
+ * Shared by slash dispatch and RPC credential redaction.
5
+ * @param {string} raw
6
+ * @returns {string[]}
7
+ */
8
+ export function parseCommandArgs(raw) {
9
+ if (!raw || !raw.trim()) return [];
10
+ const parts = [];
11
+ const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
12
+ let match;
13
+ while ((match = re.exec(raw)) !== null) {
14
+ const token = match[1] ?? match[2] ?? match[3] ?? "";
15
+ parts.push(token.replace(/\\(["'\\])/g, "$1"));
16
+ }
17
+ return parts;
18
+ }
@@ -0,0 +1,192 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { redactString } from "../redact.mjs";
3
+ import { truncateGraphemes } from "../unicode-text.mjs";
4
+ import { parseCommandArgs } from "./command-args.mjs";
5
+
6
+ const MAX_ARGUMENT_LENGTH = 8_192;
7
+ const MAX_OUTPUT_LENGTH = 65_536;
8
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
9
+
10
+ /**
11
+ * Portable RPC definition for OpenCode 2.0.21. JSON Schema is a public schema
12
+ * format; Rpc.define() is an identity helper, so no runtime SDK is required.
13
+ * Keep this module importable by both the server and the TUI.
14
+ */
15
+ export const anthropicCommandRpc = {
16
+ id: "opencode-anthropic-fix",
17
+ methods: {
18
+ execute: {
19
+ input: {
20
+ type: "object",
21
+ properties: {
22
+ // Core's portable JSON Schema converter rejects regex patterns;
23
+ // validate the identifier's alphabet in the handler instead.
24
+ sessionID: { type: "string", minLength: 1, maxLength: 128 },
25
+ arguments: { type: "string", maxLength: MAX_ARGUMENT_LENGTH },
26
+ },
27
+ required: ["sessionID", "arguments"],
28
+ additionalProperties: false,
29
+ },
30
+ output: {
31
+ type: "object",
32
+ properties: { output: { type: "string", maxLength: MAX_OUTPUT_LENGTH } },
33
+ required: ["output"],
34
+ additionalProperties: false,
35
+ },
36
+ errors: {
37
+ command_failed: { type: "null" },
38
+ invalid_session: { type: "null" },
39
+ },
40
+ },
41
+ },
42
+ events: {},
43
+ };
44
+
45
+ /** @param {unknown} input @returns {string} */
46
+ export function validateCommandArguments(input) {
47
+ if (typeof input !== "string" || input.length > MAX_ARGUMENT_LENGTH) {
48
+ throw new Error("Anthropic command arguments must be a string of at most 8192 characters.");
49
+ }
50
+ // Commands are a single CLI-style line. Reject control characters without
51
+ // echoing the input: login completion arguments may contain an OAuth code.
52
+ if ([...input].some((character) => character < " " && character !== "\t") || input.includes("\u007f")) {
53
+ throw new Error("Anthropic command arguments must not contain control characters.");
54
+ }
55
+ return input;
56
+ }
57
+
58
+ /**
59
+ * Remove credentials and terminal escape sequences before crossing the RPC/UI
60
+ * boundary. OAuth authorization URLs remain usable; submitted completion codes
61
+ * must not be echoed, even when an upstream error embeds the original input.
62
+ * @param {unknown} value
63
+ * @param {string} [args]
64
+ * @returns {string}
65
+ */
66
+ export function sanitizeCommandOutput(value, args = "") {
67
+ let output = stripVTControlCharacters(redactString(value));
68
+ const tokens = typeof args === "string" ? parseCommandArgs(args) : [];
69
+ const completion =
70
+ ["login", "reauth"].includes(tokens[0]?.toLowerCase()) && tokens[1]?.toLowerCase() === "complete"
71
+ ? tokens.slice(2).join(" ").trim()
72
+ : "";
73
+ if (completion) {
74
+ const secrets = new Set([completion, completion.split("#")[0]]);
75
+ let code;
76
+ try {
77
+ const url = new URL(completion);
78
+ code = url.searchParams.get("code");
79
+ if (code) {
80
+ // Also cover the exact encoded spelling if an error quotes only it.
81
+ const encoded = url.search.match(/(?:\?|&)code=([^&]*)/)?.[1];
82
+ if (encoded) secrets.add(encoded);
83
+ }
84
+ } catch {
85
+ // A plain code#state is not a URL; redact it as a raw completion code.
86
+ code = completion;
87
+ }
88
+ if (code) {
89
+ secrets.add(code);
90
+ secrets.add(code.split("#")[0]);
91
+ }
92
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
93
+ if (secret) output = output.split(secret).join("[redacted]");
94
+ }
95
+ }
96
+ output = output.replace(
97
+ /(["']?(?:access_?token|refresh_?token|access|refresh|client_secret|code_verifier|authorization_code)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi,
98
+ "$1[redacted]",
99
+ );
100
+ return truncateGraphemes(output, MAX_OUTPUT_LENGTH, { suffix: "\n[output truncated]" });
101
+ }
102
+
103
+ /**
104
+ * Reserve the server-side slash name for clients without the TUI extension.
105
+ * The v2 command API returns no output; running login here would lose its URL
106
+ * and pending-flow instructions. A fixed error directs callers to the RPC and
107
+ * prevents OAuth completion arguments from falling through to a model prompt.
108
+ *
109
+ * @param {{command: {transform: Function}}} context
110
+ * @returns {Promise<{dispose: () => Promise<void>}>}
111
+ */
112
+ export function registerAnthropicServerCommand(context) {
113
+ return context.command.transform((editor) => {
114
+ editor.add({
115
+ name: "anthropic",
116
+ description: "Manage Anthropic accounts through the TUI or the plugin RPC",
117
+ async execute() {
118
+ throw new Error(
119
+ "Run /anthropic in the OpenCode TUI, or call the opencode-anthropic-fix RPC execute method. " +
120
+ "The server command API cannot display administrative output.",
121
+ );
122
+ },
123
+ });
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Register the administrative command transport. Session lookup uses the
129
+ * server's scoped API, and no prompt/synthetic/message endpoint is called.
130
+ *
131
+ * @param {{rpc: {register: Function}, session: {get: Function}} context
132
+ * @param {(input: {sessionID: string, arguments: string, signal: AbortSignal}) => Promise<string>} runCommand
133
+ * @returns {Promise<{dispose: () => Promise<void>}>}
134
+ */
135
+ export async function registerAnthropicCommandRpc(context, runCommand) {
136
+ let disposed = false;
137
+ const pending = new Set();
138
+ const registration = await context.rpc.register(anthropicCommandRpc, {
139
+ execute: async (input, call) => {
140
+ if (disposed) return call.error("command_failed", "Anthropic command service is closed.", null);
141
+ let args;
142
+ try {
143
+ args = validateCommandArguments(input?.arguments);
144
+ } catch (error) {
145
+ return call.error("command_failed", error.message, null);
146
+ }
147
+ if (typeof input.sessionID !== "string" || !SESSION_ID.test(input.sessionID)) {
148
+ return call.error("invalid_session", "A valid OpenCode session is required.", null);
149
+ }
150
+
151
+ const controller = new AbortController();
152
+ const abort = () => controller.abort(call.signal.reason);
153
+ if (call.signal.aborted) abort();
154
+ else call.signal.addEventListener("abort", abort, { once: true });
155
+ pending.add(controller);
156
+ try {
157
+ controller.signal.throwIfAborted();
158
+ const session = await context.session.get({ sessionID: input.sessionID }, { signal: controller.signal });
159
+ controller.signal.throwIfAborted();
160
+ if (session?.id !== input.sessionID) {
161
+ return call.error("invalid_session", "The OpenCode session is unavailable.", null);
162
+ }
163
+ const output = await runCommand({ sessionID: input.sessionID, arguments: args, signal: controller.signal });
164
+ controller.signal.throwIfAborted();
165
+ if (typeof output !== "string") {
166
+ return call.error("command_failed", "The Anthropic command returned an invalid response.", null);
167
+ }
168
+ return { output: sanitizeCommandOutput(output, args) };
169
+ } catch (error) {
170
+ if (controller.signal.aborted) {
171
+ return call.error("command_failed", "Anthropic command cancelled.", null);
172
+ }
173
+ return call.error(
174
+ "command_failed",
175
+ sanitizeCommandOutput(error?.message || "Anthropic command failed.", args),
176
+ null,
177
+ );
178
+ } finally {
179
+ pending.delete(controller);
180
+ call.signal.removeEventListener("abort", abort);
181
+ }
182
+ },
183
+ });
184
+ return {
185
+ async dispose() {
186
+ if (disposed) return;
187
+ disposed = true;
188
+ for (const controller of pending) controller.abort();
189
+ await registration.dispose();
190
+ },
191
+ };
192
+ }