codiedev 0.9.30 → 0.9.32

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.
@@ -0,0 +1,252 @@
1
+ // Git-ref resolution and header construction for the Codiedev OpenCode
2
+ // plugin. These live OUTSIDE opencode-plugin.mjs on purpose: opencode
3
+ // treats EVERY export of a plugin module as a plugin factory, so exporting
4
+ // helpers there breaks harness startup. See openCodePluginGitRefs.test.ts.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { dirname, isAbsolute } from "node:path";
9
+
10
+ /**
11
+ * The directory the agent last WROTE to. Reading refs from the plugin's cwd is
12
+ * only right when `codie` was launched where the work lands. A dev who launches
13
+ * in their primary checkout while the agent edits a sibling git worktree would
14
+ * otherwise report the launch checkout's branch forever — the shape seen on
15
+ * `secscan`, whose merged PRs came from `feat/live-security-operations` and
16
+ * `worktree-landing-page-login` while the session recorded only `main`.
17
+ *
18
+ * Writes are the signal, not reads: an agent reads all over the tree, but the
19
+ * code it writes is what becomes the PR.
20
+ *
21
+ * Keyed by session, like skillUseBySession: one OpenCode process hosts many
22
+ * sessions (TUI `/new`, `task` subagents, `opencode serve`). A single global
23
+ * slot would let a write in session B retarget session A's headers, and both
24
+ * dirs pass the trust check when they share an origin, so the guard would not
25
+ * catch it.
26
+ */
27
+ export const workDirBySession = new Map();
28
+ /**
29
+ * Tool ids as OpenCode actually registers them, captured from a live run — not
30
+ * read off the binary, which is what got this wrong twice. The patch tool is
31
+ * `apply_patch`; `patch` is kept only as a defensive alias.
32
+ */
33
+ const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"]);
34
+
35
+ /**
36
+ * Which directory a tool call says the agent is working in, or null.
37
+ *
38
+ * Covering only `write`/`edit` was not enough. Two live `codie run` sessions
39
+ * were driven to create a file and the agent picked `bash` once and `patch`
40
+ * once — neither carries a `filePath` in its args, so both sessions reported
41
+ * the launch branch:
42
+ *
43
+ * write / edit args.filePath — known before the write
44
+ * apply_patch result.metadata.files[].filePath — only known AFTER it runs
45
+ *
46
+ * `bash` is deliberately NOT handled. Honouring its `workdir` re-anchors on
47
+ * READS too — `bash {command:"git log", workdir:"/repo"}` would move the
48
+ * anchor off a tracked worktree and back to the primary checkout, which
49
+ * passes the trust check because both share an origin. That breaks the rule
50
+ * this module is built on: writes are the signal, reads are not. Mining the
51
+ * command text was tried instead and did not work live. See NEXT_RELEASE.md.
52
+ *
53
+ * `patch` takes `patchText`, so its args never name a file. Shape confirmed
54
+ * against the opencode binary:
55
+ * metadata:{files:[{type:"update",filePath:…,relativePath:…,diff:…}]}
56
+ *
57
+ */
58
+ function workDirFromTool(tool, args, result) {
59
+ if (!WRITE_TOOLS.has(tool)) return null;
60
+ const candidates = [
61
+ args?.filePath,
62
+ args?.file_path,
63
+ ...(Array.isArray(result?.metadata?.files) ? result.metadata.files : []).flatMap((file) => [
64
+ file?.movePath, // a move reports the destination, which is where the work is
65
+ file?.filePath,
66
+ ]),
67
+ ];
68
+ // relativePath is deliberately excluded — it resolves against a cwd we may
69
+ // not share, so it is not a usable anchor.
70
+ const hit = candidates.find((path) => typeof path === "string" && isAbsolute(path));
71
+ return hit ? dirname(hit) : null;
72
+ }
73
+
74
+ export function trackWorkPath({
75
+ tool,
76
+ args,
77
+ sessionID,
78
+ result = null,
79
+ state = workDirBySession,
80
+ }) {
81
+ const dir = workDirFromTool(tool, args, result);
82
+ if (dir) state.set(sessionID, dir);
83
+ }
84
+
85
+ /**
86
+ * `tool.execute.before` fires BEFORE the write lands, so a file created in a
87
+ * new subdirectory names a directory that does not exist yet. Running git
88
+ * there fails, and caching that failure would revert to the launch cwd for the
89
+ * whole TTL — most of a task, for an agent scaffolding several directories.
90
+ * Walk up to the nearest directory that does exist.
91
+ */
92
+ function nearestExistingDir(dir, exists = existsSync) {
93
+ let current = dir;
94
+ for (let hops = 0; current && hops < 40; hops += 1) {
95
+ if (exists(current)) return current;
96
+ const parent = dirname(current);
97
+ if (parent === current) return null;
98
+ current = parent;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Canonicalize a git remote to bare `host/path` so the two sides of the trust
105
+ * check compare equal.
106
+ *
107
+ * They arrive from DIFFERENT inputs: the live side is the raw output of
108
+ * `git remote get-url origin`, while CODIEDEV_REPO_URL has already been through
109
+ * `safeRepoUrl()` in codie.ts, which strips userinfo. Canonicalizing to one
110
+ * shape — no scheme, no userinfo, scp-colon folded to a slash — is what makes
111
+ * `git@host:org/repo.git` and `host:org/repo.git` land on the same string.
112
+ *
113
+ * An earlier version rewrote ssh→https instead, which matched only when BOTH
114
+ * sides were raw. It silently failed for every ssh-cloned checkout, i.e. most
115
+ * enterprise ones.
116
+ */
117
+ function normalizeRemote(url) {
118
+ let value = String(url || "").trim().toLowerCase();
119
+ if (!value) return "";
120
+ value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, ""); // scheme://
121
+ value = value.replace(/^[^@/]+@/, ""); // git@ , user:token@
122
+ value = value.replace(/^([^/:]+):(?!\d)/, "$1/"); // scp-style host:path, keep :port
123
+ value = value.replace(/\/+$/, ""); // trailing slash BEFORE .git, or "repo.git/" survives
124
+ value = value.replace(/\.git$/, "");
125
+ return value.replace(/\/+$/, "");
126
+ }
127
+
128
+ /**
129
+ * Git refs are re-read live per request, not taken from the launch-time
130
+ * environment. bin/codie.js samples CODIEDEV_BRANCH / CODIEDEV_HEAD_SHA once
131
+ * and freezes them for the whole session, so a dev who starts on main and
132
+ * moves to a feature branch reported `main` until they quit. Gateway sessions
133
+ * then branch-matched nothing, because a merged PR's sourceBranch is always
134
+ * the feature branch. The env vars survive only as a fallback for when git
135
+ * cannot be read at all.
136
+ */
137
+ const GIT_TTL_MS = 5_000;
138
+ const gitCache = {};
139
+
140
+ export function clean(value, max) {
141
+ const text = typeof value === "string" ? value.trim() : "";
142
+ return text ? text.slice(0, max) : "";
143
+ }
144
+
145
+ /** Returns trimmed stdout, or null when git could not be read. */
146
+ function runGit(args, cwd) {
147
+ try {
148
+ return execFileSync("git", args, {
149
+ encoding: "utf8",
150
+ stdio: ["ignore", "pipe", "ignore"],
151
+ timeout: 1_000,
152
+ ...(cwd ? { cwd } : {}),
153
+ }).trim();
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * A write directory is only trusted when it belongs to the SAME repo the
161
+ * session is attributed to. Without that check, one stray edit into an
162
+ * unrelated checkout would publish that repo's branch name to the gateway.
163
+ * With no configured repo url there is nothing to check against, so we stay on
164
+ * cwd rather than trust a path.
165
+ */
166
+ function trustedDir(dir, env, run, cache, at, ttlMs, exists = existsSync) {
167
+ if (!dir) return null;
168
+ const want = normalizeRemote(env.CODIEDEV_REPO_URL);
169
+ if (!want) return null;
170
+ const probe = nearestExistingDir(dir, exists);
171
+ if (!probe) return null;
172
+ const key = `trust:${probe}`;
173
+ const slot = cache[key];
174
+ if (slot && at - slot.at < ttlMs) return slot.ok ? probe : null;
175
+ let ok = false;
176
+ try {
177
+ ok = normalizeRemote(run(["remote", "get-url", "origin"], probe)) === want;
178
+ } catch {
179
+ ok = false;
180
+ }
181
+ cache[key] = { at, ok };
182
+ return ok ? probe : null;
183
+ }
184
+
185
+ export function resolveGitRefs({
186
+ env = process.env,
187
+ run = runGit,
188
+ cache = gitCache,
189
+ now = Date.now,
190
+ ttlMs = GIT_TTL_MS,
191
+ dir = null,
192
+ exists = existsSync,
193
+ } = {}) {
194
+ const at = now();
195
+ const target = trustedDir(dir, env, run, cache, at, ttlMs, exists);
196
+ // Keyed per directory: moving between worktrees must be visible at once, not
197
+ // after the TTL of whichever directory was resolved first.
198
+ const key = `refs:${target ?? ""}`;
199
+ const slot = cache[key];
200
+ // One git pair per directory per TTL keeps this flat however fast the agent
201
+ // loops. Failures are cached too, so a pathological git costs one attempt
202
+ // per TTL rather than one per request.
203
+ if (slot && at - slot.at < ttlMs) return slot.refs;
204
+
205
+ let branch = null;
206
+ let sha = null;
207
+ try {
208
+ branch = run(["branch", "--show-current"], target ?? undefined);
209
+ sha = run(["rev-parse", "HEAD"], target ?? undefined);
210
+ } catch {
211
+ branch = null;
212
+ sha = null;
213
+ }
214
+
215
+ // A successful read wins even when it is empty: on a detached HEAD "no
216
+ // branch" is the true answer, and falling back to env would put the stale
217
+ // launch-time branch straight back.
218
+ //
219
+ // The fallback is ALL-OR-NOTHING. Mixing a live branch with an env sha (or
220
+ // the reverse) describes a commit that never existed on that branch — if
221
+ // `rev-parse` alone times out on a lock, the request would claim
222
+ // `feat/x` + `<launch sha of main>` and the server would attribute the
223
+ // session to a commit it never touched. That is worse than no sha at all.
224
+ const branchOk = typeof branch === "string";
225
+ const shaOk = typeof sha === "string" && sha !== "";
226
+ const refs = branchOk && shaOk
227
+ ? { branch: clean(branch, 200), sha: clean(sha, 40) }
228
+ : { branch: clean(env.CODIEDEV_BRANCH, 200), sha: clean(env.CODIEDEV_HEAD_SHA, 40) };
229
+ cache[key] = { at, refs };
230
+ return refs;
231
+ }
232
+
233
+ /**
234
+ * The complete set of headers this plugin may add — a closed allowlist. No
235
+ * prompt, message, code, diff, or tool output ever appears here.
236
+ */
237
+ export function buildAttributionHeaders({ sessionID, env = process.env, refs, skills = [] }) {
238
+ const headers = {};
239
+ const session = clean(sessionID, 200);
240
+ if (session) headers["X-Codiedev-Session-Id"] = session;
241
+ const repo = clean(env.CODIEDEV_REPO_URL, 2048);
242
+ if (repo) headers["X-Codiedev-Repo-Url"] = repo;
243
+ const branch = clean(refs?.branch, 200);
244
+ if (branch) headers["X-Codiedev-Branch"] = branch;
245
+ const sha = clean(refs?.sha, 40);
246
+ if (/^[0-9a-f]{7,40}$/i.test(sha)) headers["X-Codiedev-Head-Sha"] = sha;
247
+ const user = clean(env.CODIEDEV_USER, 200);
248
+ if (user) headers["X-Codiedev-User"] = user;
249
+ const skillList = clean(skills.join(","), 4096);
250
+ if (skillList) headers["X-Codiedev-Skills-Used"] = skillList;
251
+ return headers;
252
+ }
@@ -3,210 +3,16 @@
3
3
  // output. It only attaches stable session/git identifiers and explicit skill
4
4
  // names to inference requests handled by the company gateway.
5
5
 
6
- import { execFileSync } from "node:child_process";
7
- import { existsSync } from "node:fs";
8
- import { dirname, isAbsolute } from "node:path";
6
+ import {
7
+ clean,
8
+ buildAttributionHeaders,
9
+ resolveGitRefs,
10
+ trackWorkPath,
11
+ workDirBySession,
12
+ } from "./git-refs.mjs";
9
13
 
10
14
  const skillUseBySession = new Map();
11
15
 
12
- /**
13
- * The directory the agent last WROTE to. Reading refs from the plugin's cwd is
14
- * only right when `codie` was launched where the work lands. A dev who launches
15
- * in their primary checkout while the agent edits a sibling git worktree would
16
- * otherwise report the launch checkout's branch forever — the shape seen on
17
- * `secscan`, whose merged PRs came from `feat/live-security-operations` and
18
- * `worktree-landing-page-login` while the session recorded only `main`.
19
- *
20
- * Writes are the signal, not reads: an agent reads all over the tree, but the
21
- * code it writes is what becomes the PR.
22
- *
23
- * Keyed by session, like skillUseBySession: one OpenCode process hosts many
24
- * sessions (TUI `/new`, `task` subagents, `opencode serve`). A single global
25
- * slot would let a write in session B retarget session A's headers, and both
26
- * dirs pass the trust check when they share an origin, so the guard would not
27
- * catch it.
28
- */
29
- const workDirBySession = new Map();
30
- const WRITE_TOOLS = new Set(["write", "edit", "patch"]);
31
-
32
- export function trackWorkPath(tool, args, sessionID, state = workDirBySession) {
33
- if (!WRITE_TOOLS.has(tool)) return;
34
- const filePath = args?.filePath ?? args?.file_path;
35
- // Relative paths are resolved against a cwd we may not share, so they are
36
- // not usable anchors.
37
- if (typeof filePath !== "string" || !isAbsolute(filePath)) return;
38
- state.set(sessionID, dirname(filePath));
39
- }
40
-
41
- /**
42
- * `tool.execute.before` fires BEFORE the write lands, so a file created in a
43
- * new subdirectory names a directory that does not exist yet. Running git
44
- * there fails, and caching that failure would revert to the launch cwd for the
45
- * whole TTL — most of a task, for an agent scaffolding several directories.
46
- * Walk up to the nearest directory that does exist.
47
- */
48
- function nearestExistingDir(dir, exists = existsSync) {
49
- let current = dir;
50
- for (let hops = 0; current && hops < 40; hops += 1) {
51
- if (exists(current)) return current;
52
- const parent = dirname(current);
53
- if (parent === current) return null;
54
- current = parent;
55
- }
56
- return null;
57
- }
58
-
59
- /**
60
- * Canonicalize a git remote to bare `host/path` so the two sides of the trust
61
- * check compare equal.
62
- *
63
- * They arrive from DIFFERENT inputs: the live side is the raw output of
64
- * `git remote get-url origin`, while CODIEDEV_REPO_URL has already been through
65
- * `safeRepoUrl()` in codie.ts, which strips userinfo. Canonicalizing to one
66
- * shape — no scheme, no userinfo, scp-colon folded to a slash — is what makes
67
- * `git@host:org/repo.git` and `host:org/repo.git` land on the same string.
68
- *
69
- * An earlier version rewrote ssh→https instead, which matched only when BOTH
70
- * sides were raw. It silently failed for every ssh-cloned checkout, i.e. most
71
- * enterprise ones.
72
- */
73
- function normalizeRemote(url) {
74
- let value = String(url || "").trim().toLowerCase();
75
- if (!value) return "";
76
- value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, ""); // scheme://
77
- value = value.replace(/^[^@/]+@/, ""); // git@ , user:token@
78
- value = value.replace(/^([^/:]+):(?!\d)/, "$1/"); // scp-style host:path, keep :port
79
- value = value.replace(/\/+$/, ""); // trailing slash BEFORE .git, or "repo.git/" survives
80
- value = value.replace(/\.git$/, "");
81
- return value.replace(/\/+$/, "");
82
- }
83
-
84
- /**
85
- * Git refs are re-read live per request, not taken from the launch-time
86
- * environment. bin/codie.js samples CODIEDEV_BRANCH / CODIEDEV_HEAD_SHA once
87
- * and freezes them for the whole session, so a dev who starts on main and
88
- * moves to a feature branch reported `main` until they quit. Gateway sessions
89
- * then branch-matched nothing, because a merged PR's sourceBranch is always
90
- * the feature branch. The env vars survive only as a fallback for when git
91
- * cannot be read at all.
92
- */
93
- const GIT_TTL_MS = 5_000;
94
- const gitCache = {};
95
-
96
- function clean(value, max) {
97
- const text = typeof value === "string" ? value.trim() : "";
98
- return text ? text.slice(0, max) : "";
99
- }
100
-
101
- /** Returns trimmed stdout, or null when git could not be read. */
102
- function runGit(args, cwd) {
103
- try {
104
- return execFileSync("git", args, {
105
- encoding: "utf8",
106
- stdio: ["ignore", "pipe", "ignore"],
107
- timeout: 1_000,
108
- ...(cwd ? { cwd } : {}),
109
- }).trim();
110
- } catch {
111
- return null;
112
- }
113
- }
114
-
115
- /**
116
- * A write directory is only trusted when it belongs to the SAME repo the
117
- * session is attributed to. Without that check, one stray edit into an
118
- * unrelated checkout would publish that repo's branch name to the gateway.
119
- * With no configured repo url there is nothing to check against, so we stay on
120
- * cwd rather than trust a path.
121
- */
122
- function trustedDir(dir, env, run, cache, at, ttlMs, exists = existsSync) {
123
- if (!dir) return null;
124
- const want = normalizeRemote(env.CODIEDEV_REPO_URL);
125
- if (!want) return null;
126
- const probe = nearestExistingDir(dir, exists);
127
- if (!probe) return null;
128
- const key = `trust:${probe}`;
129
- const slot = cache[key];
130
- if (slot && at - slot.at < ttlMs) return slot.ok ? probe : null;
131
- let ok = false;
132
- try {
133
- ok = normalizeRemote(run(["remote", "get-url", "origin"], probe)) === want;
134
- } catch {
135
- ok = false;
136
- }
137
- cache[key] = { at, ok };
138
- return ok ? probe : null;
139
- }
140
-
141
- export function resolveGitRefs({
142
- env = process.env,
143
- run = runGit,
144
- cache = gitCache,
145
- now = Date.now,
146
- ttlMs = GIT_TTL_MS,
147
- dir = null,
148
- exists = existsSync,
149
- } = {}) {
150
- const at = now();
151
- const target = trustedDir(dir, env, run, cache, at, ttlMs, exists);
152
- // Keyed per directory: moving between worktrees must be visible at once, not
153
- // after the TTL of whichever directory was resolved first.
154
- const key = `refs:${target ?? ""}`;
155
- const slot = cache[key];
156
- // One git pair per directory per TTL keeps this flat however fast the agent
157
- // loops. Failures are cached too, so a pathological git costs one attempt
158
- // per TTL rather than one per request.
159
- if (slot && at - slot.at < ttlMs) return slot.refs;
160
-
161
- let branch = null;
162
- let sha = null;
163
- try {
164
- branch = run(["branch", "--show-current"], target ?? undefined);
165
- sha = run(["rev-parse", "HEAD"], target ?? undefined);
166
- } catch {
167
- branch = null;
168
- sha = null;
169
- }
170
-
171
- // A successful read wins even when it is empty: on a detached HEAD "no
172
- // branch" is the true answer, and falling back to env would put the stale
173
- // launch-time branch straight back.
174
- //
175
- // The fallback is ALL-OR-NOTHING. Mixing a live branch with an env sha (or
176
- // the reverse) describes a commit that never existed on that branch — if
177
- // `rev-parse` alone times out on a lock, the request would claim
178
- // `feat/x` + `<launch sha of main>` and the server would attribute the
179
- // session to a commit it never touched. That is worse than no sha at all.
180
- const branchOk = typeof branch === "string";
181
- const shaOk = typeof sha === "string" && sha !== "";
182
- const refs = branchOk && shaOk
183
- ? { branch: clean(branch, 200), sha: clean(sha, 40) }
184
- : { branch: clean(env.CODIEDEV_BRANCH, 200), sha: clean(env.CODIEDEV_HEAD_SHA, 40) };
185
- cache[key] = { at, refs };
186
- return refs;
187
- }
188
-
189
- /**
190
- * The complete set of headers this plugin may add — a closed allowlist. No
191
- * prompt, message, code, diff, or tool output ever appears here.
192
- */
193
- export function buildAttributionHeaders({ sessionID, env = process.env, refs, skills = [] }) {
194
- const headers = {};
195
- const session = clean(sessionID, 200);
196
- if (session) headers["X-Codiedev-Session-Id"] = session;
197
- const repo = clean(env.CODIEDEV_REPO_URL, 2048);
198
- if (repo) headers["X-Codiedev-Repo-Url"] = repo;
199
- const branch = clean(refs?.branch, 200);
200
- if (branch) headers["X-Codiedev-Branch"] = branch;
201
- const sha = clean(refs?.sha, 40);
202
- if (/^[0-9a-f]{7,40}$/i.test(sha)) headers["X-Codiedev-Head-Sha"] = sha;
203
- const user = clean(env.CODIEDEV_USER, 200);
204
- if (user) headers["X-Codiedev-User"] = user;
205
- const skillList = clean(skills.join(","), 4096);
206
- if (skillList) headers["X-Codiedev-Skills-Used"] = skillList;
207
- return headers;
208
- }
209
-
210
16
  function addSkill(sessionID, value) {
211
17
  const skill = clean(value, 160).replace(/^\//, "").replace(/\.md$/i, "");
212
18
  if (!skill) return;
@@ -229,10 +35,30 @@ export const CodiedevMetadata = async () => ({
229
35
  // Follow where the agent writes so refs come from the tree that becomes
230
36
  // the PR. Only the path's DIRECTORY is kept, and only to run git in it —
231
37
  // no path ever leaves this process.
232
- trackWorkPath(input.tool, output.args, input.sessionID);
38
+ //
39
+ // `write`/`edit` name the file up front; `bash` may carry a workdir. Both
40
+ // are worth catching here so the very next request already reports the
41
+ // right branch.
42
+ trackWorkPath({
43
+ tool: input.tool,
44
+ args: output.args,
45
+ sessionID: input.sessionID,
46
+ });
233
47
  if (input.tool !== "skill") return;
234
48
  addSkill(input.sessionID, output.args?.name || output.args?.skill || output.args?.key);
235
49
  },
50
+ "tool.execute.after": async (input, output) => {
51
+ // `patch` takes patchText, so the files it touched are only known once it
52
+ // has run — they arrive in the result's metadata.files[]. Without this
53
+ // hook a patch-driven session never moves off the launch branch, which is
54
+ // exactly what two live runs showed.
55
+ trackWorkPath({
56
+ tool: input.tool,
57
+ args: input.args,
58
+ sessionID: input.sessionID,
59
+ result: output,
60
+ });
61
+ },
236
62
  "chat.headers": async (input, output) => {
237
63
  Object.assign(
238
64
  output.headers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
4
4
  "description": "Connect Claude Code, Codex, Cursor, OpenCode, or VS Code Copilot to Codiedev for team session capture and artifact collaboration — and run the governed Codie harness",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",