codiedev 0.9.79 → 0.9.81
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 +113 -9
- package/dist/cliSession.d.ts +108 -0
- package/dist/cliSession.js +513 -0
- package/dist/codie.js +21 -1
- package/dist/commands/agentTemplates.generated.d.ts +11 -0
- package/dist/commands/agentTemplates.generated.js +139 -0
- package/dist/commands/agents.d.ts +7 -0
- package/dist/commands/agents.js +1130 -0
- package/dist/commands/agentsSdk.generated.d.ts +4 -0
- package/dist/commands/agentsSdk.generated.js +10 -0
- package/dist/commands/scrub.d.ts +11 -0
- package/dist/commands/scrub.js +102 -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 +25 -0
- package/dist/knownSecrets.d.ts +43 -0
- package/dist/knownSecrets.js +241 -0
- package/dist/launcher.d.ts +7 -2
- package/dist/launcher.js +36 -2
- package/dist/localScrub.d.ts +172 -0
- package/dist/localScrub.js +820 -0
- package/dist/localScrubReport.d.ts +83 -0
- package/dist/localScrubReport.js +268 -0
- package/dist/managedPi.js +18 -2
- package/dist/mcp.js +3 -0
- package/dist/scrub/extraRules.d.ts +4 -0
- package/dist/scrub/extraRules.js +17 -0
- package/dist/scrub/rules.d.ts +86 -0
- package/dist/scrub/rules.js +645 -0
- package/dist/scrub/scrubProviderRules.d.ts +14 -0
- package/dist/scrub/scrubProviderRules.js +149 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +9 -7
- package/package.json +5 -4
|
@@ -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() : "");
|