Skip to content

Commit e7d88f2

Browse files
committed
Add gateway local runtime smoke diagnostics
1 parent b5394d7 commit e7d88f2

File tree

12 files changed

+889
-23
lines changed

12 files changed

+889
-23
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Local Gateway Plugin Loader Bug
2+
3+
OpenCode `1.2.20` does not reliably load the repo-local gateway plugin when the plugin is configured with a local `file:` spec.
4+
5+
## Reproduction
6+
7+
Run:
8+
9+
```bash
10+
python3 scripts/gateway_local_plugin_runtime_smoke.py --mode both --output json
11+
```
12+
13+
This exercises two local plugin forms against a wrapped `opencode serve` instance:
14+
15+
- `path`: `file:{env:HOME}/.config/opencode/my_opencode/plugin/gateway-core`
16+
- `tarball`: `file:/.../plugin/gateway-core/my_opencode-gateway-core-0.1.1.tgz`
17+
18+
## Observed failures
19+
20+
- `path` mode installs successfully, then fails to resolve the plugin module from the cache path.
21+
- `tarball` mode fails during install because the runtime appends `@latest` to the tarball `file:` spec.
22+
- After the tarball install failure, the runtime falls back to the repo-level path plugin from `opencode.json`, which then reproduces the same path-resolution failure.
23+
24+
## Evidence
25+
26+
Run the smoke test and inspect the artifact directory printed in the JSON output under `.opencode/runtime-plugin-smoke/`.
27+
28+
Key log lines to expect:
29+
30+
- Path mode logs a `file:/.../plugin/gateway-core@latest` install, then `Cannot find module ...node_modules/file:/.../plugin/gateway-core`.
31+
- Tarball mode logs a `file:/...my_opencode-gateway-core-0.1.1.tgz@latest` install attempt and fails with exit code `1`.
32+
- Tarball mode then falls back to the repo path plugin and reproduces the same `Cannot find module` resolution failure.
33+
34+
## Impact
35+
36+
- Patched gateway runtime code in this repo cannot be validated end-to-end through `opencode serve` using local plugin specs.
37+
- Gateway audit evidence such as `gateway_runtime_bootstrap` is absent because the patched plugin never loads.
38+
39+
## Expected behavior
40+
41+
- Local `file:` directory plugins should load after install without resolving through an invalid `node_modules/file:/...` module path.
42+
- Local `file:` tarball plugins should install exactly as specified, without appending `@latest`.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"llmDecisionRuntime": {
3+
"enabled": true,
4+
"mode": "assist",
5+
"hookModes": {
6+
"task-resume-info": "assist",
7+
"todo-continuation-enforcer": "assist"
8+
},
9+
"command": "opencode",
10+
"model": "openai/gpt-5.1-codex-mini",
11+
"timeoutMs": 30000,
12+
"maxPromptChars": 1200,
13+
"maxContextChars": 2400,
14+
"enableCache": true,
15+
"cacheTtlMs": 300000,
16+
"maxCacheEntries": 256
17+
}
18+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
11
import { type GatewayConfig } from "./schema.js";
2+
export interface GatewayConfigSourceMeta {
3+
sidecarPath: string;
4+
sidecarExists: boolean;
5+
sidecarLoaded: boolean;
6+
sidecarError?: string;
7+
}
8+
export declare function loadGatewayConfigSourceWithMeta(directory: string, source: unknown): {
9+
source: Record<string, unknown>;
10+
meta: GatewayConfigSourceMeta;
11+
};
212
export declare function loadGatewayConfigSource(directory: string, source: unknown): Record<string, unknown>;
313
export declare function loadGatewayConfig(raw: unknown): GatewayConfig;

plugin/gateway-core/dist/config/load.js

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync } from "node:fs";
22
import { homedir } from "node:os";
3-
import { join, resolve } from "node:path";
3+
import { dirname, join, resolve } from "node:path";
4+
import { fileURLToPath } from "node:url";
45
import { DEFAULT_GATEWAY_CONFIG } from "./schema.js";
56
// Coerces unknown value into a normalized string array.
67
function stringList(value) {
@@ -42,26 +43,50 @@ function resolveGatewayConfigSidecarPath(directory) {
4243
if (existsSync(localPath)) {
4344
return localPath;
4445
}
45-
return join(homedir(), ".config", "opencode", "my_opencode", "gateway-core.config.json");
46+
const homeDir = String(process.env.HOME ?? "").trim() || homedir();
47+
const homePath = join(homeDir, ".config", "opencode", "my_opencode", "gateway-core.config.json");
48+
if (existsSync(homePath)) {
49+
return homePath;
50+
}
51+
return resolveBundledGatewayConfigPath();
4652
}
47-
export function loadGatewayConfigSource(directory, source) {
53+
function resolveBundledGatewayConfigPath() {
54+
const modulePath = fileURLToPath(import.meta.url);
55+
const packageRoot = resolve(dirname(modulePath), "..", "..");
56+
return join(packageRoot, "config", "default-gateway-core.config.json");
57+
}
58+
export function loadGatewayConfigSourceWithMeta(directory, source) {
4859
const sidecarPath = resolveGatewayConfigSidecarPath(directory);
60+
const meta = {
61+
sidecarPath,
62+
sidecarExists: existsSync(sidecarPath),
63+
sidecarLoaded: false,
64+
};
4965
let sidecar = {};
5066
try {
51-
if (existsSync(sidecarPath)) {
67+
if (meta.sidecarExists) {
5268
const parsed = JSON.parse(readFileSync(sidecarPath, "utf-8"));
5369
if (isRecord(parsed)) {
5470
sidecar = parsed;
71+
meta.sidecarLoaded = true;
72+
}
73+
else {
74+
meta.sidecarError = "sidecar_not_object";
5575
}
5676
}
5777
}
58-
catch {
78+
catch (error) {
79+
meta.sidecarError =
80+
error instanceof Error ? error.message : String(error ?? "unknown_error");
5981
sidecar = {};
6082
}
6183
if (!isRecord(source)) {
62-
return sidecar;
84+
return { source: sidecar, meta };
6385
}
64-
return deepMergeRecords(source, sidecar);
86+
return { source: deepMergeRecords(sidecar, source), meta };
87+
}
88+
export function loadGatewayConfigSource(directory, source) {
89+
return loadGatewayConfigSourceWithMeta(directory, source).source;
6590
}
6691
function parseAgentPolicyOverrides(value, fallback) {
6792
if (!value || typeof value !== "object") {

plugin/gateway-core/dist/index.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { loadGatewayConfig, loadGatewayConfigSource } from "./config/load.js";
1+
import { loadGatewayConfig, loadGatewayConfigSourceWithMeta, } from "./config/load.js";
22
import { writeGatewayEventAudit } from "./audit/event-audit.js";
33
import { createAutopilotLoopHook } from "./hooks/autopilot-loop/index.js";
44
import { createAutoSlashCommandHook } from "./hooks/auto-slash-command/index.js";
@@ -150,7 +150,8 @@ function configuredHooks(ctx) {
150150
const directory = typeof ctx.directory === "string" && ctx.directory.trim()
151151
? ctx.directory
152152
: process.cwd();
153-
const cfg = loadGatewayConfig(loadGatewayConfigSource(directory, ctx.config));
153+
const loadedConfig = loadGatewayConfigSourceWithMeta(directory, ctx.config);
154+
const cfg = loadGatewayConfig(loadedConfig.source);
154155
if (isLlmDecisionChildProcess()) {
155156
writeGatewayEventAudit(directory, {
156157
hook: "gateway-core",
@@ -160,6 +161,37 @@ function configuredHooks(ctx) {
160161
});
161162
return [];
162163
}
164+
writeGatewayEventAudit(directory, {
165+
hook: "gateway-core",
166+
stage: "state",
167+
reason_code: "gateway_runtime_bootstrap",
168+
sidecar_path: loadedConfig.meta.sidecarPath,
169+
sidecar_exists: loadedConfig.meta.sidecarExists,
170+
sidecar_loaded: loadedConfig.meta.sidecarLoaded,
171+
sidecar_error: loadedConfig.meta.sidecarError,
172+
session_recovery_enabled: cfg.sessionRecovery.enabled,
173+
session_recovery_auto_resume: cfg.sessionRecovery.autoResume,
174+
task_resume_info_enabled: cfg.taskResumeInfo.enabled,
175+
todo_continuation_enforcer_enabled: cfg.todoContinuationEnforcer.enabled,
176+
llm_decision_enabled: cfg.llmDecisionRuntime.enabled,
177+
llm_decision_mode: cfg.llmDecisionRuntime.mode,
178+
llm_decision_hook_modes: {
179+
taskResumeInfo: cfg.llmDecisionRuntime.hookModes[GATEWAY_LLM_DECISION_RUNTIME_BINDINGS.taskResumeInfo] ?? cfg.llmDecisionRuntime.mode,
180+
todoContinuationEnforcer: cfg.llmDecisionRuntime.hookModes[GATEWAY_LLM_DECISION_RUNTIME_BINDINGS.todoContinuationEnforcer] ?? cfg.llmDecisionRuntime.mode,
181+
},
182+
});
183+
if (cfg.todoContinuationEnforcer.enabled &&
184+
(!cfg.llmDecisionRuntime.enabled || cfg.llmDecisionRuntime.mode === "disabled")) {
185+
writeGatewayEventAudit(directory, {
186+
hook: "gateway-core",
187+
stage: "skip",
188+
reason_code: "continuation_llm_runtime_inactive",
189+
sidecar_path: loadedConfig.meta.sidecarPath,
190+
sidecar_exists: loadedConfig.meta.sidecarExists,
191+
sidecar_loaded: loadedConfig.meta.sidecarLoaded,
192+
sidecar_error: loadedConfig.meta.sidecarError,
193+
});
194+
}
163195
const llmDecisionRuntimeForHook = (hookId) => (ctx.createLlmDecisionRuntime ?? createLlmDecisionRuntime)({
164196
directory,
165197
config: resolveLlmDecisionRuntimeConfigForHook(cfg.llmDecisionRuntime, hookId),

plugin/gateway-core/src/config/load.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync } from "node:fs";
22
import { homedir } from "node:os";
3-
import { join, resolve } from "node:path";
3+
import { dirname, join, resolve } from "node:path";
4+
import { fileURLToPath } from "node:url";
45

56
import { DEFAULT_GATEWAY_CONFIG, type GatewayConfig } from "./schema.js";
67

@@ -52,35 +53,70 @@ function resolveGatewayConfigSidecarPath(directory: string): string {
5253
if (existsSync(localPath)) {
5354
return localPath;
5455
}
55-
return join(
56-
homedir(),
56+
const homeDir = String(process.env.HOME ?? "").trim() || homedir();
57+
const homePath = join(
58+
homeDir,
5759
".config",
5860
"opencode",
5961
"my_opencode",
6062
"gateway-core.config.json",
6163
);
64+
if (existsSync(homePath)) {
65+
return homePath;
66+
}
67+
return resolveBundledGatewayConfigPath();
6268
}
6369

64-
export function loadGatewayConfigSource(
70+
function resolveBundledGatewayConfigPath(): string {
71+
const modulePath = fileURLToPath(import.meta.url);
72+
const packageRoot = resolve(dirname(modulePath), "..", "..");
73+
return join(packageRoot, "config", "default-gateway-core.config.json");
74+
}
75+
76+
export interface GatewayConfigSourceMeta {
77+
sidecarPath: string;
78+
sidecarExists: boolean;
79+
sidecarLoaded: boolean;
80+
sidecarError?: string;
81+
}
82+
83+
export function loadGatewayConfigSourceWithMeta(
6584
directory: string,
6685
source: unknown,
67-
): Record<string, unknown> {
86+
): { source: Record<string, unknown>; meta: GatewayConfigSourceMeta } {
6887
const sidecarPath = resolveGatewayConfigSidecarPath(directory);
88+
const meta: GatewayConfigSourceMeta = {
89+
sidecarPath,
90+
sidecarExists: existsSync(sidecarPath),
91+
sidecarLoaded: false,
92+
};
6993
let sidecar: Record<string, unknown> = {};
7094
try {
71-
if (existsSync(sidecarPath)) {
95+
if (meta.sidecarExists) {
7296
const parsed = JSON.parse(readFileSync(sidecarPath, "utf-8")) as unknown;
7397
if (isRecord(parsed)) {
7498
sidecar = parsed;
99+
meta.sidecarLoaded = true;
100+
} else {
101+
meta.sidecarError = "sidecar_not_object";
75102
}
76103
}
77-
} catch {
104+
} catch (error) {
105+
meta.sidecarError =
106+
error instanceof Error ? error.message : String(error ?? "unknown_error");
78107
sidecar = {};
79108
}
80109
if (!isRecord(source)) {
81-
return sidecar;
110+
return { source: sidecar, meta };
82111
}
83-
return deepMergeRecords(source, sidecar);
112+
return { source: deepMergeRecords(sidecar, source), meta };
113+
}
114+
115+
export function loadGatewayConfigSource(
116+
directory: string,
117+
source: unknown,
118+
): Record<string, unknown> {
119+
return loadGatewayConfigSourceWithMeta(directory, source).source;
84120
}
85121

86122
function parseAgentPolicyOverrides(

plugin/gateway-core/src/index.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { loadGatewayConfig, loadGatewayConfigSource } from "./config/load.js";
1+
import {
2+
loadGatewayConfig,
3+
loadGatewayConfigSourceWithMeta,
4+
} from "./config/load.js";
25
import { writeGatewayEventAudit } from "./audit/event-audit.js";
36
import { createAutopilotLoopHook } from "./hooks/autopilot-loop/index.js";
47
import { createAutoSlashCommandHook } from "./hooks/auto-slash-command/index.js";
@@ -307,7 +310,8 @@ function configuredHooks(ctx: GatewayContext): GatewayHook[] {
307310
typeof ctx.directory === "string" && ctx.directory.trim()
308311
? ctx.directory
309312
: process.cwd();
310-
const cfg = loadGatewayConfig(loadGatewayConfigSource(directory, ctx.config));
313+
const loadedConfig = loadGatewayConfigSourceWithMeta(directory, ctx.config);
314+
const cfg = loadGatewayConfig(loadedConfig.source);
311315
if (isLlmDecisionChildProcess()) {
312316
writeGatewayEventAudit(directory, {
313317
hook: "gateway-core",
@@ -317,6 +321,45 @@ function configuredHooks(ctx: GatewayContext): GatewayHook[] {
317321
});
318322
return [];
319323
}
324+
writeGatewayEventAudit(directory, {
325+
hook: "gateway-core",
326+
stage: "state",
327+
reason_code: "gateway_runtime_bootstrap",
328+
sidecar_path: loadedConfig.meta.sidecarPath,
329+
sidecar_exists: loadedConfig.meta.sidecarExists,
330+
sidecar_loaded: loadedConfig.meta.sidecarLoaded,
331+
sidecar_error: loadedConfig.meta.sidecarError,
332+
session_recovery_enabled: cfg.sessionRecovery.enabled,
333+
session_recovery_auto_resume: cfg.sessionRecovery.autoResume,
334+
task_resume_info_enabled: cfg.taskResumeInfo.enabled,
335+
todo_continuation_enforcer_enabled: cfg.todoContinuationEnforcer.enabled,
336+
llm_decision_enabled: cfg.llmDecisionRuntime.enabled,
337+
llm_decision_mode: cfg.llmDecisionRuntime.mode,
338+
llm_decision_hook_modes: {
339+
taskResumeInfo:
340+
cfg.llmDecisionRuntime.hookModes[
341+
GATEWAY_LLM_DECISION_RUNTIME_BINDINGS.taskResumeInfo
342+
] ?? cfg.llmDecisionRuntime.mode,
343+
todoContinuationEnforcer:
344+
cfg.llmDecisionRuntime.hookModes[
345+
GATEWAY_LLM_DECISION_RUNTIME_BINDINGS.todoContinuationEnforcer
346+
] ?? cfg.llmDecisionRuntime.mode,
347+
},
348+
});
349+
if (
350+
cfg.todoContinuationEnforcer.enabled &&
351+
(!cfg.llmDecisionRuntime.enabled || cfg.llmDecisionRuntime.mode === "disabled")
352+
) {
353+
writeGatewayEventAudit(directory, {
354+
hook: "gateway-core",
355+
stage: "skip",
356+
reason_code: "continuation_llm_runtime_inactive",
357+
sidecar_path: loadedConfig.meta.sidecarPath,
358+
sidecar_exists: loadedConfig.meta.sidecarExists,
359+
sidecar_loaded: loadedConfig.meta.sidecarLoaded,
360+
sidecar_error: loadedConfig.meta.sidecarError,
361+
});
362+
}
320363
const llmDecisionRuntimeForHook = (hookId: string): LlmDecisionRuntime =>
321364
(ctx.createLlmDecisionRuntime ?? createLlmDecisionRuntime)({
322365
directory,

0 commit comments

Comments
 (0)