authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-08-24 11:51:28+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 02:51:28-07:00
log58ba5594439c984b08d13b6d4efb5407adb06943
treedfc2a08eb7a8457137a53af23a2e36e6e7f546e3
parente9f50c3efcb02a199042364ead292e164274e716
signature Signed by PGP key B5690EEEBB952194

fix(server): bound OpenCode skill discovery output (#7675)

Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>

5 files changed, 145 insertions(+), 11 deletions(-)

apps/server/src/provider/Layers/OpenCodeProvider.test.ts-3
...@@ -239,19 +239,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {...@@ -239,19 +239,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
239 name: "openclaw-review",239 name: "openclaw-review",
240 description: "Review OpenClaw workflow changes.",240 description: "Review OpenClaw workflow changes.",
241 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",241 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
242 content: "---\nname: openclaw-review\n---\n",
243 },242 },
244 {243 {
245 name: "openclaw-triage",244 name: "openclaw-triage",
246 description: "Triage OpenClaw routing issues.",245 description: "Triage OpenClaw routing issues.",
247 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",246 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
248 content: "---\nname: openclaw-triage\n---\n",
249 },247 },
250 {248 {
251 name: "missing-location",249 name: "missing-location",
252 description: "This incomplete SDK row should be skipped.",250 description: "This incomplete SDK row should be skipped.",
253 location: "",251 location: "",
254 content: "---\nname: missing-location\n---\n",
255 },252 },
256 ],253 ],
257 };254 };
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+1-2
...@@ -258,7 +258,7 @@ describe("parseAgentListCliOutput", () => {...@@ -258,7 +258,7 @@ describe("parseAgentListCliOutput", () => {
258});258});
259259
260describe("parseSkillsCliOutput", () => {260describe("parseSkillsCliOutput", () => {
261 it("parses skill metadata from the CLI JSON output", () => {261 it("parses only skill metadata from the CLI JSON output", () => {
262 const result = parseSkillsCliOutput(262 const result = parseSkillsCliOutput(
263 JSON.stringify([263 JSON.stringify([
264 {264 {
...@@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => {...@@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => {
275 name: "review-pr",275 name: "review-pr",
276 description: "Review a pull request.",276 description: "Review a pull request.",
277 location: "/tmp/review-pr/SKILL.md",277 location: "/tmp/review-pr/SKILL.md",
278 content: "---\nname: review-pr\n---\n",
279 },278 },
280 ]);279 ]);
281 });280 });
apps/server/src/provider/opencodeRuntime.inventory.test.ts+125-1
...@@ -4,13 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -4,13 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
4import type { OpencodeClient } from "@opencode-ai/sdk/v2";4import type { OpencodeClient } from "@opencode-ai/sdk/v2";
5import { it } from "@effect/vitest";5import { it } from "@effect/vitest";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import {
11 HostProcessEnvironment,
12 HostProcessExecutablePath,
13 HostProcessPlatform,
14} from "@t3tools/shared/hostProcess";
815
9import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts";16import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts";
1017
11const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));18const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
1219
13it.layer(testLayer)("loadOpenCodeInventory", (it) => {20it.layer(testLayer)("OpenCodeRuntime inventory", (it) => {
14 it.effect("keeps provider inventory when skill discovery fails", () =>21 it.effect("keeps provider inventory when skill discovery fails", () =>
15 Effect.gen(function* () {22 Effect.gen(function* () {
16 const runtime = yield* OpenCodeRuntime;23 const runtime = yield* OpenCodeRuntime;
...@@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => {...@@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => {
38 NodeAssert.deepEqual(inventory.skills, []);45 NodeAssert.deepEqual(inventory.skills, []);
39 }),46 }),
40 );47 );
48
49 it.effect("keeps only SDK skill metadata in inventory", () =>
50 Effect.gen(function* () {
51 const runtime = yield* OpenCodeRuntime;
52 const client = {
53 provider: {
54 list: () =>
55 Promise.resolve({
56 data: {
57 connected: ["openai"],
58 all: [],
59 default: {},
60 },
61 }),
62 },
63 app: {
64 agents: () => Promise.resolve({ data: [] }),
65 skills: () =>
66 Promise.resolve({
67 data: [
68 {
69 name: "review",
70 description: "Review code changes",
71 location: "/skills/review/SKILL.md",
72 content: "unused skill content",
73 },
74 ],
75 }),
76 },
77 } as unknown as OpencodeClient;
78
79 const inventory = yield* runtime.loadOpenCodeInventory(client);
80
81 NodeAssert.deepEqual(inventory.skills, [
82 {
83 name: "review",
84 description: "Review code changes",
85 location: "/skills/review/SKILL.md",
86 },
87 ]);
88 }),
89 );
90
91 it.effect("drops oversized CLI skill output without losing the model inventory", () =>
92 Effect.gen(function* () {
93 const fs = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 const hostEnvironment = yield* HostProcessEnvironment;
96 const executablePath = yield* HostProcessExecutablePath;
97 const hostPlatform = yield* HostProcessPlatform;
98 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" });
99 const isWindows = hostPlatform === "win32";
100 const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode");
101 const scriptPath = path.join(tempDir, "opencode.mjs");
102 const oversizedContentBytes = 8 * 1024 * 1024 + 1;
103
104 yield* fs.writeFileString(
105 scriptPath,
106 [
107 'if (process.argv[2] === "models") {',
108 ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);',
109 '} else if (process.argv[2] === "debug") {',
110 ` const content = "x".repeat(${oversizedContentBytes});`,
111 ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);',
112 "}",
113 "",
114 ].join("\n"),
115 );
116 yield* fs.writeFileString(
117 binaryPath,
118 [
119 ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]),
120 isWindows
121 ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*'
122 : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"',
123 "",
124 ].join("\n"),
125 );
126 if (!isWindows) {
127 yield* fs.chmod(binaryPath, 0o755);
128 }
129
130 const runtime = yield* OpenCodeRuntime;
131 const inventory = yield* runtime.loadInventoryFromCli({
132 binaryPath,
133 cwd: tempDir,
134 environment: {
135 ...hostEnvironment,
136 T3_TEST_NODE_BINARY: executablePath,
137 T3_TEST_OPENCODE_SCRIPT: scriptPath,
138 },
139 });
140
141 NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]);
142 NodeAssert.equal(inventory.skills.length, 0);
143 }),
144 );
145
146 it.effect("caps and drains command stdout and stderr when requested", () =>
147 Effect.gen(function* () {
148 const runtime = yield* OpenCodeRuntime;
149 const executablePath = yield* HostProcessExecutablePath;
150 const outputBytes = 2 * 1024 * 1024;
151 const result = yield* runtime.runOpenCodeCommand({
152 binaryPath: executablePath,
153 args: [
154 "-e",
155 `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`,
156 ],
157 maxOutputBytes: 64,
158 });
159
160 NodeAssert.equal(result.stdout, "o".repeat(64));
161 NodeAssert.equal(result.stderr, "e".repeat(64));
162 NodeAssert.equal(result.code, 0);
163 }),
164 );
41});165});
apps/server/src/provider/opencodeRuntime.ts+17-4
...@@ -51,6 +51,7 @@ export function resolveOpenCodeConfigContent(...@@ -51,6 +51,7 @@ export function resolveOpenCodeConfigContent(
51const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";51const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
52const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;52const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
53const DEFAULT_HOSTNAME = "127.0.0.1";53const DEFAULT_HOSTNAME = "127.0.0.1";
54const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
54export interface OpenCodeServerProcess {55export interface OpenCodeServerProcess {
55 readonly url: string;56 readonly url: string;
56 readonly exitCode: Effect.Effect<number, never>;57 readonly exitCode: Effect.Effect<number, never>;
...@@ -124,14 +125,12 @@ export interface OpenCodeSkill {...@@ -124,14 +125,12 @@ export interface OpenCodeSkill {
124 readonly name?: string | null;125 readonly name?: string | null;
125 readonly description?: string | null;126 readonly description?: string | null;
126 readonly location?: string | null;127 readonly location?: string | null;
127 readonly content?: string | null;
128}128}
129129
130const OpenCodeSkillSchema = Schema.Struct({130const OpenCodeSkillSchema = Schema.Struct({
131 name: Schema.optionalKey(Schema.NullOr(Schema.String)),131 name: Schema.optionalKey(Schema.NullOr(Schema.String)),
132 description: Schema.optionalKey(Schema.NullOr(Schema.String)),132 description: Schema.optionalKey(Schema.NullOr(Schema.String)),
133 location: Schema.optionalKey(Schema.NullOr(Schema.String)),133 location: Schema.optionalKey(Schema.NullOr(Schema.String)),
134 content: Schema.optionalKey(Schema.NullOr(Schema.String)),
135});134});
136const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit(135const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit(
137 Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),136 Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),
...@@ -169,6 +168,7 @@ export interface OpenCodeRuntimeShape {...@@ -169,6 +168,7 @@ export interface OpenCodeRuntimeShape {
169 readonly args: ReadonlyArray<string>;168 readonly args: ReadonlyArray<string>;
170 readonly environment?: NodeJS.ProcessEnv;169 readonly environment?: NodeJS.ProcessEnv;
171 readonly cwd?: string;170 readonly cwd?: string;
171 readonly maxOutputBytes?: number;
172 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;172 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
173 readonly createOpenCodeSdkClient: (input: {173 readonly createOpenCodeSdkClient: (input: {
174 readonly baseUrl: string;174 readonly baseUrl: string;
...@@ -447,8 +447,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -447,8 +447,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
447 ...(input.environment ? { env: input.environment } : { extendEnv: true }),447 ...(input.environment ? { env: input.environment } : { extendEnv: true }),
448 }),448 }),
449 );449 );
450 const collectOptions =
451 input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes };
450 const [stdout, stderr, code] = yield* Effect.all(452 const [stdout, stderr, code] = yield* Effect.all(
451 [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode],453 [
454 collectStreamAsString(child.stdout, collectOptions),
455 collectStreamAsString(child.stderr, collectOptions),
456 child.exitCode,
457 ],
452 { concurrency: "unbounded" },458 { concurrency: "unbounded" },
453 );459 );
454 const exitCode = Number(code);460 const exitCode = Number(code);
...@@ -702,7 +708,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -702,7 +708,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
702708
703 const loadSkills = (client: OpencodeClient) =>709 const loadSkills = (client: OpencodeClient) =>
704 runOpenCodeSdk("app.skills", () => client.app.skills()).pipe(710 runOpenCodeSdk("app.skills", () => client.app.skills()).pipe(
705 Effect.map((result) => (result.data ?? []) as ReadonlyArray<OpenCodeSkill>),711 Effect.map((result) =>
712 (result.data ?? []).map((skill) => ({
713 name: skill.name,
714 ...(skill.description === undefined ? {} : { description: skill.description }),
715 location: skill.location,
716 })),
717 ),
706 Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []),718 Effect.orElseSucceed((): ReadonlyArray<OpenCodeSkill> => []),
707 );719 );
708720
...@@ -732,6 +744,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -732,6 +744,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
732 runOpenCodeCommand({744 runOpenCodeCommand({
733 binaryPath: input.binaryPath,745 binaryPath: input.binaryPath,
734 args: ["debug", "skill"],746 args: ["debug", "skill"],
747 maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES,
735 ...commandContext,748 ...commandContext,
736 }).pipe(Effect.exit);749 }).pipe(Effect.exit);
737750
apps/server/src/provider/providerSnapshot.ts+2-1
...@@ -255,5 +255,6 @@ export function buildServerProvider(input: {...@@ -255,5 +255,6 @@ export function buildServerProvider(input: {
255255
256export const collectStreamAsString = <E>(256export const collectStreamAsString = <E>(
257 stream: Stream.Stream<Uint8Array, E>,257 stream: Stream.Stream<Uint8Array, E>,
258 options?: { readonly maxBytes?: number | undefined },
258): Effect.Effect<string, E> =>259): Effect.Effect<string, E> =>
259 collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text));260 collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text));