@andoai/opencode 0.1.0 → 0.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/README.md CHANGED
@@ -5,15 +5,17 @@ Official Ando Wallet integration for [OpenCode](https://opencode.ai/).
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- opencode plugin --global @andoai/opencode
8
+ opencode plugin --global --force @andoai/opencode@0.2.1
9
9
  ```
10
10
 
11
11
  Then run `/connect` in OpenCode, choose **Ando**, and select **Connect Ando Wallet**. Your browser opens
12
12
  `wallet.andoai.xyz`, where you choose an existing open, funded OpenCode session or open a new one.
13
13
 
14
- The selected MPP session credential is returned directly to the OpenCode process over a one-time IPv4
15
- loopback callback. It is sent in a form POST body, never in the callback URL. OpenCode stores the credential
16
- in its local authentication store; this plugin does not add it to `opencode.json`.
14
+ The browser returns only a short-lived, single-use authorization code over the IPv4 loopback callback.
15
+ The plugin redeems that code over HTTPS using an S256 PKCE verifier that never leaves the OpenCode process.
16
+ The MPP session credential is therefore never placed in the callback URL or submitted from HTTPS to an HTTP
17
+ form. OpenCode stores the redeemed credential in its local authentication store; this plugin does not add it
18
+ to `opencode.json`.
17
19
 
18
20
  ## Provider
19
21
 
@@ -1,9 +1,11 @@
1
- export type CredentialCallback = {
1
+ export type AuthorizationCallback = {
2
2
  callbackUrl: string;
3
3
  close: () => Promise<void>;
4
+ codeChallenge: string;
5
+ codeVerifier: string;
4
6
  state: string;
5
- waitForCredential: () => Promise<string>;
7
+ waitForCode: () => Promise<string>;
6
8
  };
7
- export declare function startCredentialCallback(options?: Readonly<{
9
+ export declare function startAuthorizationCallback(options?: Readonly<{
8
10
  timeoutMs?: number;
9
- }>): Promise<CredentialCallback>;
11
+ }>): Promise<AuthorizationCallback>;
package/dist/callback.js CHANGED
@@ -1,26 +1,28 @@
1
- import { randomBytes, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
- export async function startCredentialCallback(options = {}) {
3
+ export async function startAuthorizationCallback(options = {}) {
4
4
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1_000;
5
5
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
6
6
  throw new Error("OpenCode callback timeout must be a positive integer");
7
7
  }
8
8
  const state = randomBytes(32).toString("base64url");
9
+ const codeVerifier = randomBytes(32).toString("base64url");
10
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
9
11
  let settled = false;
10
- let resolveCredential = () => undefined;
11
- let rejectCredential = () => undefined;
12
- const credential = new Promise((resolve, reject) => {
13
- resolveCredential = resolve;
14
- rejectCredential = reject;
12
+ let resolveCode = () => undefined;
13
+ let rejectCode = () => undefined;
14
+ const authorizationCode = new Promise((resolve, reject) => {
15
+ resolveCode = resolve;
16
+ rejectCode = reject;
15
17
  });
16
- void credential.catch(() => undefined);
18
+ void authorizationCode.catch(() => undefined);
17
19
  const server = createServer((request, response) => {
18
- void handleRequest(request, response, state, (token) => {
20
+ handleRequest(request, response, state, (code) => {
19
21
  if (settled)
20
22
  return;
21
23
  settled = true;
22
24
  clearTimeout(timeout);
23
- resolveCredential(token);
25
+ resolveCode(code);
24
26
  setImmediate(() => server.close());
25
27
  });
26
28
  });
@@ -44,80 +46,60 @@ export async function startCredentialCallback(options = {}) {
44
46
  if (settled)
45
47
  return;
46
48
  settled = true;
47
- rejectCredential(new Error("Ando Wallet approval timed out"));
49
+ rejectCode(new Error("Ando Wallet approval timed out"));
48
50
  server.close();
49
51
  }, timeoutMs);
50
52
  timeout.unref();
51
53
  return {
52
54
  callbackUrl,
53
- state,
54
- waitForCredential: () => credential,
55
55
  close: async () => {
56
56
  clearTimeout(timeout);
57
57
  if (!settled) {
58
58
  settled = true;
59
- rejectCredential(new Error("Ando Wallet approval was cancelled"));
59
+ rejectCode(new Error("Ando Wallet approval was cancelled"));
60
60
  }
61
61
  await closeServer(server);
62
- }
62
+ },
63
+ codeChallenge,
64
+ codeVerifier,
65
+ state,
66
+ waitForCode: () => authorizationCode
63
67
  };
64
68
  }
65
69
  const CALLBACK_PATH = "/ando/opencode/callback";
66
- const MAX_BODY_BYTES = 8_192;
67
- const MAX_TOKEN_LENGTH = 6_144;
68
- async function handleRequest(request, response, expectedState, accept) {
69
- if (request.method !== "POST") {
70
- send(response, 405, "This callback accepts POST requests only.", { allow: "POST" });
70
+ const AUTHORIZATION_CODE = /^[A-Za-z0-9_-]{43}$/u;
71
+ function handleRequest(request, response, expectedState, accept) {
72
+ if (request.method !== "GET") {
73
+ send(response, 405, "This callback accepts GET requests only.", { allow: "GET" });
71
74
  request.resume();
72
75
  return;
73
76
  }
74
- if (request.url !== CALLBACK_PATH) {
77
+ const url = new URL(request.url ?? "", "http://127.0.0.1");
78
+ if (url.pathname !== CALLBACK_PATH) {
75
79
  send(response, 404, "Callback not found.");
76
- request.resume();
77
- return;
78
- }
79
- const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase();
80
- if (contentType !== "application/x-www-form-urlencoded") {
81
- send(response, 415, "Unsupported callback content type.");
82
- request.resume();
83
80
  return;
84
81
  }
85
- const body = await readBody(request);
86
- if (body === undefined) {
87
- send(response, 413, "Callback request is too large.");
82
+ const fields = [...url.searchParams.keys()];
83
+ if (fields.length !== 2 ||
84
+ !fields.includes("code") ||
85
+ !fields.includes("state") ||
86
+ url.searchParams.getAll("code").length !== 1 ||
87
+ url.searchParams.getAll("state").length !== 1) {
88
+ send(response, 400, "The wallet authorization response is invalid.");
88
89
  return;
89
90
  }
90
- const form = new URLSearchParams(body);
91
- const state = form.get("state") ?? "";
91
+ const state = url.searchParams.get("state") ?? "";
92
92
  if (!safeEqual(state, expectedState)) {
93
93
  send(response, 403, "The wallet approval state did not match.");
94
94
  return;
95
95
  }
96
- const credential = form.get("credential") ?? "";
97
- if (!credential.startsWith("mpp_session_v1_") ||
98
- credential.length <= "mpp_session_v1_".length ||
99
- credential.length > MAX_TOKEN_LENGTH ||
100
- !/^mpp_session_v1_[A-Za-z0-9_-]+$/u.test(credential)) {
101
- send(response, 400, "The selected Ando session credential is invalid.");
96
+ const code = url.searchParams.get("code") ?? "";
97
+ if (!AUTHORIZATION_CODE.test(code)) {
98
+ send(response, 400, "The wallet authorization code is invalid.");
102
99
  return;
103
100
  }
104
101
  send(response, 200, "Ando Wallet connected. You can close this window and return to OpenCode.", {}, true);
105
- accept(credential);
106
- }
107
- async function readBody(request) {
108
- const chunks = [];
109
- let length = 0;
110
- let tooLarge = false;
111
- for await (const chunk of request) {
112
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
113
- length += buffer.length;
114
- if (length > MAX_BODY_BYTES) {
115
- tooLarge = true;
116
- continue;
117
- }
118
- chunks.push(buffer);
119
- }
120
- return tooLarge ? undefined : Buffer.concat(chunks).toString("utf8");
102
+ accept(code);
121
103
  }
122
104
  function safeEqual(actual, expected) {
123
105
  const actualBytes = Buffer.from(actual);
package/dist/index.d.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
- import { type CredentialCallback } from "./callback.js";
2
+ import { type AuthorizationCallback } from "./callback.js";
3
3
  type StartCallback = (options?: Readonly<{
4
4
  timeoutMs?: number;
5
- }>) => Promise<CredentialCallback>;
5
+ }>) => Promise<AuthorizationCallback>;
6
+ type ExchangeCode = (input: Readonly<{
7
+ code: string;
8
+ codeVerifier: string;
9
+ }>) => Promise<string>;
6
10
  export declare function createAndoOpenCodePlugin(runtime?: Readonly<{
11
+ exchangeCode?: ExchangeCode;
7
12
  startCallback?: StartCallback;
8
13
  }>): Plugin;
9
14
  declare const AndoOpenCodePlugin: Plugin;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { startCredentialCallback } from "./callback.js";
1
+ import { startAuthorizationCallback } from "./callback.js";
2
+ import { exchangeAuthorizationCode } from "./token-exchange.js";
2
3
  const WALLET_ORIGIN = "https://wallet.andoai.xyz";
3
4
  const INFERENCE_BASE_URL = "https://inference.andoai.xyz/v1/mpp";
4
5
  const MODELS = {
@@ -7,7 +8,8 @@ const MODELS = {
7
8
  "ando/nemotron-3-ultra-550b-a55b": { name: "Nemotron 3 Ultra 550B" }
8
9
  };
9
10
  export function createAndoOpenCodePlugin(runtime = {}) {
10
- const startCallback = runtime.startCallback ?? startCredentialCallback;
11
+ const startCallback = runtime.startCallback ?? startAuthorizationCallback;
12
+ const exchangeCode = runtime.exchangeCode ?? exchangeAuthorizationCode;
11
13
  return async () => {
12
14
  const pending = new Set();
13
15
  return {
@@ -24,6 +26,7 @@ export function createAndoOpenCodePlugin(runtime = {}) {
24
26
  const authorizationUrl = new URL("/integrations/opencode/authorize", WALLET_ORIGIN);
25
27
  authorizationUrl.searchParams.set("redirect_uri", callback.callbackUrl);
26
28
  authorizationUrl.searchParams.set("state", callback.state);
29
+ authorizationUrl.searchParams.set("code_challenge", callback.codeChallenge);
27
30
  return {
28
31
  method: "auto",
29
32
  url: authorizationUrl.toString(),
@@ -33,7 +36,10 @@ export function createAndoOpenCodePlugin(runtime = {}) {
33
36
  return {
34
37
  type: "success",
35
38
  provider: "ando",
36
- key: await callback.waitForCredential()
39
+ key: await exchangeCode({
40
+ code: await callback.waitForCode(),
41
+ codeVerifier: callback.codeVerifier
42
+ })
37
43
  };
38
44
  }
39
45
  catch {
@@ -0,0 +1,10 @@
1
+ type FetchLike = typeof fetch;
2
+ export declare function exchangeAuthorizationCode(input: Readonly<{
3
+ code: string;
4
+ codeVerifier: string;
5
+ }>, options?: Readonly<{
6
+ fetchImpl?: FetchLike;
7
+ timeoutMs?: number;
8
+ }>): Promise<string>;
9
+ export declare const OPEN_CODE_TOKEN_ENDPOINT = "https://ando-wallet-api-devnet.onrender.com/v1/integrations/opencode/token";
10
+ export {};
@@ -0,0 +1,45 @@
1
+ const WALLET_API_ORIGIN = "https://ando-wallet-api-devnet.onrender.com";
2
+ const TOKEN_ENDPOINT = `${WALLET_API_ORIGIN}/v1/integrations/opencode/token`;
3
+ const REQUEST_TIMEOUT_MS = 15_000;
4
+ export async function exchangeAuthorizationCode(input, options = {}) {
5
+ if (!/^[A-Za-z0-9_-]{43}$/u.test(input.code)) {
6
+ throw new Error("Ando Wallet returned an invalid authorization code");
7
+ }
8
+ if (!/^[A-Za-z0-9._~-]{43,128}$/u.test(input.codeVerifier)) {
9
+ throw new Error("OpenCode PKCE verifier is invalid");
10
+ }
11
+ const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
12
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
13
+ throw new Error("OpenCode token exchange timeout must be a positive integer");
14
+ }
15
+ const controller = new AbortController();
16
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
17
+ timeout.unref();
18
+ try {
19
+ const response = await (options.fetchImpl ?? fetch)(TOKEN_ENDPOINT, {
20
+ body: JSON.stringify(input),
21
+ cache: "no-store",
22
+ headers: {
23
+ accept: "application/json",
24
+ "content-type": "application/json"
25
+ },
26
+ method: "POST",
27
+ redirect: "error",
28
+ signal: controller.signal
29
+ });
30
+ if (!response.ok)
31
+ throw new Error("Ando Wallet authorization could not be completed");
32
+ const body = await response.json();
33
+ const credential = body.credential;
34
+ if (typeof credential !== "string" ||
35
+ !/^mpp_session_v1_[A-Za-z0-9_-]+$/u.test(credential) ||
36
+ credential.length > 6_144) {
37
+ throw new Error("Ando Wallet returned an invalid session credential");
38
+ }
39
+ return credential;
40
+ }
41
+ finally {
42
+ clearTimeout(timeout);
43
+ }
44
+ }
45
+ export const OPEN_CODE_TOKEN_ENDPOINT = TOKEN_ENDPOINT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andoai/opencode",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Official Ando Wallet integration for OpenCode",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",