@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 +6 -4
- package/dist/callback.d.ts +6 -4
- package/dist/callback.js +37 -55
- package/dist/index.d.ts +7 -2
- package/dist/index.js +9 -3
- package/dist/token-exchange.d.ts +10 -0
- package/dist/token-exchange.js +45 -0
- package/package.json +1 -1
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
package/dist/callback.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
export type
|
|
1
|
+
export type AuthorizationCallback = {
|
|
2
2
|
callbackUrl: string;
|
|
3
3
|
close: () => Promise<void>;
|
|
4
|
+
codeChallenge: string;
|
|
5
|
+
codeVerifier: string;
|
|
4
6
|
state: string;
|
|
5
|
-
|
|
7
|
+
waitForCode: () => Promise<string>;
|
|
6
8
|
};
|
|
7
|
-
export declare function
|
|
9
|
+
export declare function startAuthorizationCallback(options?: Readonly<{
|
|
8
10
|
timeoutMs?: number;
|
|
9
|
-
}>): Promise<
|
|
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
|
|
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
|
|
11
|
-
let
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
18
|
+
void authorizationCode.catch(() => undefined);
|
|
17
19
|
const server = createServer((request, response) => {
|
|
18
|
-
|
|
20
|
+
handleRequest(request, response, state, (code) => {
|
|
19
21
|
if (settled)
|
|
20
22
|
return;
|
|
21
23
|
settled = true;
|
|
22
24
|
clearTimeout(timeout);
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
if (
|
|
87
|
-
|
|
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
|
|
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
|
|
97
|
-
if (!
|
|
98
|
-
|
|
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(
|
|
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
|
|
2
|
+
import { type AuthorizationCallback } from "./callback.js";
|
|
3
3
|
type StartCallback = (options?: Readonly<{
|
|
4
4
|
timeoutMs?: number;
|
|
5
|
-
}>) => Promise<
|
|
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 {
|
|
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 ??
|
|
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
|
|
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;
|