codiedev 0.9.30 → 0.9.31

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,208 @@
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
+ const skillUseBySession = new Map();
11
+
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
+ export 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
+ }
@@ -3,210 +3,15 @@
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
+ buildAttributionHeaders,
8
+ resolveGitRefs,
9
+ trackWorkPath,
10
+ workDirBySession,
11
+ } from "./git-refs.mjs";
9
12
 
10
13
  const skillUseBySession = new Map();
11
14
 
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
15
  function addSkill(sessionID, value) {
211
16
  const skill = clean(value, 160).replace(/^\//, "").replace(/\.md$/i, "");
212
17
  if (!skill) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.30",
3
+ "version": "0.9.31",
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",