codiedev 0.9.78 → 0.9.80
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/opencode-plugin.mjs +6 -0
- package/assets/session-key.mjs +48 -0
- package/dist/cli.js +70 -10
- package/dist/cliSession.d.ts +108 -0
- package/dist/cliSession.js +513 -0
- package/dist/codie.js +21 -1
- package/dist/commands/traces.js +57 -1
- package/dist/commands/tracesLabel.d.ts +24 -0
- package/dist/commands/tracesLabel.js +356 -0
- package/dist/configDir.d.ts +9 -0
- package/dist/configDir.js +25 -1
- package/dist/connect.js +45 -2
- package/dist/connectFlow.d.ts +21 -0
- package/dist/connectFlow.js +41 -0
- package/dist/hook.js +7 -0
- package/dist/launcher.d.ts +7 -2
- package/dist/launcher.js +36 -2
- package/dist/managedPi.js +18 -2
- package/dist/mcp.js +3 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +16 -8
- package/package.json +1 -1
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Browser sign-in for `codiedev connect`, and keeping its access token fresh.
|
|
3
|
+
//
|
|
4
|
+
// Sign-in is OAuth 2.1 authorization code + PKCE against the Codiedev
|
|
5
|
+
// backend with a loopback redirect (RFC 8252): the CLI listens on
|
|
6
|
+
// 127.0.0.1:<random port>, opens the portal's consent page, and exchanges the
|
|
7
|
+
// returned code for a 24h access token (cdv_at_) and a 30-day refresh token
|
|
8
|
+
// (cdv_rt_). No long-lived API key is saved.
|
|
9
|
+
//
|
|
10
|
+
// config.json keeps `token` = the current access token, so every existing
|
|
11
|
+
// reader keeps working. Each process entry point (codiedev, codiedev-hook,
|
|
12
|
+
// codie, the MCP server) calls ensureFreshToken() first; it refreshes when the
|
|
13
|
+
// token is about to expire.
|
|
14
|
+
//
|
|
15
|
+
// Refresh tokens are single-use and the server treats reuse of a spent one as
|
|
16
|
+
// theft (it revokes the whole sign-in). Many hooks run in parallel and share
|
|
17
|
+
// config.json, so a refresh goes:
|
|
18
|
+
// 1. read config, note the stale access token ("seen")
|
|
19
|
+
// 2. take ~/.codiedev/refresh.lock
|
|
20
|
+
// 3. re-read config; if the token is no longer "seen", another process
|
|
21
|
+
// already refreshed: use that, no network call
|
|
22
|
+
// 4. else refresh, atomically replace config.json, release
|
|
23
|
+
// specs/tla/CliSession.tla model-checks this: without step 2–3, two hooks can
|
|
24
|
+
// both present the same refresh token and sign the user out.
|
|
25
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
26
|
+
if (k2 === undefined) k2 = k;
|
|
27
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
28
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
29
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
30
|
+
}
|
|
31
|
+
Object.defineProperty(o, k2, desc);
|
|
32
|
+
}) : (function(o, m, k, k2) {
|
|
33
|
+
if (k2 === undefined) k2 = k;
|
|
34
|
+
o[k2] = m[k];
|
|
35
|
+
}));
|
|
36
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
37
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
38
|
+
}) : function(o, v) {
|
|
39
|
+
o["default"] = v;
|
|
40
|
+
});
|
|
41
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
42
|
+
var ownKeys = function(o) {
|
|
43
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
44
|
+
var ar = [];
|
|
45
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
46
|
+
return ar;
|
|
47
|
+
};
|
|
48
|
+
return ownKeys(o);
|
|
49
|
+
};
|
|
50
|
+
return function (mod) {
|
|
51
|
+
if (mod && mod.__esModule) return mod;
|
|
52
|
+
var result = {};
|
|
53
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
54
|
+
__setModuleDefault(result, mod);
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
})();
|
|
58
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
59
|
+
exports.DEVICE_CODE_GRANT = exports.LOCK_STALE_MS = exports.DEFAULT_MIN_VALIDITY_MS = void 0;
|
|
60
|
+
exports.readConfigAt = readConfigAt;
|
|
61
|
+
exports.renameWithRetry = renameWithRetry;
|
|
62
|
+
exports.writeConfigAtomic = writeConfigAtomic;
|
|
63
|
+
exports.ensureFreshToken = ensureFreshToken;
|
|
64
|
+
exports.pkcePair = pkcePair;
|
|
65
|
+
exports.openBrowser = openBrowser;
|
|
66
|
+
exports.parseCallback = parseCallback;
|
|
67
|
+
exports.browserLogin = browserLogin;
|
|
68
|
+
exports.isHeadless = isHeadless;
|
|
69
|
+
exports.deviceLogin = deviceLogin;
|
|
70
|
+
exports.logout = logout;
|
|
71
|
+
const crypto = __importStar(require("crypto"));
|
|
72
|
+
const fs = __importStar(require("fs"));
|
|
73
|
+
const http = __importStar(require("http"));
|
|
74
|
+
const os = __importStar(require("os"));
|
|
75
|
+
const path = __importStar(require("path"));
|
|
76
|
+
const child_process_1 = require("child_process");
|
|
77
|
+
/** Default: refresh when less than 10 minutes remain. */
|
|
78
|
+
exports.DEFAULT_MIN_VALIDITY_MS = 10 * 60_000;
|
|
79
|
+
/** A lock older than this was left by a crashed process. Refresh calls time
|
|
80
|
+
* out well before it (REFRESH_TIMEOUT_MS), so a live holder never looks stale. */
|
|
81
|
+
exports.LOCK_STALE_MS = 30_000;
|
|
82
|
+
const LOCK_WAIT_MS = 20_000;
|
|
83
|
+
const REFRESH_TIMEOUT_MS = 10_000;
|
|
84
|
+
const LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
85
|
+
// ─── config IO (atomic) ────────────────────────────────────────────────
|
|
86
|
+
function readConfigAt(configPath) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Windows refuses to replace a file another process holds without share-delete
|
|
95
|
+
// (antivirus, the search indexer; Node's own readers do share). Those holds
|
|
96
|
+
// last milliseconds, so retry like graceful-fs/write-file-atomic do.
|
|
97
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
98
|
+
const RENAME_RETRY_MS = [10, 25, 50, 100, 200, 400, 800];
|
|
99
|
+
function sleepSync(ms) {
|
|
100
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
101
|
+
}
|
|
102
|
+
/** rename(), retrying the transient Windows sharing errors. Exported for tests. */
|
|
103
|
+
function renameWithRetry(from, to, rename = fs.renameSync, sleep = sleepSync) {
|
|
104
|
+
for (let attempt = 0;; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
rename(from, to);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const code = err?.code ?? "";
|
|
111
|
+
if (!RENAME_RETRY_CODES.has(code) || attempt >= RENAME_RETRY_MS.length)
|
|
112
|
+
throw err;
|
|
113
|
+
sleep(RENAME_RETRY_MS[attempt]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Write via temp file + rename so a concurrent reader never sees a torn file.
|
|
119
|
+
* If the rename still fails after ~1.6s of retries (a handle held that long),
|
|
120
|
+
* fall back to writing in place: a rare torn read beats failing `connect`.
|
|
121
|
+
*/
|
|
122
|
+
function writeConfigAtomic(configPath, config) {
|
|
123
|
+
const dir = path.dirname(configPath);
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
const data = JSON.stringify(config, null, 2);
|
|
126
|
+
const tmp = path.join(dir, `.config.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
127
|
+
fs.writeFileSync(tmp, data, { encoding: "utf8", mode: 0o600 });
|
|
128
|
+
try {
|
|
129
|
+
fs.chmodSync(tmp, 0o600);
|
|
130
|
+
renameWithRetry(tmp, configPath);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
try {
|
|
135
|
+
fs.unlinkSync(tmp);
|
|
136
|
+
}
|
|
137
|
+
catch { /* already gone */ }
|
|
138
|
+
if (!RENAME_RETRY_CODES.has(err?.code ?? ""))
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
fs.writeFileSync(configPath, data, { encoding: "utf8", mode: 0o600 });
|
|
142
|
+
fs.chmodSync(configPath, 0o600);
|
|
143
|
+
}
|
|
144
|
+
// ─── refresh lock ──────────────────────────────────────────────────────
|
|
145
|
+
function lockPathFor(configPath) {
|
|
146
|
+
return path.join(path.dirname(configPath), "refresh.lock");
|
|
147
|
+
}
|
|
148
|
+
function tryLock(lockPath, now) {
|
|
149
|
+
try {
|
|
150
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
151
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: now }));
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err?.code !== "EEXIST")
|
|
157
|
+
throw err;
|
|
158
|
+
// Break a lock left by a crashed process.
|
|
159
|
+
try {
|
|
160
|
+
const age = now - fs.statSync(lockPath).mtimeMs;
|
|
161
|
+
if (age > exports.LOCK_STALE_MS)
|
|
162
|
+
fs.unlinkSync(lockPath);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* raced with its owner releasing it: retry */
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function unlock(lockPath) {
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(lockPath);
|
|
173
|
+
}
|
|
174
|
+
catch { /* already released */ }
|
|
175
|
+
}
|
|
176
|
+
function needsRefresh(config, now, minValidityMs) {
|
|
177
|
+
const auth = config.auth;
|
|
178
|
+
return !!auth && !!config.token && auth.accessTokenExpiresAt - now < minValidityMs;
|
|
179
|
+
}
|
|
180
|
+
async function postForm(fetchImpl, url, form, timeoutMs) {
|
|
181
|
+
const ctl = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetchImpl(url, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
187
|
+
body: new URLSearchParams(form).toString(),
|
|
188
|
+
signal: ctl.signal,
|
|
189
|
+
});
|
|
190
|
+
return { status: res.status, body: (await res.json().catch(() => null)) };
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Make sure config.token is a usable access token for at least
|
|
198
|
+
* `minValidityMs`. Never throws: auth problems surface as the next request's
|
|
199
|
+
* 401, which every command already explains.
|
|
200
|
+
*/
|
|
201
|
+
async function ensureFreshToken(deps, minValidityMs = exports.DEFAULT_MIN_VALIDITY_MS) {
|
|
202
|
+
const now = deps.now ?? Date.now;
|
|
203
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
204
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
205
|
+
const first = readConfigAt(deps.configPath);
|
|
206
|
+
if (!first?.auth)
|
|
207
|
+
return { status: "none", config: first };
|
|
208
|
+
if (!needsRefresh(first, now(), minValidityMs))
|
|
209
|
+
return { status: "fresh", config: first };
|
|
210
|
+
const seen = first.token;
|
|
211
|
+
const lockPath = lockPathFor(deps.configPath);
|
|
212
|
+
const deadline = now() + LOCK_WAIT_MS;
|
|
213
|
+
let locked = false;
|
|
214
|
+
try {
|
|
215
|
+
while (!(locked = tryLock(lockPath, now()))) {
|
|
216
|
+
if (now() > deadline)
|
|
217
|
+
return { status: "unavailable", config: first };
|
|
218
|
+
await sleep(50 + Math.floor(Math.random() * 100));
|
|
219
|
+
}
|
|
220
|
+
// Step 3: re-read under the lock.
|
|
221
|
+
const current = readConfigAt(deps.configPath);
|
|
222
|
+
if (!current?.auth)
|
|
223
|
+
return { status: "none", config: current };
|
|
224
|
+
if (current.token !== seen)
|
|
225
|
+
return { status: "adopted", config: current };
|
|
226
|
+
let res;
|
|
227
|
+
try {
|
|
228
|
+
res = await postForm(fetchImpl, `${current.backendUrl}/oauth/token`, {
|
|
229
|
+
grant_type: "refresh_token",
|
|
230
|
+
client_id: current.auth.clientId,
|
|
231
|
+
refresh_token: current.auth.refreshToken,
|
|
232
|
+
}, REFRESH_TIMEOUT_MS);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return { status: "unavailable", config: current };
|
|
236
|
+
}
|
|
237
|
+
if (res.status === 200 && typeof res.body?.access_token === "string" && typeof res.body.refresh_token === "string") {
|
|
238
|
+
const next = {
|
|
239
|
+
...current,
|
|
240
|
+
token: res.body.access_token,
|
|
241
|
+
auth: {
|
|
242
|
+
...current.auth,
|
|
243
|
+
refreshToken: res.body.refresh_token,
|
|
244
|
+
accessTokenExpiresAt: now() + Number(res.body.expires_in ?? 0) * 1000,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
writeConfigAtomic(deps.configPath, next);
|
|
248
|
+
return { status: "refreshed", config: next };
|
|
249
|
+
}
|
|
250
|
+
if (res.status === 400 && res.body?.error === "invalid_grant") {
|
|
251
|
+
// Revoked in the portal, signed out elsewhere, expired, or reuse was
|
|
252
|
+
// detected. Forget the credentials but keep device settings.
|
|
253
|
+
const out = { ...current, token: "" };
|
|
254
|
+
delete out.auth;
|
|
255
|
+
writeConfigAtomic(deps.configPath, out);
|
|
256
|
+
return { status: "signed_out", config: out };
|
|
257
|
+
}
|
|
258
|
+
return { status: "unavailable", config: current };
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
if (locked)
|
|
262
|
+
unlock(lockPath);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ─── browser sign-in ───────────────────────────────────────────────────
|
|
266
|
+
function base64Url(buf) {
|
|
267
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
268
|
+
}
|
|
269
|
+
function pkcePair() {
|
|
270
|
+
const verifier = base64Url(crypto.randomBytes(32));
|
|
271
|
+
const challenge = base64Url(crypto.createHash("sha256").update(verifier).digest());
|
|
272
|
+
return { verifier, challenge };
|
|
273
|
+
}
|
|
274
|
+
function openBrowser(url) {
|
|
275
|
+
try {
|
|
276
|
+
const [cmd, args] = process.platform === "darwin"
|
|
277
|
+
? ["open", [url]]
|
|
278
|
+
: process.platform === "win32"
|
|
279
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
280
|
+
: ["xdg-open", [url]];
|
|
281
|
+
const child = (0, child_process_1.spawn)(cmd, args, { detached: true, stdio: "ignore" });
|
|
282
|
+
child.on("error", () => { });
|
|
283
|
+
child.unref();
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const DONE_PAGE = (ok, msg) => `<!doctype html><html><head><meta charset="utf-8">
|
|
291
|
+
<title>Codiedev CLI</title><style>body{font-family:system-ui,sans-serif;display:grid;place-items:center;
|
|
292
|
+
height:100vh;margin:0;background:#fbfbfa;color:#1f1f1f}main{max-width:28rem;text-align:center}
|
|
293
|
+
h1{font-size:1.25rem}</style></head><body><main><h1>${ok ? "You're signed in" : "Sign-in didn't finish"}</h1>
|
|
294
|
+
<p>${msg}</p></main></body></html>`;
|
|
295
|
+
/** Parse `code`/`state` (or `error`) out of a loopback callback URL. */
|
|
296
|
+
function parseCallback(input) {
|
|
297
|
+
try {
|
|
298
|
+
const u = new URL(input.trim());
|
|
299
|
+
return {
|
|
300
|
+
code: u.searchParams.get("code") ?? undefined,
|
|
301
|
+
state: u.searchParams.get("state") ?? undefined,
|
|
302
|
+
error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? undefined,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return { error: "That isn't the address from the browser's address bar." };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function browserLogin(opts) {
|
|
310
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
311
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
312
|
+
const now = opts.now ?? Date.now;
|
|
313
|
+
const state = base64Url(crypto.randomBytes(16));
|
|
314
|
+
const { verifier, challenge } = pkcePair();
|
|
315
|
+
let settle;
|
|
316
|
+
const result = new Promise((r) => (settle = r));
|
|
317
|
+
const server = http.createServer((req, res) => {
|
|
318
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
319
|
+
if (url.pathname !== "/callback") {
|
|
320
|
+
res.writeHead(404).end();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const cb = parseCallback(url.toString());
|
|
324
|
+
const ok = !cb.error && cb.state === state && !!cb.code;
|
|
325
|
+
res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
|
|
326
|
+
res.end(DONE_PAGE(ok, ok
|
|
327
|
+
? "You can close this tab and go back to your terminal."
|
|
328
|
+
: "Go back to your terminal and run <code>codiedev connect</code> again."));
|
|
329
|
+
if (cb.error)
|
|
330
|
+
settle({ error: cb.error });
|
|
331
|
+
else if (cb.state !== state)
|
|
332
|
+
settle({ error: "The sign-in response didn't match this request (state mismatch)." });
|
|
333
|
+
else
|
|
334
|
+
settle({ code: cb.code });
|
|
335
|
+
});
|
|
336
|
+
await new Promise((resolve, reject) => {
|
|
337
|
+
server.once("error", reject);
|
|
338
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
339
|
+
});
|
|
340
|
+
const port = server.address().port;
|
|
341
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
342
|
+
const timer = setTimeout(() => settle({ error: "Timed out waiting for the browser (10 minutes)." }), LOGIN_TIMEOUT_MS);
|
|
343
|
+
try {
|
|
344
|
+
const reg = await fetchImpl(`${opts.backendUrl}/oauth/register`, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
client_name: opts.clientName ?? `Codiedev CLI on ${os.hostname()}`,
|
|
349
|
+
redirect_uris: [redirectUri],
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
if (reg.status !== 201)
|
|
353
|
+
throw new Error(`Sign-in setup failed (HTTP ${reg.status}).`);
|
|
354
|
+
const { client_id: clientId } = (await reg.json());
|
|
355
|
+
const authorize = new URL(`${opts.backendUrl}/oauth/authorize`);
|
|
356
|
+
authorize.search = new URLSearchParams({
|
|
357
|
+
client_id: clientId,
|
|
358
|
+
redirect_uri: redirectUri,
|
|
359
|
+
response_type: "code",
|
|
360
|
+
code_challenge: challenge,
|
|
361
|
+
code_challenge_method: "S256",
|
|
362
|
+
state,
|
|
363
|
+
scope: "cli",
|
|
364
|
+
}).toString();
|
|
365
|
+
const link = authorize.toString();
|
|
366
|
+
if (opts.onAuthorizeUrl) {
|
|
367
|
+
opts.onAuthorizeUrl(link);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
log("Opening your browser to sign in…");
|
|
371
|
+
log(`If it doesn't open, visit:\n ${link}\n`);
|
|
372
|
+
openBrowser(link);
|
|
373
|
+
}
|
|
374
|
+
const cb = await result;
|
|
375
|
+
if (cb.error || !cb.code)
|
|
376
|
+
throw new Error(cb.error ?? "Sign-in was cancelled.");
|
|
377
|
+
const tok = await postForm(fetchImpl, `${opts.backendUrl}/oauth/token`, {
|
|
378
|
+
grant_type: "authorization_code",
|
|
379
|
+
code: cb.code,
|
|
380
|
+
client_id: clientId,
|
|
381
|
+
redirect_uri: redirectUri,
|
|
382
|
+
code_verifier: verifier,
|
|
383
|
+
}, REFRESH_TIMEOUT_MS);
|
|
384
|
+
const body = tok.body;
|
|
385
|
+
if (tok.status !== 200 || typeof body?.access_token !== "string" || typeof body.refresh_token !== "string") {
|
|
386
|
+
throw new Error(`Sign-in failed: ${body?.error_description ?? body?.error ?? `HTTP ${tok.status}`}`);
|
|
387
|
+
}
|
|
388
|
+
if (body.scope !== "cli")
|
|
389
|
+
throw new Error("The server didn't grant CLI access.");
|
|
390
|
+
return {
|
|
391
|
+
accessToken: body.access_token,
|
|
392
|
+
refreshToken: body.refresh_token,
|
|
393
|
+
expiresAt: now() + Number(body.expires_in ?? 0) * 1000,
|
|
394
|
+
clientId,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
server.close();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// ─── device sign-in (SSH / headless) ──────────────────────────────────
|
|
403
|
+
exports.DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
404
|
+
/**
|
|
405
|
+
* True when a browser opened here can't reach the user: an SSH session, or a
|
|
406
|
+
* Linux box with no display. Then `connect` uses the device flow (a code the
|
|
407
|
+
* user approves on any device) instead of a loopback redirect, with no flag.
|
|
408
|
+
*/
|
|
409
|
+
function isHeadless(env = process.env, platform = process.platform) {
|
|
410
|
+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
|
|
411
|
+
return true;
|
|
412
|
+
if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
413
|
+
return true;
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
async function deviceLogin(opts) {
|
|
417
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
418
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
419
|
+
const now = opts.now ?? Date.now;
|
|
420
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
421
|
+
const reg = await fetchImpl(`${opts.backendUrl}/oauth/register`, {
|
|
422
|
+
method: "POST",
|
|
423
|
+
headers: { "Content-Type": "application/json" },
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
client_name: opts.clientName ?? `Codiedev CLI on ${os.hostname()}`,
|
|
426
|
+
redirect_uris: [],
|
|
427
|
+
grant_types: [exports.DEVICE_CODE_GRANT, "refresh_token"],
|
|
428
|
+
}),
|
|
429
|
+
});
|
|
430
|
+
if (reg.status !== 201)
|
|
431
|
+
throw new Error(`Sign-in setup failed (HTTP ${reg.status}).`);
|
|
432
|
+
const { client_id: clientId } = (await reg.json());
|
|
433
|
+
const start = await fetchImpl(`${opts.backendUrl}/oauth/device_authorization`, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
436
|
+
body: new URLSearchParams({ client_id: clientId, scope: "cli" }).toString(),
|
|
437
|
+
});
|
|
438
|
+
const auth = (await start.json().catch(() => null));
|
|
439
|
+
if (start.status !== 200 || !auth?.device_code || !auth.user_code || !auth.verification_uri) {
|
|
440
|
+
throw new Error(`Sign-in setup failed: ${auth?.error_description ?? `HTTP ${start.status}`}`);
|
|
441
|
+
}
|
|
442
|
+
const complete = auth.verification_uri_complete ?? auth.verification_uri;
|
|
443
|
+
if (opts.onCode) {
|
|
444
|
+
opts.onCode({ userCode: auth.user_code, verificationUri: auth.verification_uri, verificationUriComplete: complete });
|
|
445
|
+
}
|
|
446
|
+
log("To sign in, open this link on any device (your laptop or phone) and approve:\n");
|
|
447
|
+
log(` ${complete}\n`);
|
|
448
|
+
log(`Your code: ${auth.user_code} (check it matches the page)\n`);
|
|
449
|
+
log("Waiting for approval…");
|
|
450
|
+
let intervalMs = Math.max(1, Number(auth.interval ?? 5)) * 1000;
|
|
451
|
+
const deadline = now() + Number(auth.expires_in ?? 600) * 1000;
|
|
452
|
+
for (;;) {
|
|
453
|
+
await sleep(intervalMs);
|
|
454
|
+
if (now() > deadline)
|
|
455
|
+
throw new Error("The code expired before it was approved. Run `codiedev connect` again.");
|
|
456
|
+
let res;
|
|
457
|
+
try {
|
|
458
|
+
res = await postForm(fetchImpl, `${opts.backendUrl}/oauth/token`, { grant_type: exports.DEVICE_CODE_GRANT, device_code: auth.device_code, client_id: clientId }, REFRESH_TIMEOUT_MS);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
continue; // network blip: keep polling until the code expires
|
|
462
|
+
}
|
|
463
|
+
const body = res.body;
|
|
464
|
+
if (res.status === 200 && typeof body?.access_token === "string" && typeof body.refresh_token === "string") {
|
|
465
|
+
if (body.scope !== "cli")
|
|
466
|
+
throw new Error("The server didn't grant CLI access.");
|
|
467
|
+
return {
|
|
468
|
+
accessToken: body.access_token,
|
|
469
|
+
refreshToken: body.refresh_token,
|
|
470
|
+
expiresAt: now() + Number(body.expires_in ?? 0) * 1000,
|
|
471
|
+
clientId,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
switch (body?.error) {
|
|
475
|
+
case "authorization_pending":
|
|
476
|
+
continue;
|
|
477
|
+
case "slow_down":
|
|
478
|
+
intervalMs += 5000;
|
|
479
|
+
continue;
|
|
480
|
+
case "access_denied":
|
|
481
|
+
throw new Error("Sign-in was denied in the browser.");
|
|
482
|
+
case "expired_token":
|
|
483
|
+
throw new Error("The code expired before it was approved. Run `codiedev connect` again.");
|
|
484
|
+
default:
|
|
485
|
+
throw new Error(`Sign-in failed: ${body?.error_description ?? body?.error ?? `HTTP ${res.status}`}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** `codiedev logout`: end a browser sign-in server-side, forget it locally. */
|
|
490
|
+
async function logout(deps) {
|
|
491
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
492
|
+
const config = readConfigAt(deps.configPath);
|
|
493
|
+
if (!config?.token)
|
|
494
|
+
return { wasConnected: false, hadBrowserAuth: false, serverRevoked: false };
|
|
495
|
+
let serverRevoked = false;
|
|
496
|
+
const hadBrowserAuth = !!config.auth;
|
|
497
|
+
if (config.token && config.auth) {
|
|
498
|
+
try {
|
|
499
|
+
const res = await fetchImpl(`${config.backendUrl}/api/cli/logout`, {
|
|
500
|
+
method: "POST",
|
|
501
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
502
|
+
});
|
|
503
|
+
serverRevoked = res.ok && (await res.json().catch(() => null))?.revoked === true;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
/* offline: still forget locally; the portal can revoke it */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const out = { ...config, token: "" };
|
|
510
|
+
delete out.auth;
|
|
511
|
+
writeConfigAtomic(deps.configPath, out);
|
|
512
|
+
return { wasConnected: true, hadBrowserAuth, serverRevoked };
|
|
513
|
+
}
|
package/dist/codie.js
CHANGED
|
@@ -59,6 +59,11 @@ const upgradeNudge_1 = require("./upgradeNudge");
|
|
|
59
59
|
const codie_1 = require("./packs/codie");
|
|
60
60
|
const paths_1 = require("./packs/paths");
|
|
61
61
|
const managedPi_1 = require("./managedPi");
|
|
62
|
+
const cliSession_1 = require("./cliSession");
|
|
63
|
+
// The engine reads its gateway key once, at launch. With browser sign-in the
|
|
64
|
+
// key is a 24h access token, so start every session with at least 12h left
|
|
65
|
+
// (refreshing first if needed) instead of one that dies mid-task.
|
|
66
|
+
const CODIE_MIN_TOKEN_VALIDITY_MS = 12 * 60 * 60_000;
|
|
62
67
|
// How long to let the gateway finish booking the last request before asking
|
|
63
68
|
// what the session cost.
|
|
64
69
|
const USAGE_SETTLE_MS = 1200;
|
|
@@ -91,7 +96,8 @@ function existingInlineConfig() {
|
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
98
|
// The one credential. `codiedev connect` wrote it; the gateway validates the
|
|
94
|
-
// same userApiTokens row
|
|
99
|
+
// same userApiTokens row (a browser sign-in reports through its own row), so
|
|
100
|
+
// there is no second harness token to mint.
|
|
95
101
|
function connectedCodiedevConfig() {
|
|
96
102
|
try {
|
|
97
103
|
return JSON.parse(fs.readFileSync(path.join((0, configDir_1.codiedevStateDir)(), "config.json"), "utf8"));
|
|
@@ -171,7 +177,21 @@ function installBrandWordmarkUnsafe(runtime) {
|
|
|
171
177
|
}
|
|
172
178
|
async function main() {
|
|
173
179
|
(0, codieRuntime_1.assertCodieNodeVersion)(process.versions.node);
|
|
180
|
+
if (!process.env.CODIEDEV_GATEWAY_KEY && !process.env.CODIEDEV_TOKEN) {
|
|
181
|
+
const fresh = await (0, cliSession_1.ensureFreshToken)({ configPath: path.join((0, configDir_1.codiedevStateDir)(), "config.json") }, CODIE_MIN_TOKEN_VALIDITY_MS);
|
|
182
|
+
if (fresh.status === "signed_out") {
|
|
183
|
+
throw new Error("Your Codiedev sign-in has ended. Run `codiedev connect` to sign in again");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
174
186
|
const connected = connectedCodiedevConfig();
|
|
187
|
+
// Browser sign-in: let the engine's per-request hook keep the 24h key
|
|
188
|
+
// fresh for sessions that run longer (assets/session-key.mjs). Not for an
|
|
189
|
+
// explicit key from the environment, and not for pasted API tokens.
|
|
190
|
+
if (!process.env.CODIEDEV_GATEWAY_KEY && !process.env.CODIEDEV_TOKEN && connected.auth) {
|
|
191
|
+
process.env.CODIEDEV_SESSION_CONFIG = path.join((0, configDir_1.codiedevStateDir)(), "config.json");
|
|
192
|
+
process.env.CODIEDEV_CLI_JS = path.join(__dirname, "cli.js");
|
|
193
|
+
process.env.CODIEDEV_NODE = process.execPath;
|
|
194
|
+
}
|
|
175
195
|
const key = process.env.CODIEDEV_GATEWAY_KEY ||
|
|
176
196
|
process.env.CODIEDEV_TOKEN ||
|
|
177
197
|
(typeof connected.token === "string" ? connected.token.trim() : "");
|
package/dist/commands/traces.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// `codiedev traces agents`
|
|
3
3
|
// `codiedev traces cases <agent> [--dataset name] [--model id] [--out dir] [--json]`
|
|
4
4
|
// `codiedev traces report <results.json> [--harness name]`
|
|
5
|
+
// (scores, review queues, automatic scoring and datasets: ./tracesLabel.ts)
|
|
5
6
|
//
|
|
6
7
|
// Test cases are runs an engineer saved from Codiedev → Traces. They run in
|
|
7
8
|
// the engineer's coding agent, not in the portal: `cases` writes them into the
|
|
@@ -51,8 +52,9 @@ exports.runTraces = runTraces;
|
|
|
51
52
|
const fs = __importStar(require("fs"));
|
|
52
53
|
const path = __importStar(require("path"));
|
|
53
54
|
const shared_1 = require("./shared");
|
|
55
|
+
const tracesLabel_1 = require("./tracesLabel");
|
|
54
56
|
exports.TRACES_HELP = `
|
|
55
|
-
codiedev traces — test cases
|
|
57
|
+
codiedev traces — test cases, scores, review queues and automatic scoring from Codiedev → Traces
|
|
56
58
|
|
|
57
59
|
codiedev traces agents Agents you maintain, with saved case counts
|
|
58
60
|
codiedev traces cases <agent> [--dataset name [--version n]] [--case id[,id]] [--model id] [--out dir] [--json]
|
|
@@ -66,6 +68,36 @@ codiedev traces — test cases saved from Codiedev → Traces
|
|
|
66
68
|
Send the filled-in results back, so
|
|
67
69
|
Traces shows the pass rate for that model
|
|
68
70
|
|
|
71
|
+
codiedev traces overview [--days n] [--json]
|
|
72
|
+
Every agent you can see: runs, failures,
|
|
73
|
+
spend, and what's going wrong most
|
|
74
|
+
codiedev traces runs <agent> [--failed] [--flag name] [--model id] [--verdict pass|fail] [--days n] [--limit n] [--json]
|
|
75
|
+
An agent's runs, newest first
|
|
76
|
+
codiedev traces run <run> [--agent name] [--json]
|
|
77
|
+
One run: steps, what went wrong and the fix,
|
|
78
|
+
scores, prompts and replies (maintainers)
|
|
79
|
+
codiedev traces score-kinds [add <name> --type number|categories|boolean [--min n --max n] [--categories a,b]]
|
|
80
|
+
List or add the kinds of score your team uses
|
|
81
|
+
codiedev traces queue create <name> [--scores a,b] [--description text]
|
|
82
|
+
codiedev traces score <run> <score> <value> [--agent name] [--comment text] [--step n]
|
|
83
|
+
Score a run (a portal run id agent~run, or
|
|
84
|
+
the agent's own run id with --agent)
|
|
85
|
+
codiedev traces scores <agent> [--days n] [--json]
|
|
86
|
+
Each score's average or mix, and the score kinds
|
|
87
|
+
codiedev traces queue list Review queues and how many are left
|
|
88
|
+
codiedev traces queue add <queue> --agent <name> [--failed] [--days n] [--run id ...]
|
|
89
|
+
File runs into a queue for people to review
|
|
90
|
+
codiedev traces judge <agent> [--on | --off] [--scores a,b] [--instructions text]
|
|
91
|
+
[--model id] [--sample percent] [--daily n] [--budget usd | --key cdvm_…]
|
|
92
|
+
Show or set automatic scoring (a judge model
|
|
93
|
+
scores a sample of runs, billed to your key)
|
|
94
|
+
codiedev traces import <agent> <file.csv|file.jsonl> --dataset <name>
|
|
95
|
+
Add examples from a file (input, expected, name)
|
|
96
|
+
codiedev traces add-example <agent> --dataset <name> --input <text> [--expected <text>] [--name <text>]
|
|
97
|
+
codiedev traces versions <dataset> A dataset's saved versions and what changed
|
|
98
|
+
codiedev traces save-version <dataset> [--note text]
|
|
99
|
+
Save the dataset as its next version
|
|
100
|
+
|
|
69
101
|
<agent> is the agent's name (its API key name) or id. Only an agent's
|
|
70
102
|
maintainers can pull its cases or report on them.
|
|
71
103
|
`.trim();
|
|
@@ -295,6 +327,30 @@ async function runTraces(args, configOverride) {
|
|
|
295
327
|
return runCases(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
296
328
|
if (sub === "report")
|
|
297
329
|
return runReport(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
330
|
+
if (sub === "overview")
|
|
331
|
+
return (0, tracesLabel_1.runOverview)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
332
|
+
if (sub === "runs")
|
|
333
|
+
return (0, tracesLabel_1.runRuns)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
334
|
+
if (sub === "run")
|
|
335
|
+
return (0, tracesLabel_1.runRun)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
336
|
+
if (sub === "score-kinds")
|
|
337
|
+
return (0, tracesLabel_1.runScoreKinds)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
338
|
+
if (sub === "score")
|
|
339
|
+
return (0, tracesLabel_1.runScore)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
340
|
+
if (sub === "scores")
|
|
341
|
+
return (0, tracesLabel_1.runScores)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
342
|
+
if (sub === "queue")
|
|
343
|
+
return (0, tracesLabel_1.runQueue)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
344
|
+
if (sub === "judge")
|
|
345
|
+
return (0, tracesLabel_1.runJudge)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
346
|
+
if (sub === "import")
|
|
347
|
+
return (0, tracesLabel_1.runImport)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
348
|
+
if (sub === "add-example")
|
|
349
|
+
return (0, tracesLabel_1.runAddExample)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
350
|
+
if (sub === "versions")
|
|
351
|
+
return (0, tracesLabel_1.runVersions)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
352
|
+
if (sub === "save-version")
|
|
353
|
+
return (0, tracesLabel_1.runSaveVersion)(rest, configOverride ?? (0, shared_1.requireConfig)());
|
|
298
354
|
console.log(exports.TRACES_HELP);
|
|
299
355
|
if (sub && sub !== "help")
|
|
300
356
|
process.exit(1);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CodiedevConfig } from "../utils";
|
|
2
|
+
type Parsed = {
|
|
3
|
+
positional: string[];
|
|
4
|
+
flags: Record<string, string>;
|
|
5
|
+
bools: Set<string>;
|
|
6
|
+
runs: string[];
|
|
7
|
+
};
|
|
8
|
+
export declare function parseArgs(args: string[]): Parsed;
|
|
9
|
+
/** "4" → 4, "true" → true, anything else as text (a category). */
|
|
10
|
+
export declare function scoreValue(raw: string): number | boolean | string;
|
|
11
|
+
export declare function runScore(args: string[], config: CodiedevConfig): Promise<void>;
|
|
12
|
+
export declare function runScores(args: string[], config: CodiedevConfig): Promise<void>;
|
|
13
|
+
export declare function runQueue(args: string[], config: CodiedevConfig): Promise<void>;
|
|
14
|
+
export declare function runJudge(args: string[], config: CodiedevConfig): Promise<void>;
|
|
15
|
+
export declare function runImport(args: string[], config: CodiedevConfig): Promise<void>;
|
|
16
|
+
export declare function runAddExample(args: string[], config: CodiedevConfig): Promise<void>;
|
|
17
|
+
export declare function runVersions(args: string[], config: CodiedevConfig): Promise<void>;
|
|
18
|
+
export declare function runSaveVersion(args: string[], config: CodiedevConfig): Promise<void>;
|
|
19
|
+
export declare function runOverview(args: string[], config: CodiedevConfig): Promise<void>;
|
|
20
|
+
export declare function runRuns(args: string[], config: CodiedevConfig): Promise<void>;
|
|
21
|
+
export declare function runRun(args: string[], config: CodiedevConfig): Promise<void>;
|
|
22
|
+
export declare function runScoreKinds(args: string[], config: CodiedevConfig): Promise<void>;
|
|
23
|
+
export declare function runQueueCreate(args: string[], config: CodiedevConfig): Promise<void>;
|
|
24
|
+
export {};
|