@ai-sdk/harness-opencode 0.0.0-cca10482-20260708215408
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/CHANGELOG.md +182 -0
- package/LICENSE +13 -0
- package/README.md +6 -0
- package/dist/bridge/host-tool-mcp.mjs +101 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +2194 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +13 -0
- package/dist/bridge/pnpm-lock.yaml +933 -0
- package/dist/index.d.ts +177 -0
- package/dist/index.js +999 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/bridge/host-tool-mcp.ts +134 -0
- package/src/bridge/index.ts +1993 -0
- package/src/bridge/opencode-events.ts +88 -0
- package/src/bridge/opencode-path.ts +17 -0
- package/src/bridge/opencode-usage.ts +156 -0
- package/src/bridge/package.json +13 -0
- package/src/bridge/pnpm-lock.yaml +933 -0
- package/src/bridge/tool-relay-auth.ts +151 -0
- package/src/index.ts +12 -0
- package/src/opencode-auth.ts +175 -0
- package/src/opencode-bridge-protocol.ts +29 -0
- package/src/opencode-harness.ts +1020 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
|
|
4
|
+
export type ToolRelayCall = {
|
|
5
|
+
toolName: string;
|
|
6
|
+
input: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class ToolRelayAuthorizer {
|
|
10
|
+
private readonly ttlMs: number;
|
|
11
|
+
private readonly now: () => number;
|
|
12
|
+
private readonly authorizations: Array<{ key: string; expiresAt: number }> =
|
|
13
|
+
[];
|
|
14
|
+
|
|
15
|
+
constructor({
|
|
16
|
+
ttlMs = 10_000,
|
|
17
|
+
now = Date.now,
|
|
18
|
+
}: {
|
|
19
|
+
ttlMs?: number;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
} = {}) {
|
|
22
|
+
this.ttlMs = ttlMs;
|
|
23
|
+
this.now = now;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
authorizeToolCall(call: ToolRelayCall): void {
|
|
27
|
+
this.pruneExpired();
|
|
28
|
+
this.authorizations.push({
|
|
29
|
+
key: toolRelayCallKey(call),
|
|
30
|
+
expiresAt: this.now() + this.ttlMs,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
consumeToolCall(call: ToolRelayCall): boolean {
|
|
35
|
+
this.pruneExpired();
|
|
36
|
+
const key = toolRelayCallKey(call);
|
|
37
|
+
const index = this.authorizations.findIndex(auth => auth.key === key);
|
|
38
|
+
if (index === -1) return false;
|
|
39
|
+
this.authorizations.splice(index, 1);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private pruneExpired(): void {
|
|
44
|
+
const now = this.now();
|
|
45
|
+
for (let i = this.authorizations.length - 1; i >= 0; i--) {
|
|
46
|
+
if (this.authorizations[i].expiresAt <= now) {
|
|
47
|
+
this.authorizations.splice(i, 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toolRelayCallKey({ toolName, input }: ToolRelayCall): string {
|
|
54
|
+
return `${toolName}\0${canonicalJson(input ?? {})}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function canonicalJson(value: unknown): string {
|
|
58
|
+
return JSON.stringify(normalizeJsonValue(value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeJsonValue(value: unknown): unknown {
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
return value.map(normalizeJsonValue);
|
|
64
|
+
}
|
|
65
|
+
if (value && typeof value === 'object') {
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.entries(value as Record<string, unknown>)
|
|
68
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
69
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
70
|
+
.map(([key, entryValue]) => [key, normalizeJsonValue(entryValue)]),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function isToolRelayRequestFromAllowedProcess({
|
|
77
|
+
socket,
|
|
78
|
+
allowedScriptPaths,
|
|
79
|
+
}: {
|
|
80
|
+
socket: Socket;
|
|
81
|
+
allowedScriptPaths: ReadonlySet<string>;
|
|
82
|
+
}): Promise<boolean> {
|
|
83
|
+
if (process.platform !== 'linux') return false;
|
|
84
|
+
if (!socket.remotePort || !socket.localPort) return false;
|
|
85
|
+
|
|
86
|
+
const inode = await findTcpSocketInode({
|
|
87
|
+
clientPort: socket.remotePort,
|
|
88
|
+
serverPort: socket.localPort,
|
|
89
|
+
});
|
|
90
|
+
if (!inode) return false;
|
|
91
|
+
|
|
92
|
+
const cmdline = await findProcessCmdlineForSocketInode({ inode });
|
|
93
|
+
return cmdline?.some(arg => allowedScriptPaths.has(arg)) ?? false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function findTcpSocketInode({
|
|
97
|
+
clientPort,
|
|
98
|
+
serverPort,
|
|
99
|
+
}: {
|
|
100
|
+
clientPort: number;
|
|
101
|
+
serverPort: number;
|
|
102
|
+
}): Promise<string | undefined> {
|
|
103
|
+
for (const tablePath of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
104
|
+
const table = await readFile(tablePath, 'utf8').catch(() => undefined);
|
|
105
|
+
if (!table) continue;
|
|
106
|
+
for (const line of table.split('\n').slice(1)) {
|
|
107
|
+
const columns = line.trim().split(/\s+/);
|
|
108
|
+
if (columns.length < 10) continue;
|
|
109
|
+
const local = parseProcNetAddress(columns[1]);
|
|
110
|
+
const remote = parseProcNetAddress(columns[2]);
|
|
111
|
+
if (
|
|
112
|
+
local?.port === clientPort &&
|
|
113
|
+
remote?.port === serverPort &&
|
|
114
|
+
columns[9] !== '0'
|
|
115
|
+
) {
|
|
116
|
+
return columns[9];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseProcNetAddress(value: string): { port: number } | undefined {
|
|
124
|
+
const [, portHex] = value.split(':');
|
|
125
|
+
if (!portHex) return undefined;
|
|
126
|
+
return { port: Number.parseInt(portHex, 16) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function findProcessCmdlineForSocketInode({
|
|
130
|
+
inode,
|
|
131
|
+
}: {
|
|
132
|
+
inode: string;
|
|
133
|
+
}): Promise<string[] | undefined> {
|
|
134
|
+
const procEntries = await readdir('/proc', { withFileTypes: true }).catch(
|
|
135
|
+
() => [],
|
|
136
|
+
);
|
|
137
|
+
for (const entry of procEntries) {
|
|
138
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
139
|
+
const fdDir = `/proc/${entry.name}/fd`;
|
|
140
|
+
const fds = await readdir(fdDir).catch(() => []);
|
|
141
|
+
for (const fd of fds) {
|
|
142
|
+
const target = await readlink(`${fdDir}/${fd}`).catch(() => undefined);
|
|
143
|
+
if (target !== `socket:[${inode}]`) continue;
|
|
144
|
+
const cmdline = await readFile(`/proc/${entry.name}/cmdline`, 'utf8')
|
|
145
|
+
.then(value => value.split('\0').filter(Boolean))
|
|
146
|
+
.catch(() => undefined);
|
|
147
|
+
if (cmdline) return cmdline;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createOpenCode } from './opencode-harness';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default `openCode` harness instance with no overrides. Equivalent to
|
|
5
|
+
* `createOpenCode()`.
|
|
6
|
+
*/
|
|
7
|
+
export const openCode = createOpenCode();
|
|
8
|
+
|
|
9
|
+
export { createOpenCode } from './opencode-harness';
|
|
10
|
+
export { VERSION } from './version';
|
|
11
|
+
export type { OpenCodeHarnessSettings } from './opencode-harness';
|
|
12
|
+
export type { OpenCodeAuthOptions } from './opencode-auth';
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { getAiGatewayAuthFromEnv } from '@ai-sdk/harness/utils';
|
|
2
|
+
|
|
3
|
+
export type OpenCodeAuthOptions = {
|
|
4
|
+
readonly gateway?: {
|
|
5
|
+
readonly apiKey?: string;
|
|
6
|
+
readonly baseUrl?: string;
|
|
7
|
+
};
|
|
8
|
+
readonly anthropic?: {
|
|
9
|
+
readonly apiKey?: string;
|
|
10
|
+
readonly authToken?: string;
|
|
11
|
+
readonly baseUrl?: string;
|
|
12
|
+
};
|
|
13
|
+
readonly openai?: {
|
|
14
|
+
readonly apiKey?: string;
|
|
15
|
+
readonly baseUrl?: string;
|
|
16
|
+
readonly organization?: string;
|
|
17
|
+
readonly project?: string;
|
|
18
|
+
};
|
|
19
|
+
readonly openaiCompatible?: {
|
|
20
|
+
readonly apiKey?: string;
|
|
21
|
+
readonly baseUrl?: string;
|
|
22
|
+
readonly name?: string;
|
|
23
|
+
readonly queryParams?: Record<string, string>;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function resolveOpenCodeProvider({
|
|
28
|
+
model,
|
|
29
|
+
provider,
|
|
30
|
+
}: {
|
|
31
|
+
model?: string;
|
|
32
|
+
provider?: string;
|
|
33
|
+
}): 'anthropic' | 'openai' {
|
|
34
|
+
if (provider === 'anthropic' || provider === 'openai') {
|
|
35
|
+
return provider;
|
|
36
|
+
}
|
|
37
|
+
if (model?.includes('/')) {
|
|
38
|
+
const [modelProvider] = model.split('/');
|
|
39
|
+
if (modelProvider === 'anthropic' || modelProvider === 'openai') {
|
|
40
|
+
return modelProvider;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return 'anthropic';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function splitOpenCodeModel(
|
|
47
|
+
model: string | undefined,
|
|
48
|
+
provider: string | undefined,
|
|
49
|
+
): { providerID?: string; modelID?: string; model?: string } {
|
|
50
|
+
if (!model) return {};
|
|
51
|
+
if (model.includes('/')) {
|
|
52
|
+
const [providerID, ...rest] = model.split('/');
|
|
53
|
+
return {
|
|
54
|
+
providerID,
|
|
55
|
+
modelID: rest.join('/'),
|
|
56
|
+
model,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
providerID: provider,
|
|
61
|
+
modelID: model,
|
|
62
|
+
model: provider ? `${provider}/${model}` : model,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveOpenCodeEnv({
|
|
67
|
+
auth,
|
|
68
|
+
model,
|
|
69
|
+
provider,
|
|
70
|
+
processEnv = process.env,
|
|
71
|
+
}: {
|
|
72
|
+
auth: OpenCodeAuthOptions | undefined;
|
|
73
|
+
model?: string;
|
|
74
|
+
provider?: string;
|
|
75
|
+
processEnv?: Record<string, string | undefined>;
|
|
76
|
+
}): Record<string, string> {
|
|
77
|
+
const selectedProvider = resolveOpenCodeProvider({ model, provider });
|
|
78
|
+
if (auth?.openaiCompatible) {
|
|
79
|
+
return pickOpenAICompatible(auth.openaiCompatible, processEnv);
|
|
80
|
+
}
|
|
81
|
+
if (selectedProvider === 'openai') {
|
|
82
|
+
if (auth?.openai) {
|
|
83
|
+
return pickOpenAI({ explicit: auth.openai, processEnv });
|
|
84
|
+
}
|
|
85
|
+
} else if (auth?.anthropic) {
|
|
86
|
+
return pickAnthropic({ explicit: auth.anthropic, processEnv });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const gatewayAuthFromEnv = getAiGatewayAuthFromEnv({ env: processEnv });
|
|
90
|
+
if (auth?.gateway) {
|
|
91
|
+
return pickGateway({ explicit: auth.gateway, gatewayAuthFromEnv });
|
|
92
|
+
}
|
|
93
|
+
if (gatewayAuthFromEnv.apiKey) {
|
|
94
|
+
return pickGateway({ explicit: {}, gatewayAuthFromEnv });
|
|
95
|
+
}
|
|
96
|
+
return selectedProvider === 'openai'
|
|
97
|
+
? pickOpenAI({ processEnv })
|
|
98
|
+
: pickAnthropic({ processEnv });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pickOpenAICompatible(
|
|
102
|
+
explicit: NonNullable<OpenCodeAuthOptions['openaiCompatible']>,
|
|
103
|
+
processEnv: Record<string, string | undefined>,
|
|
104
|
+
): Record<string, string> {
|
|
105
|
+
const env: Record<string, string> = {};
|
|
106
|
+
const apiKey = explicit.apiKey ?? processEnv.OPENAI_API_KEY;
|
|
107
|
+
if (apiKey) env.OPENAI_API_KEY = apiKey;
|
|
108
|
+
const baseUrl = explicit.baseUrl ?? processEnv.OPENAI_BASE_URL;
|
|
109
|
+
if (baseUrl) env.OPENAI_BASE_URL = baseUrl;
|
|
110
|
+
const name = explicit.name ?? processEnv.OPENAI_NAME;
|
|
111
|
+
if (name) env.OPENAI_NAME = name;
|
|
112
|
+
if (explicit.queryParams && Object.keys(explicit.queryParams).length > 0) {
|
|
113
|
+
env.OPENAI_QUERY_PARAMS_JSON = JSON.stringify(explicit.queryParams);
|
|
114
|
+
} else if (processEnv.OPENAI_QUERY_PARAMS_JSON) {
|
|
115
|
+
env.OPENAI_QUERY_PARAMS_JSON = processEnv.OPENAI_QUERY_PARAMS_JSON;
|
|
116
|
+
}
|
|
117
|
+
return env;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pickOpenAI({
|
|
121
|
+
explicit,
|
|
122
|
+
processEnv,
|
|
123
|
+
}: {
|
|
124
|
+
explicit?: NonNullable<OpenCodeAuthOptions['openai']>;
|
|
125
|
+
processEnv: Record<string, string | undefined>;
|
|
126
|
+
}): Record<string, string> {
|
|
127
|
+
const env: Record<string, string> = {};
|
|
128
|
+
const apiKey = explicit?.apiKey ?? processEnv.OPENAI_API_KEY;
|
|
129
|
+
if (apiKey) env.OPENAI_API_KEY = apiKey;
|
|
130
|
+
const baseUrl = explicit?.baseUrl ?? processEnv.OPENAI_BASE_URL;
|
|
131
|
+
if (baseUrl) env.OPENAI_BASE_URL = baseUrl;
|
|
132
|
+
const organization = explicit?.organization ?? processEnv.OPENAI_ORGANIZATION;
|
|
133
|
+
if (organization) env.OPENAI_ORGANIZATION = organization;
|
|
134
|
+
const project = explicit?.project ?? processEnv.OPENAI_PROJECT;
|
|
135
|
+
if (project) env.OPENAI_PROJECT = project;
|
|
136
|
+
return env;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pickAnthropic({
|
|
140
|
+
explicit,
|
|
141
|
+
processEnv,
|
|
142
|
+
}: {
|
|
143
|
+
explicit?: NonNullable<OpenCodeAuthOptions['anthropic']>;
|
|
144
|
+
processEnv: Record<string, string | undefined>;
|
|
145
|
+
}): Record<string, string> {
|
|
146
|
+
const env: Record<string, string> = {};
|
|
147
|
+
const apiKey = explicit?.apiKey ?? processEnv.ANTHROPIC_API_KEY;
|
|
148
|
+
if (apiKey) env.ANTHROPIC_API_KEY = apiKey;
|
|
149
|
+
const authToken = explicit?.authToken ?? processEnv.ANTHROPIC_AUTH_TOKEN;
|
|
150
|
+
if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken;
|
|
151
|
+
const baseUrl = explicit?.baseUrl ?? processEnv.ANTHROPIC_BASE_URL;
|
|
152
|
+
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
|
|
153
|
+
return env;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function pickGateway({
|
|
157
|
+
explicit,
|
|
158
|
+
gatewayAuthFromEnv,
|
|
159
|
+
}: {
|
|
160
|
+
explicit: NonNullable<OpenCodeAuthOptions['gateway']>;
|
|
161
|
+
gatewayAuthFromEnv: ReturnType<typeof getAiGatewayAuthFromEnv>;
|
|
162
|
+
}): Record<string, string> {
|
|
163
|
+
const env: Record<string, string> = {};
|
|
164
|
+
const apiKey = explicit.apiKey ?? gatewayAuthFromEnv.apiKey;
|
|
165
|
+
if (apiKey) env.AI_GATEWAY_API_KEY = apiKey;
|
|
166
|
+
env.AI_GATEWAY_BASE_URL = toOpenCodeGatewayBaseUrl(
|
|
167
|
+
explicit.baseUrl ?? gatewayAuthFromEnv.baseUrl,
|
|
168
|
+
);
|
|
169
|
+
return env;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function toOpenCodeGatewayBaseUrl(baseUrl: string): string {
|
|
173
|
+
const trimmed = baseUrl.replace(/\/+$/, '');
|
|
174
|
+
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
|
|
175
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
harnessV1BridgeInboundCommandSchemas,
|
|
3
|
+
harnessV1BridgeOutboundMessageSchema,
|
|
4
|
+
harnessV1BridgeReadySchema,
|
|
5
|
+
harnessV1BridgeStartBaseSchema,
|
|
6
|
+
} from '@ai-sdk/harness';
|
|
7
|
+
import { z } from 'zod/v4';
|
|
8
|
+
|
|
9
|
+
export const outboundMessageSchema = harnessV1BridgeOutboundMessageSchema;
|
|
10
|
+
export type OutboundMessage = z.infer<typeof outboundMessageSchema>;
|
|
11
|
+
|
|
12
|
+
export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
|
|
13
|
+
operation: z.enum(['prompt', 'compact']).optional(),
|
|
14
|
+
provider: z.string().optional(),
|
|
15
|
+
variant: z.string().optional(),
|
|
16
|
+
instructions: z.string().optional(),
|
|
17
|
+
resumeSessionId: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type StartMessage = z.infer<typeof startMessageSchema>;
|
|
21
|
+
|
|
22
|
+
export const inboundMessageSchema = z.discriminatedUnion('type', [
|
|
23
|
+
startMessageSchema,
|
|
24
|
+
...harnessV1BridgeInboundCommandSchemas,
|
|
25
|
+
]);
|
|
26
|
+
export type InboundMessage = z.infer<typeof inboundMessageSchema>;
|
|
27
|
+
|
|
28
|
+
export const bridgeReadySchema = harnessV1BridgeReadySchema;
|
|
29
|
+
export type BridgeReady = z.infer<typeof bridgeReadySchema>;
|