codiedev 0.9.79 → 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.
@@ -10,6 +10,7 @@ import {
10
10
  trackWorkPath,
11
11
  workDirBySession,
12
12
  } from "./git-refs.mjs";
13
+ import { freshGatewayKey } from "./session-key.mjs";
13
14
 
14
15
  const skillUseBySession = new Map();
15
16
 
@@ -60,6 +61,11 @@ export const CodiedevMetadata = async () => ({
60
61
  });
61
62
  },
62
63
  "chat.headers": async (input, output) => {
64
+ // Browser sign-in: the current 24h token, refreshed as it nears expiry,
65
+ // so a session open for days keeps working (see session-key.mjs). Exact
66
+ // "Authorization" key: per-call headers win over the apiKey-derived one.
67
+ const key = await freshGatewayKey();
68
+ if (key) output.headers.Authorization = `Bearer ${key}`;
63
69
  Object.assign(
64
70
  output.headers,
65
71
  buildAttributionHeaders({
@@ -0,0 +1,48 @@
1
+ // The gateway key for one model request, kept fresh for the whole session.
2
+ //
3
+ // With browser sign-in (`codiedev connect`), the key is a 24h access token.
4
+ // The engine (OpenCode, pi) would otherwise hold the one it was launched
5
+ // with until the session ends, so a session open for more than a day would
6
+ // start failing. `codie` sets these variables only for a browser sign-in:
7
+ // CODIEDEV_SESSION_CONFIG path to ~/.codiedev/config.json
8
+ // CODIEDEV_CLI_JS the CLI entry (dist/cli.js)
9
+ // CODIEDEV_NODE the node binary that runs it
10
+ // Each request reads the current token from config (other codiedev
11
+ // processes may have refreshed it). When it has under 30 minutes left, this
12
+ // runs `codiedev session-refresh`, which refreshes under the shared lock
13
+ // (cliSession.ts, specs/tla/CliSession.tla), then reads again.
14
+ // No variables (pasted API token, CI): returns null and the launch key stands.
15
+
16
+ import { execFile } from "node:child_process";
17
+ import { readFileSync } from "node:fs";
18
+
19
+ export const REFRESH_WHEN_LEFT_MS = 30 * 60_000;
20
+
21
+ function readSession(path) {
22
+ try {
23
+ const c = JSON.parse(readFileSync(path, "utf8"));
24
+ return c && c.auth && typeof c.token === "string" && c.token ? c : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function runRefresh(env) {
31
+ return new Promise((resolve) => {
32
+ execFile(env.CODIEDEV_NODE, [env.CODIEDEV_CLI_JS, "session-refresh"], { timeout: 30_000, env }, () => resolve());
33
+ });
34
+ }
35
+
36
+ /** Fresh gateway key, or null when the session isn't a browser sign-in. */
37
+ export async function freshGatewayKey(env = process.env, now = Date.now, refresh = runRefresh) {
38
+ const path = env.CODIEDEV_SESSION_CONFIG;
39
+ if (!path || !env.CODIEDEV_CLI_JS || !env.CODIEDEV_NODE) return null;
40
+ let session = readSession(path);
41
+ if (!session) return null;
42
+ const left = Number(session.auth.accessTokenExpiresAt) - now();
43
+ if (!(left > REFRESH_WHEN_LEFT_MS)) {
44
+ await refresh(env);
45
+ session = readSession(path) ?? session;
46
+ }
47
+ return session.token;
48
+ }
package/dist/cli.js CHANGED
@@ -15,6 +15,8 @@
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  const connect_1 = require("./connect");
18
+ const cliSession_1 = require("./cliSession");
19
+ const utils_1 = require("./utils");
18
20
  const push_1 = require("./commands/push");
19
21
  const delete_1 = require("./commands/delete");
20
22
  const pull_1 = require("./commands/pull");
@@ -45,8 +47,10 @@ Codiedev CLI
45
47
  someone left off across sessions and providers.
46
48
 
47
49
  Connect:
48
- codiedev connect Link Claude Code / Codex / Cursor /
50
+ codiedev connect Sign in with your browser and link
51
+ Claude Code / Codex / Cursor /
49
52
  GitHub Copilot / pi to your org
53
+ codiedev logout Sign this machine out
50
54
  codiedev doctor Verify your setup after connecting
51
55
 
52
56
  Harness:
@@ -172,8 +176,9 @@ const COMMAND_HELP = {
172
176
  connect: `
173
177
  codiedev connect — link your agent CLI to Codiedev
174
178
 
175
- Interactive setup. Prompts for an API token, then installs:
176
- - ~/.codiedev/config.json (token + org + tracked repos)
179
+ Opens your browser to sign in (approve once, no API key to copy), then
180
+ installs:
181
+ - ~/.codiedev/config.json (sign-in + org + tracked repos)
177
182
  - ~/.claude/settings.json hook (captures session transcripts)
178
183
  - ~/.claude/CLAUDE.md instructions (teaches Claude the commands)
179
184
  - ~/.codex/hooks.json hook (same, for Codex)
@@ -186,7 +191,27 @@ codiedev connect — link your agent CLI to Codiedev
186
191
 
187
192
  Re-run anytime to swap accounts or refresh the instruction block.
188
193
 
189
- Get your token: https://codiedev.com/portal/integrations/claude-code
194
+ Over SSH or on a machine with no display it shows a link and a short code
195
+ instead: open the link on your laptop or phone, check the code, approve.
196
+ Detected automatically.
197
+
198
+ Options:
199
+ --token Paste an API token instead of signing in with a browser
200
+ (CI, or a machine with no browser anywhere). Create one at
201
+ https://codiedev.com/portal/integrations/claude-code
202
+ --force, -f Sign in again even if this machine is already connected.
203
+
204
+ Browser sign-in stores a 24-hour access token and a refresh token that
205
+ rotates on every use; nothing long-lived is saved. Revoke a machine from
206
+ Integrations in the portal, or run 'codiedev logout' on it.
207
+ `.trim(),
208
+ logout: `
209
+ codiedev logout — sign this machine out
210
+
211
+ Ends this machine's browser sign-in on the server and removes it from
212
+ ~/.codiedev/config.json. Device settings (ignored folders) are kept.
213
+ For a pasted API token, it's only forgotten locally: revoke it in the
214
+ portal under Integrations.
190
215
  `.trim(),
191
216
  push: `
192
217
  codiedev push — author or update an artifact
@@ -405,13 +430,15 @@ Run 'codiedev help <command>' for each, or see the quick list:
405
430
 
406
431
  codiedev help
407
432
 
408
- ## Getting a token
433
+ ## Signing in
409
434
 
410
- 1. Log in to https://codiedev.com/portal/integrations/claude-code
411
- 2. Click "Create token"
412
- 3. Copy the cdv_... string and paste it into 'codiedev connect'
435
+ 'codiedev connect' opens your browser; approve, and you're done. Nothing
436
+ long-lived is stored: a 24-hour access token plus a rotating refresh token.
413
437
 
414
- Tokens are per-user. Rotate any time by revoking + creating a new one.
438
+ Over SSH it shows a link and code to approve from your laptop or phone,
439
+ automatically. For CI, create a token at
440
+ https://codiedev.com/portal/integrations/claude-code and run
441
+ 'codiedev connect --token'.
415
442
  `.trim();
416
443
  // Retired commands: say so instead of "Unknown command", since agent
417
444
  // instructions written by older installs may still reference them.
@@ -474,11 +501,41 @@ async function main() {
474
501
  console.log(`codiedev v${version_1.CLI_VERSION}`);
475
502
  return;
476
503
  }
504
+ // Browser sign-in: refresh the access token (under the shared lock) before
505
+ // the command reads config. No-op for pasted API tokens.
506
+ if (command !== "connect" && command !== "logout") {
507
+ const fresh = await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }).catch(() => null);
508
+ if (fresh?.status === "signed_out") {
509
+ console.error("\nYour Codiedev sign-in has ended (revoked or expired). Run `codiedev connect` to sign in again.\n");
510
+ }
511
+ }
477
512
  try {
478
513
  switch (command) {
479
514
  case "connect":
480
515
  await (0, connect_1.runConnect)(rest);
481
516
  return;
517
+ case "session-refresh": {
518
+ // Internal: the Codie engine's per-request hook asks for a fresh
519
+ // token when the current one has under 30 minutes left. Refreshes
520
+ // under the shared lock; prints nothing.
521
+ await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }, 60 * 60_000).catch(() => null);
522
+ return;
523
+ }
524
+ case "logout": {
525
+ const out = await (0, cliSession_1.logout)({ configPath: utils_1.CONFIG_PATH });
526
+ if (!out.wasConnected) {
527
+ console.log("This machine isn't signed in.");
528
+ }
529
+ else if (out.hadBrowserAuth) {
530
+ console.log(out.serverRevoked
531
+ ? "Signed out. This machine's sign-in is revoked."
532
+ : "Signed out on this machine. Couldn't reach Codiedev to revoke the sign-in; revoke it under Integrations in the portal.");
533
+ }
534
+ else {
535
+ console.log("Removed the API token from this machine. It still works elsewhere until you revoke it under Integrations in the portal.");
536
+ }
537
+ return;
538
+ }
482
539
  case "doctor":
483
540
  case "verify":
484
541
  await (0, doctor_1.runDoctor)(rest);
@@ -0,0 +1,108 @@
1
+ import type { CodiedevConfig } from "./utils";
2
+ export interface BrowserAuth {
3
+ kind: "browser";
4
+ clientId: string;
5
+ refreshToken: string;
6
+ /** epoch ms when `token` (the access token) stops working */
7
+ accessTokenExpiresAt: number;
8
+ }
9
+ export interface TokenSet {
10
+ accessToken: string;
11
+ refreshToken: string;
12
+ expiresAt: number;
13
+ clientId: string;
14
+ }
15
+ /** Default: refresh when less than 10 minutes remain. */
16
+ export declare const DEFAULT_MIN_VALIDITY_MS: number;
17
+ /** A lock older than this was left by a crashed process. Refresh calls time
18
+ * out well before it (REFRESH_TIMEOUT_MS), so a live holder never looks stale. */
19
+ export declare const LOCK_STALE_MS = 30000;
20
+ export interface SessionDeps {
21
+ configPath: string;
22
+ fetchImpl?: typeof fetch;
23
+ now?: () => number;
24
+ sleep?: (ms: number) => Promise<void>;
25
+ }
26
+ export declare function readConfigAt(configPath: string): CodiedevConfig | null;
27
+ /** rename(), retrying the transient Windows sharing errors. Exported for tests. */
28
+ export declare function renameWithRetry(from: string, to: string, rename?: (a: string, b: string) => void, sleep?: (ms: number) => void): void;
29
+ /**
30
+ * Write via temp file + rename so a concurrent reader never sees a torn file.
31
+ * If the rename still fails after ~1.6s of retries (a handle held that long),
32
+ * fall back to writing in place: a rare torn read beats failing `connect`.
33
+ */
34
+ export declare function writeConfigAtomic(configPath: string, config: CodiedevConfig): void;
35
+ export type FreshResult = {
36
+ status: "fresh";
37
+ config: CodiedevConfig;
38
+ } | {
39
+ status: "refreshed";
40
+ config: CodiedevConfig;
41
+ } | {
42
+ status: "adopted";
43
+ config: CodiedevConfig;
44
+ } | {
45
+ status: "unavailable";
46
+ config: CodiedevConfig;
47
+ } | {
48
+ status: "signed_out";
49
+ config: CodiedevConfig;
50
+ } | {
51
+ status: "none";
52
+ config: CodiedevConfig | null;
53
+ };
54
+ /**
55
+ * Make sure config.token is a usable access token for at least
56
+ * `minValidityMs`. Never throws: auth problems surface as the next request's
57
+ * 401, which every command already explains.
58
+ */
59
+ export declare function ensureFreshToken(deps: SessionDeps, minValidityMs?: number): Promise<FreshResult>;
60
+ export declare function pkcePair(): {
61
+ verifier: string;
62
+ challenge: string;
63
+ };
64
+ export declare function openBrowser(url: string): boolean;
65
+ export interface LoginOptions {
66
+ backendUrl: string;
67
+ clientName?: string;
68
+ fetchImpl?: typeof fetch;
69
+ /** Test hook: called with the authorize URL instead of opening a browser. */
70
+ onAuthorizeUrl?: (url: string) => void;
71
+ log?: (line: string) => void;
72
+ now?: () => number;
73
+ }
74
+ /** Parse `code`/`state` (or `error`) out of a loopback callback URL. */
75
+ export declare function parseCallback(input: string): {
76
+ code?: string;
77
+ state?: string;
78
+ error?: string;
79
+ };
80
+ export declare function browserLogin(opts: LoginOptions): Promise<TokenSet>;
81
+ export declare const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
82
+ /**
83
+ * True when a browser opened here can't reach the user: an SSH session, or a
84
+ * Linux box with no display. Then `connect` uses the device flow (a code the
85
+ * user approves on any device) instead of a loopback redirect, with no flag.
86
+ */
87
+ export declare function isHeadless(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
88
+ export interface DeviceLoginOptions {
89
+ backendUrl: string;
90
+ clientName?: string;
91
+ fetchImpl?: typeof fetch;
92
+ log?: (line: string) => void;
93
+ now?: () => number;
94
+ sleep?: (ms: number) => Promise<void>;
95
+ /** Test hook: receives the code + link instead of only printing them. */
96
+ onCode?: (info: {
97
+ userCode: string;
98
+ verificationUri: string;
99
+ verificationUriComplete: string;
100
+ }) => void;
101
+ }
102
+ export declare function deviceLogin(opts: DeviceLoginOptions): Promise<TokenSet>;
103
+ /** `codiedev logout`: end a browser sign-in server-side, forget it locally. */
104
+ export declare function logout(deps: SessionDeps): Promise<{
105
+ wasConnected: boolean;
106
+ hadBrowserAuth: boolean;
107
+ serverRevoked: boolean;
108
+ }>;
@@ -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, so there is no second harness token to mint.
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() : "");
@@ -1 +1,10 @@
1
+ /**
2
+ * The user's home, always absolute. $HOME can arrive relative or unexpanded ("~", ".", "") from a
3
+ * sandboxed shell or a wrapper; trusting it once wrote hook commands like ".codiedev/bin/…" into
4
+ * ~/.claude/settings.json, which only resolve when Claude Code starts in the home folder, so
5
+ * capture silently failed in every project. Fall back to the account's home from the OS.
6
+ */
7
+ export declare function realHome(): string;
8
+ /** $HOME when it's a real absolute path, else the OS account's home, else resolved against cwd. */
9
+ export declare function pickHome(fromEnv: string | undefined, fromOs: string | undefined): string;
1
10
  export declare function codiedevStateDir(): string;
package/dist/configDir.js CHANGED
@@ -33,6 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.realHome = realHome;
37
+ exports.pickHome = pickHome;
36
38
  exports.codiedevStateDir = codiedevStateDir;
37
39
  // Single source of truth for where the CLI's config/state lives on disk.
38
40
  //
@@ -46,9 +48,31 @@ exports.codiedevStateDir = codiedevStateDir;
46
48
  // without creating a circular import.
47
49
  const path = __importStar(require("path"));
48
50
  const os = __importStar(require("os"));
51
+ /**
52
+ * The user's home, always absolute. $HOME can arrive relative or unexpanded ("~", ".", "") from a
53
+ * sandboxed shell or a wrapper; trusting it once wrote hook commands like ".codiedev/bin/…" into
54
+ * ~/.claude/settings.json, which only resolve when Claude Code starts in the home folder, so
55
+ * capture silently failed in every project. Fall back to the account's home from the OS.
56
+ */
57
+ function realHome() {
58
+ let fromOs;
59
+ try {
60
+ fromOs = os.userInfo().homedir;
61
+ }
62
+ catch { /* no passwd entry */ }
63
+ return pickHome(os.homedir(), fromOs);
64
+ }
65
+ /** $HOME when it's a real absolute path, else the OS account's home, else resolved against cwd. */
66
+ function pickHome(fromEnv, fromOs) {
67
+ if (fromEnv && path.isAbsolute(fromEnv) && !fromEnv.split(path.sep).includes("~"))
68
+ return fromEnv;
69
+ if (fromOs && path.isAbsolute(fromOs))
70
+ return fromOs;
71
+ return path.resolve(fromEnv || ".");
72
+ }
49
73
  const CONFIG_DIR = process.env.CODIEDEV_CONFIG_DIR?.trim()
50
74
  ? path.resolve(process.env.CODIEDEV_CONFIG_DIR.trim())
51
- : path.join(os.homedir(), ".codiedev");
75
+ : path.join(realHome(), ".codiedev");
52
76
  function codiedevStateDir() {
53
77
  return CONFIG_DIR;
54
78
  }
package/dist/connect.js CHANGED
@@ -41,6 +41,7 @@ const crypto = __importStar(require("crypto"));
41
41
  const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
43
  const utils_1 = require("./utils");
44
+ const cliSession_1 = require("./cliSession");
44
45
  const connectFlow_1 = require("./connectFlow");
45
46
  const version_1 = require("./version");
46
47
  const upgradeNudge_1 = require("./upgradeNudge");
@@ -60,6 +61,16 @@ const BACKEND_URL = process.env.CODIEDEV_URL || "https://cli.codiedev.com";
60
61
  * input in CI). Without a TTY there's no echo to suppress anyway, and
61
62
  * raw mode would error.
62
63
  */
64
+ /** One visible line from the terminal (for yes/no questions). */
65
+ function prompt(question) {
66
+ return new Promise((resolve) => {
67
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
68
+ rl.question(question, (answer) => {
69
+ rl.close();
70
+ resolve(answer);
71
+ });
72
+ });
73
+ }
63
74
  function promptHidden(question) {
64
75
  return new Promise((resolve) => {
65
76
  const stdin = process.stdin;
@@ -168,16 +179,47 @@ async function runConnect(args = []) {
168
179
  // mint a fresh token from the portal — which is what bit Sonifi's first
169
180
  // engineer when he interpreted "no visible token" as "the old one is gone".
170
181
  const force = args.includes("--force") || args.includes("-f");
182
+ // `--token`: paste an API token instead of signing in (CI). Otherwise sign
183
+ // in with the browser: a loopback redirect on a desktop, a device code
184
+ // (approve on any device) over SSH or with no display, detected
185
+ // automatically. `--no-browser` / `--browser` force one or the other.
186
+ const pasteToken = args.includes("--token");
187
+ const useDevice = args.includes("--no-browser") || (!args.includes("--browser") && (0, cliSession_1.isHeadless)());
171
188
  console.log("\nWelcome to Codiedev CLI\n");
172
189
  await (0, upgradeNudge_1.maybePrintUpgradeNudge)();
173
190
  console.log("This will connect your coding agent to your Codiedev workspace and install");
174
191
  console.log("the session capture hook for org-wide session sharing.\n");
192
+ // A saved browser sign-in is reused only if it still works: refresh it
193
+ // first (a revoked one comes back signed out, with an empty token).
194
+ await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }).catch(() => undefined);
175
195
  const saved = (0, utils_1.readConfig)();
176
- const decision = (0, connectFlow_1.resolveConnectToken)({ savedConfig: saved, force });
196
+ const decision = (0, connectFlow_1.resolveConnectToken)({ savedConfig: saved, force: force || pasteToken });
177
197
  let token;
198
+ let auth;
178
199
  if (decision.source === "reused") {
179
200
  token = decision.token;
180
- console.log(`Reusing API token from ~/.codiedev/config.json (run with --force to use a new one).\n`);
201
+ auth = saved?.auth;
202
+ console.log(auth
203
+ ? `Reusing your browser sign-in from ~/.codiedev/config.json (run with --force to sign in again).\n`
204
+ : `Reusing API token from ~/.codiedev/config.json (run with --force to sign in again).\n`);
205
+ }
206
+ else if (!pasteToken) {
207
+ // Replacing a browser sign-in: end the old one so the portal doesn't
208
+ // keep listing it.
209
+ if (saved?.auth)
210
+ await (0, cliSession_1.logout)({ configPath: utils_1.CONFIG_PATH }).catch(() => undefined);
211
+ const result = await (0, connectFlow_1.signInWithTokenFallback)({
212
+ signIn: () => useDevice ? (0, cliSession_1.deviceLogin)({ backendUrl: BACKEND_URL }) : (0, cliSession_1.browserLogin)({ backendUrl: BACKEND_URL }),
213
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
214
+ askYesNo: async (q) => /^y(es)?$/i.test((await prompt(q)).trim()),
215
+ askToken: promptHidden,
216
+ log: (line) => console.log(line),
217
+ error: (line) => console.error(line),
218
+ });
219
+ if (!result)
220
+ process.exit(1);
221
+ token = result.token;
222
+ auth = result.auth;
181
223
  }
182
224
  else {
183
225
  // Hidden prompt — masks the token with `*` as it's typed/pasted so it
@@ -217,6 +259,7 @@ async function runConnect(args = []) {
217
259
  deviceId: saved?.deviceId || crypto.randomUUID(),
218
260
  deviceName: saved?.deviceName || os.hostname(),
219
261
  captureIgnoredPaths: saved?.captureIgnoredPaths ?? [],
262
+ ...(auth ? { auth } : {}),
220
263
  };
221
264
  try {
222
265
  (0, utils_1.writeConfig)(config);
@@ -11,3 +11,24 @@ export type ConnectTokenResult = {
11
11
  source: "prompt-required";
12
12
  };
13
13
  export declare function resolveConnectToken(input: ConnectTokenInput): ConnectTokenResult;
14
+ export interface SignInResult {
15
+ token: string;
16
+ auth?: import("./cliSession").BrowserAuth;
17
+ }
18
+ export interface SignInDeps {
19
+ /** Browser (loopback) or device-code sign-in; throws on failure. */
20
+ signIn: () => Promise<import("./cliSession").TokenSet>;
21
+ /** A person at a terminal who can answer prompts. */
22
+ interactive: boolean;
23
+ askYesNo: (question: string) => Promise<boolean>;
24
+ askToken: (question: string) => Promise<string>;
25
+ log: (line: string) => void;
26
+ error: (line: string) => void;
27
+ }
28
+ /**
29
+ * Browser sign-in, with the API-token paste as the way out: if the browser
30
+ * flow fails (blocked page, expired code, proxy) an interactive user is asked
31
+ * whether to paste a token instead, rather than being sent to rerun connect.
32
+ * Returns null when there's nothing to continue with (caller exits 1).
33
+ */
34
+ export declare function signInWithTokenFallback(deps: SignInDeps): Promise<SignInResult | null>;
@@ -10,6 +10,7 @@
10
10
  // generate a new one — leaving him thinking the old one had been lost.
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.resolveConnectToken = resolveConnectToken;
13
+ exports.signInWithTokenFallback = signInWithTokenFallback;
13
14
  function resolveConnectToken(input) {
14
15
  if (input.force)
15
16
  return { source: "prompt-required" };
@@ -19,3 +20,43 @@ function resolveConnectToken(input) {
19
20
  return { source: "prompt-required" };
20
21
  return { source: "reused", token: input.savedConfig.token };
21
22
  }
23
+ /**
24
+ * Browser sign-in, with the API-token paste as the way out: if the browser
25
+ * flow fails (blocked page, expired code, proxy) an interactive user is asked
26
+ * whether to paste a token instead, rather than being sent to rerun connect.
27
+ * Returns null when there's nothing to continue with (caller exits 1).
28
+ */
29
+ async function signInWithTokenFallback(deps) {
30
+ deps.log("Prefer an API token? Press Ctrl+C and run `codiedev connect --token`.\n");
31
+ try {
32
+ const s = await deps.signIn();
33
+ deps.log("Signed in. No API key is stored on this machine.\n");
34
+ return {
35
+ token: s.accessToken,
36
+ auth: {
37
+ kind: "browser",
38
+ clientId: s.clientId,
39
+ refreshToken: s.refreshToken,
40
+ accessTokenExpiresAt: s.expiresAt,
41
+ },
42
+ };
43
+ }
44
+ catch (err) {
45
+ deps.error(`\nBrowser sign-in didn't finish: ${err.message}`);
46
+ if (!deps.interactive) {
47
+ deps.error("To paste an API token instead, run `codiedev connect --token`.");
48
+ return null;
49
+ }
50
+ if (!(await deps.askYesNo("Paste an API token instead? [y/N] "))) {
51
+ deps.error("Run `codiedev connect` to try again, or `codiedev connect --token` to paste a token.");
52
+ return null;
53
+ }
54
+ deps.log("Create one at https://codiedev.com/portal/integrations/claude-code\n");
55
+ const token = (await deps.askToken("Enter your Codiedev API token: ")).trim();
56
+ if (!token) {
57
+ deps.error("Error: API token is required.");
58
+ return null;
59
+ }
60
+ return { token };
61
+ }
62
+ }
package/dist/hook.js CHANGED
@@ -60,6 +60,8 @@ const captureExclusions_1 = require("./captureExclusions");
60
60
  const cursorHooks_1 = require("./cursorHooks");
61
61
  const copilotHooks_1 = require("./copilotHooks");
62
62
  const piHooks_1 = require("./piHooks");
63
+ const cliSession_1 = require("./cliSession");
64
+ const utils_2 = require("./utils");
63
65
  // Pure builder for the discovery additionalContext. Returns null when there are
64
66
  // no matches. The suggested `codiedev pull` command carries the EXACT top key
65
67
  // (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
@@ -440,6 +442,11 @@ function resolveMode(arg) {
440
442
  return "claude-code";
441
443
  }
442
444
  async function main() {
445
+ // Browser sign-in: refresh the access token under the shared lock before
446
+ // anything reads config (cliSession.ts). A no-op for pasted API tokens and
447
+ // for tokens with time left; never throws, never prints (hook stdout is
448
+ // the agent's).
449
+ await (0, cliSession_1.ensureFreshToken)({ configPath: utils_2.CONFIG_PATH }).catch(() => undefined);
443
450
  // Shared upload deps used by both "upload" and "upload-deferred" branches.
444
451
  // Extracted to keep both branches in lockstep — any new dep added here
445
452
  // automatically applies to the deferred path too.
@@ -8,13 +8,18 @@ export declare function launcherPath(): string;
8
8
  /** True iff `cmd` invokes the stable launcher (from any state dir). */
9
9
  export declare function usesLauncher(cmd: string | undefined): boolean;
10
10
  export declare function readInstallRecord(): InstallRecord | null;
11
+ /**
12
+ * The hook script worth recording: absolute and present, and a global install over an npx cache
13
+ * copy (npm prunes ~/.npm/_npx, and hooks pointed there go quietly dead). Null = don't record.
14
+ */
15
+ export declare function stableHookScript(hookScript: string, globalCandidates?: string[]): string | null;
11
16
  /** Point the launcher at `hookScript`. Atomic, so a hook mid-launch never reads half a file. */
12
- export declare function recordCurrentInstall(hookScript: string): boolean;
17
+ export declare function recordCurrentInstall(requested: string): boolean;
13
18
  /**
14
19
  * Make sure the launcher exists and forwards to `hookScript`, returning the
15
20
  * launcher path to bake into hook configs — or null if the state dir is not
16
21
  * writable, in which case callers keep baking the direct path. Memoized:
17
22
  * one connect writes ~20 hook entries.
18
23
  */
19
- export declare function ensureLauncher(hookScript: string): string | null;
24
+ export declare function ensureLauncher(requested: string): string | null;
20
25
  export declare function resetLauncherCacheForTests(): void;
package/dist/launcher.js CHANGED
@@ -48,6 +48,7 @@ exports.LAUNCHER_FILE = void 0;
48
48
  exports.launcherPath = launcherPath;
49
49
  exports.usesLauncher = usesLauncher;
50
50
  exports.readInstallRecord = readInstallRecord;
51
+ exports.stableHookScript = stableHookScript;
51
52
  exports.recordCurrentInstall = recordCurrentInstall;
52
53
  exports.ensureLauncher = ensureLauncher;
53
54
  exports.resetLauncherCacheForTests = resetLauncherCacheForTests;
@@ -114,8 +115,35 @@ function packageVersionFor(hookScript) {
114
115
  return undefined;
115
116
  }
116
117
  }
118
+ /**
119
+ * The hook script worth recording: absolute and present, and a global install over an npx cache
120
+ * copy (npm prunes ~/.npm/_npx, and hooks pointed there go quietly dead). Null = don't record.
121
+ */
122
+ function stableHookScript(hookScript, globalCandidates = globalHookScripts()) {
123
+ if (!hookScript || !path.isAbsolute(hookScript) || hookScript.includes(`${path.sep}~${path.sep}`))
124
+ return null;
125
+ if (!fs.existsSync(hookScript))
126
+ return null;
127
+ if (hookScript.includes(`${path.sep}_npx${path.sep}`)) {
128
+ const global = globalCandidates.find((p) => path.isAbsolute(p) && fs.existsSync(p));
129
+ if (global)
130
+ return global;
131
+ }
132
+ return hookScript;
133
+ }
134
+ /** Where a global `npm i -g codiedev` puts hook.js, next to the node running this. */
135
+ function globalHookScripts() {
136
+ const prefix = path.dirname(path.dirname(process.execPath));
137
+ return [
138
+ path.join(prefix, "lib", "node_modules", "codiedev", "dist", "hook.js"),
139
+ path.join(prefix, "node_modules", "codiedev", "dist", "hook.js"),
140
+ ];
141
+ }
117
142
  /** Point the launcher at `hookScript`. Atomic, so a hook mid-launch never reads half a file. */
118
- function recordCurrentInstall(hookScript) {
143
+ function recordCurrentInstall(requested) {
144
+ const hookScript = stableHookScript(requested);
145
+ if (!hookScript)
146
+ return false;
119
147
  try {
120
148
  const record = {
121
149
  hookScript,
@@ -136,11 +164,17 @@ let ensuredFor;
136
164
  * writable, in which case callers keep baking the direct path. Memoized:
137
165
  * one connect writes ~20 hook entries.
138
166
  */
139
- function ensureLauncher(hookScript) {
167
+ function ensureLauncher(requested) {
168
+ const hookScript = stableHookScript(requested);
169
+ if (!hookScript)
170
+ return null;
140
171
  if (ensuredFor !== undefined && ensuredFor === hookScript)
141
172
  return launcherPath();
142
173
  try {
143
174
  const target = launcherPath();
175
+ // A relative launcher path in a hook config only works from one folder; never bake one.
176
+ if (!path.isAbsolute(target))
177
+ return null;
144
178
  const source = launcherSource(hookScript);
145
179
  let current = null;
146
180
  try {
package/dist/managedPi.js CHANGED
@@ -94,7 +94,8 @@ exports.BLOCKED_BASE_URL = "http://127.0.0.1:9/blocked-by-codiedev";
94
94
  * joins the captured pi session (same headers as the OpenCode metadata
95
95
  * plugin, assets/git-refs.mjs). */
96
96
  exports.GUARD_SOURCE = `// Written by Codiedev on every \`codie --pi\` launch. Edits are overwritten.
97
- import { execFileSync } from "node:child_process";
97
+ import { execFile, execFileSync } from "node:child_process";
98
+ import { readFileSync } from "node:fs";
98
99
  import { getProviders } from "@mariozechner/pi-ai";
99
100
 
100
101
  const PROVIDER = ${JSON.stringify(exports.PI_PROVIDER_ID)};
@@ -153,8 +154,23 @@ export default function (pi) {
153
154
  const p = String(event?.input?.path ?? "").replace(/\\\\/g, "/").match(/\\/([a-z0-9][a-z0-9_.:-]{0,119})\\/SKILL\\.md$/i);
154
155
  if (event?.toolName === "read" && !event.isError && p && skills.size < 50) skills.add(p[1]);
155
156
  });
157
+ // Browser sign-in: pi reads CODIEDEV_GATEWAY_KEY at request time, so put
158
+ // the current token there, refreshing it as it nears expiry (same logic as
159
+ // assets/session-key.mjs). Pasted API tokens: no variables, nothing changes.
160
+ const freshKey = async () => {
161
+ const env = process.env;
162
+ if (!env.CODIEDEV_SESSION_CONFIG || !env.CODIEDEV_CLI_JS || !env.CODIEDEV_NODE) return;
163
+ const read = () => { try { const c = JSON.parse(readFileSync(env.CODIEDEV_SESSION_CONFIG, "utf8")); return c && c.auth && c.token ? c : null; } catch { return null; } };
164
+ let s = read();
165
+ if (!s) return;
166
+ if (!(Number(s.auth.accessTokenExpiresAt) - Date.now() > 30 * 60000)) {
167
+ await new Promise((r) => execFile(env.CODIEDEV_NODE, [env.CODIEDEV_CLI_JS, "session-refresh"], { timeout: 30000 }, () => r()));
168
+ s = read() ?? s;
169
+ }
170
+ env.CODIEDEV_GATEWAY_KEY = s.token;
171
+ };
156
172
  // Before every model call in the turn: the agent may have switched branch.
157
- pi.on("turn_start", async (_event, ctx) => tag(ctx));
173
+ pi.on("turn_start", async (_event, ctx) => { await freshKey(); tag(ctx); });
158
174
  pi.on("before_provider_request", (_event, ctx) => {
159
175
  // Belt and braces: the request is already headed for BLOCKED.
160
176
  if (!ours(ctx.model)) ctx.abort();
package/dist/mcp.js CHANGED
@@ -51,6 +51,7 @@ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
51
51
  const fs = __importStar(require("fs"));
52
52
  const path = __importStar(require("path"));
53
53
  const utils_1 = require("./utils");
54
+ const cliSession_1 = require("./cliSession");
54
55
  const shared_1 = require("./commands/shared");
55
56
  const inboxFormat_1 = require("./inboxFormat");
56
57
  const PKG_NAME = "codiedev";
@@ -399,6 +400,8 @@ function emitTelemetry(config, event) {
399
400
  });
400
401
  }
401
402
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
403
+ // Long-lived process: the access token may expire while it runs.
404
+ await (0, cliSession_1.ensureFreshToken)({ configPath: utils_1.CONFIG_PATH }).catch(() => undefined);
402
405
  const config = (0, utils_1.readConfig)();
403
406
  if (!config) {
404
407
  return {
package/dist/utils.d.ts CHANGED
@@ -6,6 +6,7 @@ import type { CaptureLogEntry } from "./captureLog";
6
6
  import { codiedevStateDir } from "./configDir";
7
7
  export { inspectPiExtension, piExtensionPath } from "./piHooks";
8
8
  export { codiedevStateDir };
9
+ import type { BrowserAuth } from "./cliSession";
9
10
  export interface CodiedevConfig {
10
11
  token: string;
11
12
  companyId: string;
@@ -20,6 +21,7 @@ export interface CodiedevConfig {
20
21
  deviceId?: string;
21
22
  deviceName?: string;
22
23
  captureIgnoredPaths?: string[];
24
+ auth?: BrowserAuth;
23
25
  }
24
26
  export declare const CONFIG_PATH: string;
25
27
  export declare function readConfig(): CodiedevConfig | null;
package/dist/utils.js CHANGED
@@ -150,6 +150,7 @@ const piHooks_1 = require("./piHooks");
150
150
  var piHooks_2 = require("./piHooks");
151
151
  Object.defineProperty(exports, "inspectPiExtension", { enumerable: true, get: function () { return piHooks_2.inspectPiExtension; } });
152
152
  Object.defineProperty(exports, "piExtensionPath", { enumerable: true, get: function () { return piHooks_2.piExtensionPath; } });
153
+ const cliSession_1 = require("./cliSession");
153
154
  const CONFIG_DIR = (0, configDir_1.codiedevStateDir)();
154
155
  exports.CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
155
156
  function readConfig() {
@@ -171,12 +172,9 @@ function writeConfigAtPath(config, configDir, configPath) {
171
172
  if (!fs.existsSync(configDir)) {
172
173
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
173
174
  }
174
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
175
- encoding: "utf8",
176
- mode: 0o600,
177
- });
178
- // writeFile's mode does not replace permissions on a pre-existing file.
179
- fs.chmodSync(configPath, 0o600);
175
+ // Temp file + rename: hooks read this file concurrently, and a torn read
176
+ // looks like "not connected". The temp file is created 0600.
177
+ (0, cliSession_1.writeConfigAtomic)(configPath, config);
180
178
  }
181
179
  // Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
182
180
  // the "origin" remote; falls back to the first remote found. Returns null if
@@ -1038,7 +1036,11 @@ function resolveHookInvocation(subcommand, preferredNodePath) {
1038
1036
  // Bake the stable launcher, not this install's path, so later
1039
1037
  // upgrades (including pnpm's hashed dirs) never strand the hook.
1040
1038
  const launcher = (0, launcher_1.ensureLauncher)(hookScript);
1041
- return { command: nodePath, args: [launcher ?? hookScript, subcommand] };
1039
+ // No launcher and no stable install to point at (e.g. an npx cache under a bogus "~"):
1040
+ // the npx fallback below beats baking a path that won't exist tomorrow.
1041
+ if (launcher || (0, launcher_1.stableHookScript)(hookScript)) {
1042
+ return { command: nodePath, args: [launcher ?? hookScript, subcommand] };
1043
+ }
1042
1044
  }
1043
1045
  }
1044
1046
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.79",
3
+ "version": "0.9.80",
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",