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.
- package/assets/git-refs.mjs +208 -0
- package/assets/opencode-plugin.mjs +27 -17
- package/dist/commands/reverseTicket.d.ts +1 -0
- package/dist/commands/reverseTicket.js +53 -15
- package/package.json +1 -1
|
@@ -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
|
-
|
|
6
|
+
import {
|
|
7
|
+
buildAttributionHeaders,
|
|
8
|
+
resolveGitRefs,
|
|
9
|
+
trackWorkPath,
|
|
10
|
+
workDirBySession,
|
|
11
|
+
} from "./git-refs.mjs";
|
|
7
12
|
|
|
8
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
|
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,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
|
-
(
|
|
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
|
-
|
|
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 (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|