opencode-workflow-guard 1.12.1 → 1.13.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 +5 -2
- package/docs/installation.md +4 -3
- package/docs/policies.md +6 -2
- package/package.json +4 -2
- package/src/lib/guard-dispatcher.ts +6 -2
- package/src/lib/types.ts +1 -0
- package/src/lib/v2-plugin.ts +625 -0
- package/src/lib/verify.ts +11 -0
- package/src/policies/boundary.ts +16 -3
- package/src/policies/continuation.ts +20 -1
- package/src/policies/git.ts +42 -1
- package/src/policies/tamper.ts +17 -0
- package/src/workflow-guard-ui.ts +95 -14
- package/src/workflow-guard.ts +26 -5
- package/test/test-e2e.mts +129 -27
- package/test/test.mts +67 -1
|
@@ -14,6 +14,7 @@ type ContinuationState = {
|
|
|
14
14
|
generatedMessageIDs: Map<string, Set<string>>;
|
|
15
15
|
lastUserMessageIDs: Map<string, string>;
|
|
16
16
|
inFlight: Set<string>;
|
|
17
|
+
interrupted: Set<string>;
|
|
17
18
|
};
|
|
18
19
|
export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
|
|
19
20
|
const states = new WeakMap<object, Map<string, ContinuationState>>();
|
|
@@ -43,7 +44,7 @@ function getState(): ContinuationState | undefined {
|
|
|
43
44
|
const root = projectRootKey(getWorkspaceRoot());
|
|
44
45
|
let state = clientStates.get(root);
|
|
45
46
|
if (!state) {
|
|
46
|
-
state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set() };
|
|
47
|
+
state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set(), interrupted: new Set() };
|
|
47
48
|
clientStates.set(root, state);
|
|
48
49
|
}
|
|
49
50
|
return state;
|
|
@@ -56,6 +57,9 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
|
|
|
56
57
|
try {
|
|
57
58
|
const ralph = isRalphModeEnabled(getWorkspaceRoot());
|
|
58
59
|
if (ralph && state.ralphOutcomes.get(sessionID) === "user_stopped") return false;
|
|
60
|
+
// A user-initiated interrupt (Esc) suspends automatic continuation:
|
|
61
|
+
// only genuine user input re-enables it (see recordUserMessage).
|
|
62
|
+
if (state.interrupted.has(sessionID)) return false;
|
|
59
63
|
const effective = await effectiveTodosWithOwner(sessionID);
|
|
60
64
|
if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) {
|
|
61
65
|
if (ralph && state.ralphOutcomes.has(sessionID)) state.ralphOutcomes.set(sessionID, "completed");
|
|
@@ -122,12 +126,26 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
|
|
|
122
126
|
if (messageID && state.lastUserMessageIDs.get(sessionID) === messageID) return;
|
|
123
127
|
if (messageID) state.lastUserMessageIDs.set(sessionID, messageID);
|
|
124
128
|
state.counts.delete(sessionID);
|
|
129
|
+
state.interrupted.delete(sessionID);
|
|
125
130
|
if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
|
|
126
131
|
if (state.ralphOutcomes.get(sessionID) === "user_stopped") state.ralphOutcomes.delete(sessionID);
|
|
127
132
|
else state.ralphOutcomes.set(sessionID, "user_stopped");
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
|
|
136
|
+
// Record a user-initiated interrupt (Esc): the session's run was aborted
|
|
137
|
+
// from the user side, so automatic continuation must stop. Ralph runs are
|
|
138
|
+
// marked user_stopped, mirroring genuine user input. A later genuine user
|
|
139
|
+
// message re-enables continuation (recordUserMessage clears the flag).
|
|
140
|
+
export function markInterrupted(sessionID: string): void {
|
|
141
|
+
const state = getState();
|
|
142
|
+
if (!state) return;
|
|
143
|
+
state.interrupted.add(sessionID);
|
|
144
|
+
if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
|
|
145
|
+
state.ralphOutcomes.set(sessionID, "user_stopped");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
export function getRalphOutcome(sessionID: string): RalphOutcome | undefined {
|
|
132
150
|
return getState()?.ralphOutcomes.get(sessionID);
|
|
133
151
|
}
|
|
@@ -145,4 +163,5 @@ export function clearContinuationState(sessionID: string): void {
|
|
|
145
163
|
state.generatedMessageIDs.delete(sessionID);
|
|
146
164
|
state.lastUserMessageIDs.delete(sessionID);
|
|
147
165
|
state.inFlight.delete(sessionID);
|
|
166
|
+
state.interrupted.delete(sessionID);
|
|
148
167
|
}
|
package/src/policies/git.ts
CHANGED
|
@@ -123,7 +123,7 @@ export function normalizeGitCommands(command: string): string {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export const GIT_WRITE_RE =
|
|
126
|
-
/\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
|
|
126
|
+
/\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
|
|
127
127
|
|
|
128
128
|
export function currentGitBranch(root: string): string | undefined {
|
|
129
129
|
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
@@ -179,6 +179,47 @@ export function pushedProtectedBranchIn(pushCommand: string, root: string): stri
|
|
|
179
179
|
return undefined;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* True when a `git push` command pushes a tag refspec: an explicit
|
|
184
|
+
* `refs/tags/<name>` destination, or a short name that resolves to an
|
|
185
|
+
* existing tag in the repository at `root`. Publishing a tag is a release
|
|
186
|
+
* operation, not a branch mutation, so callers exempt it from the
|
|
187
|
+
* protected-branch and merged-branch push rules (#134). Deletion refspecs
|
|
188
|
+
* (`:refs/tags/<name>`, `git push --delete`, `-d`) return false so tag
|
|
189
|
+
* deletions stay blocked.
|
|
190
|
+
*/
|
|
191
|
+
export function tagRefspecIn(pushCommand: string, root: string): boolean {
|
|
192
|
+
if (/\s--delete\b|\s-d(?:\b|$)/.test(pushCommand)) return false;
|
|
193
|
+
for (const segment of splitShellSegments(pushCommand)) {
|
|
194
|
+
const parsed = parseGitInvocation(segment);
|
|
195
|
+
if (!parsed || !/\bpush\b/.test(parsed.rest)) continue;
|
|
196
|
+
const tokens = parsed.rest.split(/\s+/).slice(1);
|
|
197
|
+
let remoteSeen = false;
|
|
198
|
+
for (const rawToken of tokens) {
|
|
199
|
+
if (rawToken.startsWith("-")) continue;
|
|
200
|
+
if (!remoteSeen) {
|
|
201
|
+
remoteSeen = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const token = rawToken.replace(/^\+/, "");
|
|
205
|
+
if (!token) continue;
|
|
206
|
+
if (token.startsWith(":")) continue; // deletion refspec: empty source
|
|
207
|
+
if (token.includes(":")) {
|
|
208
|
+
if (token.includes(":refs/tags/")) return true; // <src>:refs/tags/<name>
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (token.startsWith("refs/tags/")) return true; // explicit tag refspec
|
|
212
|
+
const probe = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/tags/${token}`], {
|
|
213
|
+
cwd: root,
|
|
214
|
+
encoding: "utf8",
|
|
215
|
+
timeout: 10_000,
|
|
216
|
+
});
|
|
217
|
+
if (probe.status === 0) return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
182
223
|
export function onProtectedBranch(root: string): boolean {
|
|
183
224
|
return isProtectedBranchName(currentGitBranch(root) ?? "", root);
|
|
184
225
|
}
|
package/src/policies/tamper.ts
CHANGED
|
@@ -36,11 +36,23 @@ export function normalizeGlobPathEvasion(text: string): string {
|
|
|
36
36
|
.replace(/opencode\.[?*]/gi, ocJson);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Collaboration invocations (hosted-git/PR/issue CLIs) never write local
|
|
40
|
+
// configuration: their arguments may legitimately mention guarded paths.
|
|
41
|
+
const COLLABORATION_INVOCATION_PATTERNS: RegExp[] = [
|
|
42
|
+
/^\s*(?:gh|glab)\s+(?:issue|pr)\b/,
|
|
43
|
+
/^\s*az\s+repos\s+pr\b/,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function isCollaborationInvocation(segment: string): boolean {
|
|
47
|
+
return COLLABORATION_INVOCATION_PATTERNS.some((re) => re.test(segment));
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
export function isSettingsTamper(command: string): boolean {
|
|
40
51
|
const segments = command.split(/[\n|;&]+/).map((s) =>
|
|
41
52
|
normalizeGlobPathEvasion(normalizeShellEvasion(s)),
|
|
42
53
|
);
|
|
43
54
|
return segments.some((segment) =>
|
|
55
|
+
!isCollaborationInvocation(segment) &&
|
|
44
56
|
SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
|
|
45
57
|
);
|
|
46
58
|
}
|
|
@@ -53,6 +65,11 @@ export function isProtectedPath(targetPath: string): boolean {
|
|
|
53
65
|
const base = basename(path);
|
|
54
66
|
const lower = path.toLowerCase();
|
|
55
67
|
const dotOc = "." + "opencode/";
|
|
68
|
+
// opencode plan mode writes agent plan markdown under the project's
|
|
69
|
+
// .opencode/plans/ directory - plan files are documents, not
|
|
70
|
+
// configuration. The trailing slash keeps the plans directory
|
|
71
|
+
// itself protected.
|
|
72
|
+
if (lower.includes("/" + dotOc + "plans/")) return false;
|
|
56
73
|
const cfgOc = "/.config/" + "opencode/";
|
|
57
74
|
const cfgOcJson = "/.config/" + "opencode.json";
|
|
58
75
|
return (
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
|
|
3
|
+
*
|
|
4
|
+
* - Registers the `/guard-options` palette + slash command to toggle project options.
|
|
5
|
+
* - Renders the Workflow Guard badge in the home and prompt footer status slots.
|
|
6
|
+
*
|
|
7
|
+
* The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
11
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
2
12
|
import type { JSX } from "@opentui/solid";
|
|
3
13
|
import { createElement, insert, setProp } from "@opentui/solid";
|
|
4
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -75,6 +85,79 @@ export function formatBadge(): { text: string; isBlocked: boolean } {
|
|
|
75
85
|
return { text: BADGE_ACTIVE, isBlocked: false };
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
// ── V2 CLI plugin ─────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
type TuiContext = Plugin.Context;
|
|
91
|
+
|
|
92
|
+
const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: string }> = [
|
|
93
|
+
{ key: "recoveryCheckpoints", label: "Recovery checkpoints", description: "Toggle durable pre-run Git checkpoints" },
|
|
94
|
+
{ key: "projectMemory", label: "Project memory", description: "Toggle durable local project memory" },
|
|
95
|
+
{ key: "learning", label: "Learner mode", description: "Toggle evidence-based learning tools" },
|
|
96
|
+
{ key: "titleSettleWorkaround", label: "Title settle workaround", description: "Delay automatic continuation while OpenCode generates a session title" },
|
|
97
|
+
{ key: "ralphMode", label: "Ralph mode", description: "Opt into bounded autonomous continuation of already-owned todos" },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
101
|
+
const badge = () => {
|
|
102
|
+
const formatted = formatBadge();
|
|
103
|
+
return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const optionsRoot = () => ctx.location?.directory || process.cwd();
|
|
107
|
+
|
|
108
|
+
ctx.keymap.layer(() => ({
|
|
109
|
+
commands: [{
|
|
110
|
+
id: "workflow-guard.project-options",
|
|
111
|
+
title: "Workflow Guard: Project Options",
|
|
112
|
+
description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
|
|
113
|
+
group: "Workflow Guard",
|
|
114
|
+
palette: true,
|
|
115
|
+
slash: { name: "guard-options" },
|
|
116
|
+
async run() {
|
|
117
|
+
const root = optionsRoot();
|
|
118
|
+
for (;;) {
|
|
119
|
+
const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
|
|
120
|
+
const choice = await ctx.ui.dialog.select<ProjectToggle>({
|
|
121
|
+
title: "Workflow Guard Project Options",
|
|
122
|
+
options: TOGGLE_OPTIONS.map((option) => ({
|
|
123
|
+
title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
|
|
124
|
+
value: option.key,
|
|
125
|
+
description: option.description,
|
|
126
|
+
})),
|
|
127
|
+
});
|
|
128
|
+
if (!choice) break;
|
|
129
|
+
try {
|
|
130
|
+
const enabled = !readProjectOption(root, choice);
|
|
131
|
+
const path = writeProjectOption(root, choice, enabled);
|
|
132
|
+
ctx.ui.toast.show({
|
|
133
|
+
variant: "success",
|
|
134
|
+
title: "Workflow Guard",
|
|
135
|
+
message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
ctx.ui.toast.show({
|
|
139
|
+
variant: "error",
|
|
140
|
+
title: "Workflow Guard",
|
|
141
|
+
message: error instanceof Error ? error.message : String(error),
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}],
|
|
148
|
+
bindings: [],
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
|
|
152
|
+
ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
|
|
153
|
+
|
|
154
|
+
return () => {
|
|
155
|
+
// Slot claims and keymap layers are disposed automatically on unload.
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ── V1 TUI plugin ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
78
161
|
export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
79
162
|
api.keymap.registerLayer({
|
|
80
163
|
commands: [{
|
|
@@ -85,11 +168,6 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
85
168
|
slashName: "guard-options",
|
|
86
169
|
run() {
|
|
87
170
|
const root = api.state.path.worktree || api.state.path.directory;
|
|
88
|
-
const recovery = readProjectOption(root, "recoveryCheckpoints");
|
|
89
|
-
const memory = readProjectOption(root, "projectMemory");
|
|
90
|
-
const learning = readProjectOption(root, "learning");
|
|
91
|
-
const titleSettle = readProjectOption(root, "titleSettleWorkaround");
|
|
92
|
-
const ralphMode = readProjectOption(root, "ralphMode");
|
|
93
171
|
const toggle = (key: ProjectToggle) => {
|
|
94
172
|
const enabled = !readProjectOption(root, key);
|
|
95
173
|
const path = writeProjectOption(root, key, enabled);
|
|
@@ -99,13 +177,12 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
99
177
|
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
100
178
|
title: "Workflow Guard Project Options",
|
|
101
179
|
current: undefined,
|
|
102
|
-
options:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
],
|
|
180
|
+
options: TOGGLE_OPTIONS.map((option) => ({
|
|
181
|
+
title: `${option.label}: ${readProjectOption(root, option.key) ? "On" : "Off"}`,
|
|
182
|
+
value: option.key,
|
|
183
|
+
description: option.description,
|
|
184
|
+
onSelect: () => toggle(option.key),
|
|
185
|
+
})),
|
|
109
186
|
}));
|
|
110
187
|
},
|
|
111
188
|
}],
|
|
@@ -129,7 +206,11 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
129
206
|
});
|
|
130
207
|
};
|
|
131
208
|
|
|
209
|
+
// Default export supports BOTH generations: V1 TUI clients call tui(), V2 reads setup().
|
|
132
210
|
export default {
|
|
133
|
-
|
|
211
|
+
...Plugin.define({
|
|
212
|
+
id: "workflow-guard-ui",
|
|
213
|
+
setup: WorkflowGuardTuiV2,
|
|
214
|
+
}),
|
|
134
215
|
tui: WorkflowGuardTui,
|
|
135
|
-
} satisfies TuiPluginModule;
|
|
216
|
+
} as const satisfies TuiPluginModule;
|
package/src/workflow-guard.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* from ./lib/, and re-exports the public helper surface for tests.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { type Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
11
|
+
import { type Plugin as V1Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
12
|
+
import { Plugin } from "@opencode/plugin";
|
|
13
|
+
import { WorkflowGuardV2 } from "./lib/v2-plugin.ts";
|
|
12
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
15
|
import { resolve } from "node:path";
|
|
14
16
|
import { join } from "node:path";
|
|
@@ -188,6 +190,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
|
|
|
188
190
|
// ── Verification engine ──────────────────────────────────────────────────────
|
|
189
191
|
import {
|
|
190
192
|
detectVerifyCommand,
|
|
193
|
+
resolveVerifyTimeoutMs,
|
|
191
194
|
runVerify,
|
|
192
195
|
snipVerifyOutput,
|
|
193
196
|
getCurrentGitCommitHash,
|
|
@@ -197,6 +200,7 @@ import {
|
|
|
197
200
|
|
|
198
201
|
export {
|
|
199
202
|
detectVerifyCommand,
|
|
203
|
+
resolveVerifyTimeoutMs,
|
|
200
204
|
runVerify,
|
|
201
205
|
snipVerifyOutput,
|
|
202
206
|
getCurrentGitCommitHash,
|
|
@@ -239,6 +243,7 @@ import {
|
|
|
239
243
|
clearContinuationState,
|
|
240
244
|
continueUnfinishedSession,
|
|
241
245
|
isGeneratedContinuationMessage,
|
|
246
|
+
markInterrupted,
|
|
242
247
|
recordUserMessage,
|
|
243
248
|
} from "./policies/continuation.ts";
|
|
244
249
|
import {
|
|
@@ -377,7 +382,7 @@ export function buildCompactionContext(operationalState: string, priorityBlocks:
|
|
|
377
382
|
return context;
|
|
378
383
|
}
|
|
379
384
|
|
|
380
|
-
export const WorkflowGuard:
|
|
385
|
+
export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
381
386
|
// Honor worktree if present (e.g. opencode worktrees or devcontainers)
|
|
382
387
|
// so worktree plugins cannot punch through boundary gates. When the host
|
|
383
388
|
// reports the filesystem root, use the SDK's actual project worktree instead.
|
|
@@ -763,12 +768,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
763
768
|
}
|
|
764
769
|
}
|
|
765
770
|
if (event?.type === "message.updated") {
|
|
766
|
-
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown } })?.info;
|
|
771
|
+
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
|
|
767
772
|
if (info?.role === "user" && typeof info.sessionID === "string") {
|
|
768
773
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => {
|
|
769
774
|
recordUserMessage(info.sessionID as string, typeof info.id === "string" ? info.id : undefined);
|
|
770
775
|
});
|
|
771
776
|
}
|
|
777
|
+
// A user-initiated interrupt (Esc) surfaces as an aborted
|
|
778
|
+
// assistant message: stop automatic continuation for the
|
|
779
|
+
// session until genuine user input arrives.
|
|
780
|
+
if (info?.role === "assistant" && typeof info.sessionID === "string" && info.error?.name === "MessageAbortedError") {
|
|
781
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(info.sessionID as string));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (event?.type === "session.error") {
|
|
785
|
+
const props = event.properties as { sessionID?: unknown; error?: { name?: unknown } };
|
|
786
|
+
if (typeof props?.sessionID === "string" && props?.error?.name === "MessageAbortedError") {
|
|
787
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(props.sessionID as string));
|
|
788
|
+
}
|
|
772
789
|
}
|
|
773
790
|
if (event?.type === "session.deleted") {
|
|
774
791
|
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
@@ -804,8 +821,12 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
804
821
|
};
|
|
805
822
|
};
|
|
806
823
|
|
|
807
|
-
// Default export
|
|
824
|
+
// Default export supports BOTH generations: V1 calls server(), V2 reads id+setup().
|
|
825
|
+
// Spread Plugin.define() so the V2 definition type-checks separately from server().
|
|
808
826
|
export default {
|
|
809
|
-
|
|
827
|
+
...Plugin.define({
|
|
828
|
+
id: "workflow-guard",
|
|
829
|
+
setup: WorkflowGuardV2,
|
|
830
|
+
}),
|
|
810
831
|
server: WorkflowGuard,
|
|
811
832
|
} satisfies PluginModule;
|
package/test/test-e2e.mts
CHANGED
|
@@ -7,8 +7,9 @@ import { parse as parseJsonc } from "jsonc-parser";
|
|
|
7
7
|
let pass = 0;
|
|
8
8
|
let fail = 0;
|
|
9
9
|
let unavailable = 0;
|
|
10
|
-
const check = (name: string, cond: unknown):
|
|
10
|
+
const check = (name: string, cond: unknown): boolean => {
|
|
11
11
|
cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
|
|
12
|
+
return Boolean(cond);
|
|
12
13
|
};
|
|
13
14
|
const providerUnavailable = (output: string): boolean =>
|
|
14
15
|
/available credits|insufficient credits|rate limit|too many requests|capacity|overloaded/i.test(output);
|
|
@@ -137,7 +138,7 @@ check("server and TUI package specs resolve to different modules", installedPack
|
|
|
137
138
|
const opencodeCheck = spawnSync("opencode", ["--version"], { encoding: "utf8" });
|
|
138
139
|
if (opencodeCheck.status !== 0) {
|
|
139
140
|
console.log("SKIP: opencode CLI is not available in PATH. Package checks passed; skipping live runtime load tests.");
|
|
140
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
141
|
+
if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
|
|
141
142
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
142
143
|
process.exit(fail > 0 ? 1 : 0);
|
|
143
144
|
}
|
|
@@ -149,20 +150,41 @@ mkdirSync(pluginsDir, { recursive: true });
|
|
|
149
150
|
|
|
150
151
|
// Pre-seed .opencode with dependencies already installed into testDir
|
|
151
152
|
// so opencode does not execute a cold network install at runtime startup.
|
|
152
|
-
const
|
|
153
|
+
const opencodeVersionRaw = opencodeCheck.stdout.trim();
|
|
154
|
+
const opencodeSemver = opencodeVersionRaw.match(/(\d+\.\d+\.\d+)/)?.[1] ?? opencodeVersionRaw;
|
|
155
|
+
const opencodeMajor = Number(opencodeSemver.split(".")[0] ?? 1);
|
|
156
|
+
const isOpenCodeV2 = opencodeMajor >= 2;
|
|
153
157
|
const lockPath = join(testDir, "package-lock.json");
|
|
154
158
|
if (existsSync(lockPath) && existsSync(join(testDir, "node_modules"))) {
|
|
155
159
|
try {
|
|
156
160
|
const lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
157
161
|
lock.name = ".opencode";
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
const installedVersions: Record<string, string> = {};
|
|
163
|
+
for (const key of ["node_modules/@opencode-ai/plugin", "node_modules/@opencode/plugin"]) {
|
|
164
|
+
const version = lock.packages?.[key]?.version;
|
|
165
|
+
if (typeof version === "string" && version) installedVersions[key.replace("node_modules/", "")] = version;
|
|
160
166
|
}
|
|
161
|
-
if (
|
|
162
|
-
|
|
167
|
+
if (isOpenCodeV2) {
|
|
168
|
+
// V2 loads .opencode deps with the real installed versions; rewriting
|
|
169
|
+
// them to the CLI version string would produce invalid semver.
|
|
170
|
+
const dependencies: Record<string, string> = {};
|
|
171
|
+
for (const [name, version] of Object.entries(installedVersions)) dependencies[name] = version;
|
|
172
|
+
if (!Object.keys(dependencies).length) dependencies["@opencode/plugin"] = "latest";
|
|
173
|
+
if (lock.packages?.[""]) {
|
|
174
|
+
lock.packages[""].dependencies = dependencies;
|
|
175
|
+
}
|
|
176
|
+
writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
|
|
177
|
+
writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies }, null, 2) + "\n");
|
|
178
|
+
} else {
|
|
179
|
+
if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
|
|
180
|
+
lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeSemver;
|
|
181
|
+
}
|
|
182
|
+
if (lock.packages?.[""]) {
|
|
183
|
+
lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeSemver };
|
|
184
|
+
}
|
|
185
|
+
writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
|
|
186
|
+
writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies: { "@opencode-ai/plugin": opencodeSemver } }, null, 2) + "\n");
|
|
163
187
|
}
|
|
164
|
-
writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
|
|
165
|
-
writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies: { "@opencode-ai/plugin": opencodeVersion } }, null, 2) + "\n");
|
|
166
188
|
cpSync(join(testDir, "node_modules"), join(opencodeDir, "node_modules"), { recursive: true });
|
|
167
189
|
} catch {}
|
|
168
190
|
}
|
|
@@ -179,20 +201,63 @@ const importedMarker = join(testDir, ".workflow-guard-imported");
|
|
|
179
201
|
const initializedMarker = join(testDir, ".workflow-guard-initialized");
|
|
180
202
|
const accountabilityMarker = join(testDir, ".workflow-guard-accountability");
|
|
181
203
|
writeFileSync(localAdapter, `import { writeFileSync } from "node:fs";
|
|
204
|
+
import { Plugin } from "@opencode/plugin";
|
|
182
205
|
|
|
183
206
|
writeFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
|
|
184
207
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
208
|
+
// Load the shipped dual plugin: V1 probes call server(), V2 runs the real
|
|
209
|
+
// setup(ctx) against the real plugin context and probes the REGISTERED tools.
|
|
210
|
+
const loaded = await import("../workflow-guard-source/workflow-guard.ts");
|
|
211
|
+
|
|
212
|
+
const headlessToolContext = { sessionID: "headless-e2e", agent: "build", messageID: "msg_headless", id: "call_headless", progress: async () => {} };
|
|
213
|
+
|
|
214
|
+
const runV2Probes = async (ctx: any) => {
|
|
215
|
+
\tawait loaded.default.setup(ctx);
|
|
216
|
+
\tconst tools = await ctx.tool.list();
|
|
217
|
+
\tconst byId = (id: string) => tools.find((t: any) => t.id === id);
|
|
218
|
+
\tconst todowrite = byId("todowrite");
|
|
219
|
+
\tconst guardStatus = byId("guard_status");
|
|
220
|
+
\tconst guardWhy = byId("guard_why");
|
|
221
|
+
\tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
|
|
222
|
+
\tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
|
|
223
|
+
\tconst whyRes = guardWhy ? await guardWhy.execute({ tool: "bash", input: { command: "git push origin main" } }, headlessToolContext) : undefined;
|
|
224
|
+
\twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({
|
|
225
|
+
\t\ttools: tools.map((t: any) => t.id),
|
|
226
|
+
\t\ttodowriteEnriched: Boolean(todowrite?.description?.includes("Workflow Guard lifecycle")),
|
|
227
|
+
\t\tstatus: JSON.parse(pick(statusRes)),
|
|
228
|
+
\t\twhy: JSON.parse(pick(whyRes)),
|
|
229
|
+
\t}) + "\\n");
|
|
230
|
+
\twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const runV1Probes = async (ctx: any) => {
|
|
234
|
+
\tconst hooks = await loaded.default.server(ctx);
|
|
235
|
+
\tconst directory = ctx?.location?.directory ?? ctx?.directory ?? process.cwd();
|
|
236
|
+
\tconst toolCtx = { sessionID: "headless-e2e", directory, worktree: directory };
|
|
237
|
+
\tconst status = await hooks.tool?.guard_status?.execute({}, toolCtx);
|
|
238
|
+
\tconst why = await hooks.tool?.guard_why?.execute({ tool: "bash", input: { command: "git push origin main" } }, toolCtx);
|
|
190
239
|
\twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: JSON.parse(String(why)) }) + "\\n");
|
|
191
240
|
\twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
|
|
192
241
|
\treturn hooks;
|
|
193
242
|
};
|
|
194
243
|
|
|
195
|
-
export default {
|
|
244
|
+
export default {
|
|
245
|
+
\t...Plugin.define({
|
|
246
|
+
\t\tid: "workflow-guard-e2e",
|
|
247
|
+
\t\tasync setup(ctx: any) {
|
|
248
|
+
\t\t\twriteFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
|
|
249
|
+
\t\t\ttry {
|
|
250
|
+
\t\t\t\tawait runV2Probes(ctx);
|
|
251
|
+
\t\t\t} catch (error) {
|
|
252
|
+
\t\t\t\twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ error: String((error as Error)?.message ?? error), stack: String((error as Error)?.stack ?? "") }) + "\\n");
|
|
253
|
+
\t\t\t\tthrow error;
|
|
254
|
+
\t\t\t}
|
|
255
|
+
\t\t},
|
|
256
|
+
\t}),
|
|
257
|
+
\tserver: async (ctx: any) => {
|
|
258
|
+
\t\treturn await runV1Probes(ctx);
|
|
259
|
+
\t},
|
|
260
|
+
};
|
|
196
261
|
`);
|
|
197
262
|
writeFileSync(join(testDir, ".opencode", "opencode.json"), `${JSON.stringify({ plugin: ["./plugins/workflow-guard.ts"] }, null, 2)}\n`);
|
|
198
263
|
check("local plugin adapter and source copied successfully", existsSync(localAdapter) && existsSync(targetPlugin));
|
|
@@ -211,6 +276,13 @@ const runtimeEnv: NodeJS.ProcessEnv = {
|
|
|
211
276
|
delete runtimeEnv.OPENCODE_PID;
|
|
212
277
|
delete runtimeEnv.OPENCODE_PURE;
|
|
213
278
|
delete runtimeEnv.OPENCODE;
|
|
279
|
+
if (isOpenCodeV2) {
|
|
280
|
+
// V2 runs a shared managed service on a fixed default port. Each run uses
|
|
281
|
+
// its own isolated XDG dirs, so pick a run-unique port to keep concurrent
|
|
282
|
+
// and sequential runs from colliding on service startup.
|
|
283
|
+
const servicePort = 49400 + (process.pid % 1000);
|
|
284
|
+
spawnSync("opencode", ["service", "set", "port", String(servicePort)], { cwd: testDir, encoding: "utf8", env: runtimeEnv, timeout: 30_000 });
|
|
285
|
+
}
|
|
214
286
|
const runOpenCode = (args: string[], timeout: number) =>
|
|
215
287
|
spawnSync("opencode", ["run", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
|
|
216
288
|
const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
|
|
@@ -229,7 +301,11 @@ const configProbe = await new Promise<{ status: number | null; stdout: string; s
|
|
|
229
301
|
|
|
230
302
|
const checkReady = () => {
|
|
231
303
|
try {
|
|
232
|
-
|
|
304
|
+
const parsed = JSON.parse(stdout);
|
|
305
|
+
// V1 prints an object with plugin_origins; V2 prints a config document
|
|
306
|
+
// array. Either way, the adapter's setup/server must have run.
|
|
307
|
+
const hasConfig = Boolean(parsed?.plugin_origins) || Array.isArray(parsed);
|
|
308
|
+
if (hasConfig && existsSync(initializedMarker)) {
|
|
233
309
|
done(0);
|
|
234
310
|
}
|
|
235
311
|
} catch {}
|
|
@@ -247,26 +323,52 @@ const configProbe = await new Promise<{ status: number | null; stdout: string; s
|
|
|
247
323
|
|
|
248
324
|
timer = setTimeout(() => done(null), 30_000);
|
|
249
325
|
});
|
|
326
|
+
if (isOpenCodeV2) {
|
|
327
|
+
// V2's debug config can exit before plugin setup completes; wait briefly
|
|
328
|
+
// for the adapter's initialized marker before evaluating plugin checks.
|
|
329
|
+
for (let i = 0; i < 200 && !existsSync(initializedMarker); i++) {
|
|
330
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
250
333
|
let configLoadsLocalPlugin = false;
|
|
251
334
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
335
|
+
if (isOpenCodeV2) {
|
|
336
|
+
// V2 has no plugin_origins in debug config output; the adapter's setup
|
|
337
|
+
// writing the imported marker is the plugin-load proof.
|
|
338
|
+
configLoadsLocalPlugin = existsSync(importedMarker) && existsSync(initializedMarker);
|
|
339
|
+
} else {
|
|
340
|
+
const config = JSON.parse(configProbe.stdout);
|
|
341
|
+
configLoadsLocalPlugin = config.plugin_origins?.some(
|
|
342
|
+
(origin: { spec?: unknown; source?: unknown; scope?: unknown }) =>
|
|
343
|
+
typeof origin.spec === "string" &&
|
|
344
|
+
origin.spec.endsWith("/.opencode/plugins/workflow-guard.ts") &&
|
|
345
|
+
origin.scope === "local",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
259
348
|
} catch {}
|
|
260
|
-
check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin);
|
|
349
|
+
const configCheckPassed = Boolean(check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin));
|
|
350
|
+
if (!configCheckPassed) {
|
|
351
|
+
console.log(` configProbe stderr: ${configProbe.stderr.slice(0, 2000)}`);
|
|
352
|
+
console.log(` configProbe stdout: ${configProbe.stdout.slice(0, 2000)}`);
|
|
353
|
+
console.log(` importedMarker: ${existsSync(importedMarker)} initializedMarker: ${existsSync(initializedMarker)}`);
|
|
354
|
+
}
|
|
261
355
|
let headlessAccountability: any;
|
|
262
356
|
try {
|
|
263
357
|
headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
|
|
264
358
|
} catch {}
|
|
265
359
|
check("headless OpenCode runtime exposes structured guard status and why without TUI", headlessAccountability?.status?.workspaceRoot === testDir && headlessAccountability?.why?.policy === "git" && headlessAccountability?.why?.code === "protected_branch_push");
|
|
360
|
+
if (isOpenCodeV2) {
|
|
361
|
+
// The V2 adapter runs the shipped setup(ctx) for real; verify the
|
|
362
|
+
// registered tool surface. Builtin description enrichment is best-effort:
|
|
363
|
+
// builtins are not visible to ctx.tool.list() during plugin setup, so
|
|
364
|
+
// todowriteEnriched is reported but not asserted.
|
|
365
|
+
check("V2 setup registers custom guard tools", (headlessAccountability?.tools ?? []).includes("guard_status") && (headlessAccountability?.tools ?? []).includes("guard_why"));
|
|
366
|
+
console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
|
|
367
|
+
}
|
|
266
368
|
|
|
267
369
|
if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
|
|
268
370
|
console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
|
|
269
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
371
|
+
if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
|
|
270
372
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
271
373
|
process.exit(fail > 0 ? 1 : 0);
|
|
272
374
|
}
|
|
@@ -286,7 +388,7 @@ try {
|
|
|
286
388
|
const liveModel = process.env.WORKFLOW_GUARD_LIVE_MODEL?.trim() || recentModel;
|
|
287
389
|
if (!liveModel) {
|
|
288
390
|
console.error(`FAIL: no recent OpenCode model found in ${recentModelPath}; select a model in OpenCode or set WORKFLOW_GUARD_LIVE_MODEL=provider/model.`);
|
|
289
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
391
|
+
if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
|
|
290
392
|
process.exit(1);
|
|
291
393
|
}
|
|
292
394
|
console.log(` Using live model: ${liveModel}`);
|
|
@@ -337,7 +439,7 @@ if (!run3AccountedFor) {
|
|
|
337
439
|
}
|
|
338
440
|
|
|
339
441
|
// Clean up
|
|
340
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
442
|
+
if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
|
|
341
443
|
|
|
342
444
|
console.log(`\n${pass} passed, ${fail} failed, ${unavailable} live unavailable`);
|
|
343
445
|
process.exit(fail ? 1 : 0);
|