codiedev 0.9.29 → 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,12 +3,14 @@
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
- const skillUseBySession = new Map();
6
+ import {
7
+ buildAttributionHeaders,
8
+ resolveGitRefs,
9
+ trackWorkPath,
10
+ workDirBySession,
11
+ } from "./git-refs.mjs";
7
12
 
8
- function clean(value, max) {
9
- const text = typeof value === "string" ? value.trim() : "";
10
- return text ? text.slice(0, max) : "";
11
- }
13
+ const skillUseBySession = new Map();
12
14
 
13
15
  function addSkill(sessionID, value) {
14
16
  const skill = clean(value, 160).replace(/^\//, "").replace(/\.md$/i, "");
@@ -29,26 +31,34 @@ export const CodiedevMetadata = async () => ({
29
31
  if (!builtIns.has(input.command)) addSkill(input.sessionID, input.command);
30
32
  },
31
33
  "tool.execute.before": async (input, output) => {
34
+ // Follow where the agent writes so refs come from the tree that becomes
35
+ // the PR. Only the path's DIRECTORY is kept, and only to run git in it —
36
+ // no path ever leaves this process.
37
+ trackWorkPath(input.tool, output.args, input.sessionID);
32
38
  if (input.tool !== "skill") return;
33
39
  addSkill(input.sessionID, output.args?.name || output.args?.skill || output.args?.key);
34
40
  },
35
41
  "chat.headers": async (input, output) => {
36
- output.headers["X-Codiedev-Session-Id"] = clean(input.sessionID, 200);
37
- const repo = clean(process.env.CODIEDEV_REPO_URL, 2048);
38
- const branch = clean(process.env.CODIEDEV_BRANCH, 200);
39
- const sha = clean(process.env.CODIEDEV_HEAD_SHA, 40);
40
- const user = clean(process.env.CODIEDEV_USER, 200);
41
- const skills = [...(skillUseBySession.get(input.sessionID) || [])].join(",");
42
- if (repo) output.headers["X-Codiedev-Repo-Url"] = repo;
43
- if (branch) output.headers["X-Codiedev-Branch"] = branch;
44
- if (/^[0-9a-f]{7,40}$/i.test(sha)) output.headers["X-Codiedev-Head-Sha"] = sha;
45
- if (user) output.headers["X-Codiedev-User"] = user;
46
- if (skills) output.headers["X-Codiedev-Skills-Used"] = skills;
42
+ Object.assign(
43
+ output.headers,
44
+ buildAttributionHeaders({
45
+ sessionID: input.sessionID,
46
+ env: process.env,
47
+ refs: resolveGitRefs({ dir: workDirBySession.get(input.sessionID) ?? null }),
48
+ skills: [...(skillUseBySession.get(input.sessionID) || [])],
49
+ }),
50
+ );
47
51
  },
48
52
  event: async ({ event }) => {
49
53
  if (event?.type === "session.deleted") {
50
54
  const sessionID = event.properties?.info?.id || event.properties?.sessionID;
51
- if (sessionID) skillUseBySession.delete(sessionID);
55
+ if (sessionID) {
56
+ skillUseBySession.delete(sessionID);
57
+ // Drop the work dir with it, or a fresh session in the primary
58
+ // checkout keeps reporting the deleted session's worktree branch
59
+ // until its own first write.
60
+ workDirBySession.delete(sessionID);
61
+ }
52
62
  }
53
63
  },
54
64
  });
@@ -1 +1,2 @@
1
+ export declare function isSafeRef(ref: string): boolean;
1
2
  export declare function runReverseTicket(args: string[]): Promise<void>;
@@ -1,9 +1,47 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSafeRef = isSafeRef;
3
4
  exports.runReverseTicket = runReverseTicket;
4
5
  const child_process_1 = require("child_process");
5
6
  const shared_1 = require("./shared");
6
7
  const prUrl_1 = require("../prUrl");
8
+ /**
9
+ * Run a binary with an explicit argv, never a shell.
10
+ *
11
+ * The `gh`/`glab` API paths are built from an owner/repo parsed out of a URL the
12
+ * caller pastes in. `parsePrOrMrUrl` only bounds those segments to "no slash",
13
+ * so they can still carry `$(…)`, backticks or `;` — which a `shell: true`
14
+ * invocation would execute. Passing argv directly means the shell never sees
15
+ * them at all.
16
+ */
17
+ function runArgv(bin, argv, opts = {}) {
18
+ const res = (0, child_process_1.spawnSync)(bin, argv, {
19
+ stdio: ["pipe", "pipe", "pipe"],
20
+ encoding: "utf8",
21
+ maxBuffer: opts.maxBuffer ?? 50 * 1024 * 1024,
22
+ shell: false,
23
+ });
24
+ if (res.error)
25
+ throw res.error;
26
+ if (res.status !== 0) {
27
+ throw new Error(`${bin} ${argv.join(" ")} failed (exit ${res.status}): ${(res.stderr || "").trim().slice(0, 500)}`);
28
+ }
29
+ return res.stdout ?? "";
30
+ }
31
+ // Git ref names legally contain `$()`, `;`, `|` and backticks, all of which
32
+ // break out of the `shell: true` git calls in branch mode below. A ref supplied
33
+ // via `--base` is caller-controlled, so bound it to the safe subset before it
34
+ // reaches a command line. Mirrors `validate_ref` in modal/runner.py.
35
+ const SAFE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
36
+ function isSafeRef(ref) {
37
+ if (!ref || ref.startsWith("-"))
38
+ return false;
39
+ if (ref.includes("..") || ref.includes("@{"))
40
+ return false;
41
+ if (ref.endsWith("/") || ref.endsWith("."))
42
+ return false;
43
+ return SAFE_REF_RE.test(ref);
44
+ }
7
45
  async function fetchPrOrMrBundle(parsed, rawUrl, noTruncate) {
8
46
  console.log(`Fetching ${parsed.provider === "gitlab" ? "MR" : "PR"} ${parsed.owner}/${parsed.repo}#${parsed.number}…`);
9
47
  if (parsed.provider === "github") {
@@ -86,7 +124,7 @@ async function fetchPrOrMrBundle(parsed, rawUrl, noTruncate) {
86
124
  }
87
125
  function checkCli(bin) {
88
126
  try {
89
- (0, child_process_1.execSync)(`${bin} --version`, { stdio: ["pipe", "pipe", "pipe"] });
127
+ runArgv(bin, ["--version"], { maxBuffer: 1024 * 1024 });
90
128
  return true;
91
129
  }
92
130
  catch {
@@ -94,28 +132,22 @@ function checkCli(bin) {
94
132
  }
95
133
  }
96
134
  function ghApi(path) {
97
- const out = (0, child_process_1.execSync)(`gh api "${path}"`, {
98
- stdio: ["pipe", "pipe", "pipe"],
99
- maxBuffer: 50 * 1024 * 1024,
100
- }).toString("utf8");
101
- return JSON.parse(out);
135
+ return JSON.parse(runArgv("gh", ["api", path]));
102
136
  }
103
137
  function ghRaw(path) {
104
- return (0, child_process_1.execSync)(`gh api -H "Accept: application/vnd.github.v3.diff" "${path}"`, {
105
- stdio: ["pipe", "pipe", "pipe"],
106
- maxBuffer: 50 * 1024 * 1024,
107
- }).toString("utf8");
138
+ return runArgv("gh", [
139
+ "api",
140
+ "-H",
141
+ "Accept: application/vnd.github.v3.diff",
142
+ path,
143
+ ]);
108
144
  }
109
145
  // glab api takes a project path (URL-encoded) or numeric id and a sub-path.
110
146
  // For self-hosted GitLab, glab respects `GITLAB_HOST` env if the user has
111
147
  // configured glab against their internal instance.
112
148
  function glabApi(projectPath, subpath) {
113
149
  const encoded = encodeURIComponent(projectPath);
114
- const out = (0, child_process_1.execSync)(`glab api "projects/${encoded}/${subpath}"`, {
115
- stdio: ["pipe", "pipe", "pipe"],
116
- maxBuffer: 50 * 1024 * 1024,
117
- }).toString("utf8");
118
- return JSON.parse(out);
150
+ return JSON.parse(runArgv("glab", ["api", `projects/${encoded}/${subpath}`]));
119
151
  }
120
152
  function parseArgs(args) {
121
153
  let prUrl;
@@ -258,6 +290,12 @@ async function runBranchMode(opts) {
258
290
  console.error("Not inside a git repository. Run this from a checked-out repo.");
259
291
  process.exit(1);
260
292
  }
293
+ // `--base` lands unquoted on a `git` command line below, so reject anything
294
+ // outside the safe-ref subset instead of letting the shell interpret it.
295
+ if (opts.explicitBase !== undefined && !isSafeRef(opts.explicitBase)) {
296
+ console.error(`Invalid --base ref: ${JSON.stringify(opts.explicitBase)}. Expected a plain branch or tag name.`);
297
+ process.exit(1);
298
+ }
261
299
  // Resolve base ref — explicit, then origin/main, then origin/master.
262
300
  const candidates = opts.explicitBase
263
301
  ? [opts.explicitBase]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.29",
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",