@turns/dev 0.0.13 → 0.0.15

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/dist/daemon.js CHANGED
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { AGENT_SLICE_NAME } from "./agent-isolation.js";
8
+ import { executablePath } from "./executable-path.js";
7
9
  import { applicationPaths } from "./auth.js";
8
10
  const HERE = path.dirname(fileURLToPath(import.meta.url));
9
11
  /** Resolve linked-worktree installs back to the primary checkout. A service
@@ -41,7 +43,7 @@ export const MAC_LAUNCH_AGENT_LABEL = 'dev.turns.cli';
41
43
  function systemdQuote(value) {
42
44
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
43
45
  }
44
- export function linuxServiceUnit({ cliEntry = canonicalDaemonCliEntry(), execPath = process.execPath } = {}) {
46
+ export function linuxServiceUnit({ cliEntry = canonicalDaemonCliEntry(), execPath = process.execPath, pathEnvironment = executablePath(), } = {}) {
45
47
  return `[Unit]
46
48
  Description=Continuously upload local AI sessions to turns.dev
47
49
  Wants=network-online.target
@@ -56,11 +58,23 @@ ExecStart=${systemdQuote(execPath)} --max-old-space-size=256 --max-semi-space-si
56
58
  Restart=on-failure
57
59
  RestartSec=5
58
60
  Environment=NODE_NO_WARNINGS=1
61
+ Environment=${systemdQuote(`PATH=${pathEnvironment}`)}
59
62
 
60
63
  [Install]
61
64
  WantedBy=default.target
62
65
  `;
63
66
  }
67
+ /** Monitor agents separately, before pressure reaches the user-manager limit. */
68
+ export function linuxAgentSliceUnit() {
69
+ return `[Unit]
70
+ Description=Turns agent workloads
71
+
72
+ [Slice]
73
+ MemoryAccounting=yes
74
+ ManagedOOMMemoryPressure=kill
75
+ ManagedOOMMemoryPressureLimit=40%
76
+ `;
77
+ }
64
78
  function xmlEscape(value) {
65
79
  return String(value)
66
80
  .replaceAll('&', '&')
@@ -140,6 +154,10 @@ export function installLinuxDaemon({ paths = applicationPaths(), spawnSyncImpl =
140
154
  const temporary = `${paths.linuxService}.${process.pid}.tmp`;
141
155
  fs.writeFileSync(temporary, unit, { mode: 0o644 });
142
156
  fs.renameSync(temporary, paths.linuxService);
157
+ const slicePath = path.join(path.dirname(paths.linuxService), AGENT_SLICE_NAME);
158
+ const sliceTemporary = `${slicePath}.${process.pid}.tmp`;
159
+ fs.writeFileSync(sliceTemporary, linuxAgentSliceUnit(), { mode: 0o644 });
160
+ fs.renameSync(sliceTemporary, slicePath);
143
161
  systemctl(['disable', '--now', LEGACY_SYSTEMD_SERVICE_NAME], { spawnSyncImpl, allowFailure: true });
144
162
  const unitDirectory = path.dirname(paths.linuxService);
145
163
  for (const legacyLink of [
@@ -174,6 +192,7 @@ export function uninstallLinuxDaemon({ paths = applicationPaths(), spawnSyncImpl
174
192
  if (error?.code !== 'ENOENT')
175
193
  throw error;
176
194
  }
195
+ fs.rmSync(path.join(path.dirname(paths.linuxService), AGENT_SLICE_NAME), { force: true });
177
196
  systemctl(['daemon-reload'], { spawnSyncImpl, allowFailure: true });
178
197
  }
179
198
  export function linuxDaemonStatus({ spawnSyncImpl = spawnSync } = {}) {
@@ -0,0 +1,19 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /** Preserve explicit PATH precedence and add installations absent from service environments. */
4
+ export function executablePath(env = process.env, home = os.homedir(), execPath = process.execPath) {
5
+ return [
6
+ ...new Set([
7
+ ...String(env.PATH || '').split(path.delimiter),
8
+ path.dirname(execPath),
9
+ path.join(home, '.local', 'bin'),
10
+ path.join(home, 'bin'),
11
+ path.join(home, '.npm-global', 'bin'),
12
+ ...(env.NPM_CONFIG_PREFIX ? [path.join(env.NPM_CONFIG_PREFIX, 'bin')] : []),
13
+ '/opt/homebrew/bin',
14
+ '/usr/local/bin',
15
+ '/usr/bin',
16
+ '/bin',
17
+ ].filter((directory) => path.isAbsolute(directory))),
18
+ ].join(path.delimiter);
19
+ }
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { workingDirectoryIsAllowed } from "./directory-policy.js";
6
- import { uploadFileStream } from "./path-resolution.js";
6
+ import { storedFileMetadata, uploadFileStream } from "./path-resolution.js";
7
7
  /**
8
8
  * Answering "what is in that directory?" from the machine that knows.
9
9
  *
@@ -248,6 +248,22 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
248
248
  if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
249
249
  throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
250
250
  }
251
+ // Compare metadata attached to the R2 object, not the directory index:
252
+ // directory observations can already describe newer, unuploaded bytes.
253
+ // Post-edit hashes always force an upload, even when mtime was preserved.
254
+ if (resolved.stat.size > 0 && !contentEtag) {
255
+ const cached = await storedFileMetadata({ baseUrl, options, machine, absolutePath: resolved.path, fetchImpl });
256
+ // The file can change while its stored metadata is being fetched.
257
+ resolved.stat = fs.statSync(resolved.path);
258
+ if (!resolved.stat.isFile())
259
+ throw new Error('The file changed while its stored metadata was being checked.');
260
+ if (resolved.stat.size > MAX_BROWSE_FILE_BYTES)
261
+ throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
262
+ const modifiedAt = isoTime(resolved.stat.mtimeMs);
263
+ if (cached && cached.bytes === resolved.stat.size && cached.modifiedAt === modifiedAt) {
264
+ return { path: resolved.path, kind: 'file', bytes: cached.bytes, contentType: cached.contentType, modifiedAt };
265
+ }
266
+ }
251
267
  const sample = readFileTypeSample(resolved.path, resolved.stat.size);
252
268
  const contentType = browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size);
253
269
  const modifiedAt = isoTime(resolved.stat.mtimeMs);
@@ -0,0 +1,299 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /** Fixed, read-only CLI commands. Never invoke a prompt, login, or config dump. */
5
+ export const NATIVE_ACCOUNT_COMMANDS = {
6
+ cursor: ['status', '--format', 'json'],
7
+ devin: ['auth', 'status'],
8
+ kilo: ['profile', '--json'],
9
+ };
10
+ function text(value, limit = 160) {
11
+ if (typeof value !== 'string')
12
+ return '';
13
+ const result = value.trim();
14
+ if (result.length > limit || /[\x00-\x1f]|(?:sk-|gh[pousr]_|github_pat_|eyJ)[A-Za-z0-9_-]{12}/u.test(result))
15
+ return '';
16
+ return result;
17
+ }
18
+ /** Allowlisted display metadata, never serialized credentials or arbitrary messages. */
19
+ export function accountSummary(value) {
20
+ if (!value || typeof value !== 'object')
21
+ return '';
22
+ const identity = text(value.email) || text(value.login) || text(value.username) || text(value.name);
23
+ const plan = text(value.planType ?? value.subscriptionType ?? value.plan_name ?? value.plan, 80);
24
+ return [identity, plan ? `(${plan})` : ''].filter(Boolean).join(' ');
25
+ }
26
+ function jsonDocument(output) {
27
+ try {
28
+ // Some CLIs put warnings before their JSON response.
29
+ return JSON.parse(output.slice(output.indexOf('{'), output.lastIndexOf('}') + 1));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function parseNativeHarnessAccount(harness, output) {
36
+ const clean = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '');
37
+ if (harness === 'devin') {
38
+ if (/not logged in|not authenticated/iu.test(clean))
39
+ return { authenticated: false };
40
+ if (!/^Logged in/mu.test(clean))
41
+ return null;
42
+ const email = clean.match(/^\s*Email:\s*(.+)$/mu)?.[1];
43
+ const plan = clean.match(/^\s*Plan:\s*(.+)$/mu)?.[1] || clean.match(/^\s*Tier:\s*(.+)$/mu)?.[1];
44
+ const account = accountSummary({ email, plan });
45
+ return { authenticated: true, ...(account ? { account } : {}) };
46
+ }
47
+ const data = jsonDocument(clean);
48
+ if (!data)
49
+ return null;
50
+ if (harness === 'cursor') {
51
+ if (typeof data.isAuthenticated !== 'boolean')
52
+ return null;
53
+ const email = data.email ||
54
+ data.user?.email ||
55
+ clean.match(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/u)?.[0];
56
+ const account = accountSummary({ email, plan: data.membershipType || data.plan });
57
+ return { authenticated: data.isAuthenticated, ...(data.isAuthenticated && account ? { account } : {}) };
58
+ }
59
+ if (harness === 'kilo') {
60
+ const account = accountSummary(data.user ?? data.profile ?? data);
61
+ return account ? { authenticated: true, account } : null;
62
+ }
63
+ return null;
64
+ }
65
+ /** JSONC support for Copilot, preserving quoted URLs and trailing-comma strings. */
66
+ function readJson(file) {
67
+ try {
68
+ if (fs.statSync(file).size > 1024 * 1024)
69
+ return null;
70
+ const content = fs
71
+ .readFileSync(file, 'utf8')
72
+ .replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//gu, (_match, quoted) => quoted || '')
73
+ .replace(/("(?:\\.|[^"\\])*")|,\s*(?=[}\]])/gu, (_match, quoted) => quoted || '');
74
+ return JSON.parse(content);
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /** Decode only display claims locally. They are NOT proof that a grant is valid. */
81
+ function tokenAccount(value) {
82
+ if (typeof value !== 'string' || value.length > 32_768)
83
+ return '';
84
+ try {
85
+ const claims = JSON.parse(Buffer.from(value.split('.')[1], 'base64url').toString('utf8'));
86
+ return accountSummary({
87
+ email: claims.email || claims['https://api.openai.com/profile']?.email,
88
+ username: claims.preferred_username,
89
+ plan: claims['https://api.openai.com/auth']?.chatgpt_plan_type,
90
+ });
91
+ }
92
+ catch {
93
+ return '';
94
+ }
95
+ }
96
+ function credentialSummary(value) {
97
+ if (!value || typeof value !== 'object')
98
+ return '';
99
+ const tokens = value.tokens ?? value;
100
+ return (accountSummary(value.user) ||
101
+ accountSummary(value.account) ||
102
+ accountSummary(value) ||
103
+ tokenAccount(tokens.id_token ?? tokens.idToken) ||
104
+ tokenAccount(tokens.access_token ?? tokens.accessToken ?? tokens.access ?? tokens.token));
105
+ }
106
+ function stored(account) {
107
+ return account ? { authenticated: null, account: account.slice(0, 512) } : null;
108
+ }
109
+ const PROVIDER_LABELS = {
110
+ 'openai-codex': 'ChatGPT',
111
+ anthropic: 'Claude',
112
+ 'github-copilot': 'Copilot',
113
+ 'google-gemini-cli': 'Gemini',
114
+ 'google-antigravity': 'Antigravity',
115
+ opencode: 'OpenCode Zen',
116
+ 'opencode-go': 'OpenCode Go',
117
+ kilo: 'Kilo',
118
+ };
119
+ /** Multiple configured providers stay explicitly labelled; never pick an arbitrary
120
+ * saved account and present it as the selected model's subscription. */
121
+ function providerAccounts(providers) {
122
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers))
123
+ return null;
124
+ const entries = Object.entries(providers)
125
+ .slice(0, 8)
126
+ .flatMap(([id, credential]) => {
127
+ if (!credential || typeof credential !== 'object')
128
+ return [];
129
+ const label = PROVIDER_LABELS[id] || text(id, 60);
130
+ if (!label)
131
+ return [];
132
+ const identity = credentialSummary(credential);
133
+ const method = credential.type === 'oauth' || credential.tokens
134
+ ? 'OAuth configured'
135
+ : credential.type === 'api' || credential.type === 'api_key'
136
+ ? 'API key configured'
137
+ : '';
138
+ return identity || method ? [`${label}: ${identity || method}`] : [];
139
+ });
140
+ return stored(entries.join('; '));
141
+ }
142
+ /** Local metadata is a saved account hint, not a live auth/entitlement check.
143
+ * Read only each harness's own store, honour home overrides, and never send keys,
144
+ * access/refresh tokens, or a whole configuration over the socket. Missing,
145
+ * opaque, encrypted, or unfamiliar stores report unknown rather than signed out. */
146
+ export function readLocalHarnessAccount(harness, { home = os.homedir(), env = process.env } = {}) {
147
+ const dataHome = env.XDG_DATA_HOME || path.join(home, '.local/share');
148
+ const configHome = env.XDG_CONFIG_HOME || path.join(home, '.config');
149
+ const read = (...parts) => readJson(path.join(...parts));
150
+ if (harness === 'copilot') {
151
+ if (env.COPILOT_GITHUB_TOKEN || env.GH_TOKEN || env.GITHUB_TOKEN)
152
+ return stored('GitHub token configured');
153
+ const config = read(env.COPILOT_HOME || path.join(home, '.copilot'), 'config.json');
154
+ const user = config?.lastLoggedInUser;
155
+ const login = text(user?.login);
156
+ return stored(login ? `@${login}` : '');
157
+ }
158
+ if (harness === 'gemini' || harness === 'qwen') {
159
+ const root = path.join(harness === 'gemini' ? env.GEMINI_CLI_HOME || home : home, `.${harness}`);
160
+ const config = read(root, 'settings.json');
161
+ const authType = config?.security?.auth?.selectedType;
162
+ if (authType && !['oauth-personal', 'qwen-oauth'].includes(authType)) {
163
+ return stored(authType === 'vertex-ai' ? 'Vertex AI configured' : 'API key configured');
164
+ }
165
+ if (harness === 'gemini') {
166
+ const email = text(read(root, 'google_accounts.json')?.active);
167
+ if (email)
168
+ return stored(email);
169
+ }
170
+ return stored(credentialSummary(read(root, 'oauth_creds.json')));
171
+ }
172
+ if (harness === 'grok') {
173
+ if (env.XAI_API_KEY || env.GROK_API_KEY)
174
+ return stored('API key configured');
175
+ const auth = read(env.GROK_HOME || path.join(home, '.grok'), 'auth.json');
176
+ const accounts = Object.values(auth ?? {})
177
+ .map(credentialSummary)
178
+ .filter(Boolean);
179
+ return stored([...new Set(accounts)].join('; '));
180
+ }
181
+ if (harness === 'kimi') {
182
+ const root = env.KIMI_SHARE_DIR || path.join(home, '.kimi');
183
+ // The Python and newer standalone CLIs use separate credential roots.
184
+ for (const directory of [path.join(root, 'credentials'), path.join(home, '.kimi-code/credentials')]) {
185
+ try {
186
+ const accounts = fs
187
+ .readdirSync(directory)
188
+ .filter((name) => name.endsWith('.json'))
189
+ .slice(0, 8)
190
+ .map((name) => credentialSummary(read(directory, name)))
191
+ .filter(Boolean);
192
+ if (accounts.length)
193
+ return stored([...new Set(accounts)].join('; '));
194
+ }
195
+ catch { }
196
+ }
197
+ return null;
198
+ }
199
+ if (harness === 'opencode' || harness === 'kilo')
200
+ return providerAccounts(read(dataHome, harness, 'auth.json'));
201
+ if (harness === 'pi' || harness === 'prime') {
202
+ return providerAccounts(read(harness === 'pi'
203
+ ? env.PI_CODING_AGENT_DIR || path.join(home, '.pi/agent')
204
+ : env.PRIME_AGENT_CODING_AGENT_DIR || path.join(home, '.prime/agent'), 'auth.json'));
205
+ }
206
+ if (harness === 'hermes') {
207
+ const config = read(env.HERMES_HOME || path.join(home, '.hermes'), 'auth.json');
208
+ const active = config?.active_provider;
209
+ return providerAccounts(active && config?.providers?.[active] ? { [active]: config.providers[active] } : config?.providers);
210
+ }
211
+ if (harness === 'openclaw') {
212
+ const config = read(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'agents/main/agent/auth-profiles.json');
213
+ return providerAccounts(config?.profiles);
214
+ }
215
+ if (harness === 'letta') {
216
+ if (env.LETTA_API_KEY)
217
+ return stored('Letta API key configured');
218
+ const settings = read(path.join(home, '.letta'), 'settings.json');
219
+ const identity = credentialSummary(settings);
220
+ return stored(identity || tokenAccount(settings?.env?.LETTA_API_KEY));
221
+ }
222
+ if (harness === 'codebuddy') {
223
+ if (env.CODEBUDDY_API_KEY)
224
+ return stored('CodeBuddy API key configured');
225
+ // CodeBuddy encrypts credential fields but leaves its account metadata in
226
+ // the shared auth session. Do not inspect the historical `accounts` array.
227
+ const roots = [
228
+ path.join(dataHome, 'CodeBuddyExtension'),
229
+ path.join(home, 'Library/Application Support/CodeBuddyExtension'),
230
+ ];
231
+ for (const root of roots) {
232
+ const directory = path.join(root, 'Data/Public/auth');
233
+ try {
234
+ const accounts = fs
235
+ .readdirSync(directory)
236
+ .filter((name) => name.endsWith('.info'))
237
+ .slice(0, 8)
238
+ .map((name) => read(directory, name)?.account)
239
+ .map((account) => accountSummary({ name: account?.nickname, email: account?.email, plan: account?.enterpriseName }))
240
+ .filter(Boolean);
241
+ if (accounts.length)
242
+ return stored([...new Set(accounts)].join('; '));
243
+ }
244
+ catch { }
245
+ }
246
+ return null;
247
+ }
248
+ if (harness === 'poolside') {
249
+ const credentials = read(configHome, 'poolside/credentials.json');
250
+ if (!Array.isArray(credentials))
251
+ return null;
252
+ return stored(credentials.map(credentialSummary).filter(Boolean).join('; '));
253
+ }
254
+ return null;
255
+ }
256
+ /** Vibe's own account endpoint supplies its subscription plan without a model
257
+ * request. Mirror the installed CLI's /api/vibe/whoami contract; restrict this
258
+ * fallback to the standard Mistral host rather than sending a tenant key there.
259
+ * https://github.com/mistralai/mistral-vibe */
260
+ export async function readVibeAccount({ home = os.homedir(), env = process.env, fetchImpl = fetch, } = {}) {
261
+ const root = env.VIBE_HOME || path.join(home, '.vibe');
262
+ let config = '';
263
+ let dotenv = '';
264
+ try {
265
+ config = fs.readFileSync(path.join(root, 'config.toml'), 'utf8');
266
+ }
267
+ catch { }
268
+ try {
269
+ dotenv = fs.readFileSync(path.join(root, '.env'), 'utf8');
270
+ }
271
+ catch { }
272
+ const consoleOverride = env.VIBE_CONSOLE_BASE_URL || config.match(/^\s*console_base_url\s*=\s*["']([^"']+)["']/mu)?.[1];
273
+ if (consoleOverride && consoleOverride.replace(/\/$/u, '') !== 'https://console.mistral.ai')
274
+ return null;
275
+ const raw = dotenv.match(/^\s*(?:export\s+)?MISTRAL_API_KEY\s*=\s*(.+)\s*$/mu)?.[1]?.trim() || '';
276
+ const key = env.MISTRAL_API_KEY || raw.replace(/^(["'])(.*)\1$/u, '$2');
277
+ if (!key)
278
+ return null;
279
+ try {
280
+ const response = await fetchImpl('https://console.mistral.ai/api/vibe/whoami', {
281
+ headers: { Authorization: `Bearer ${key}` },
282
+ redirect: 'error',
283
+ signal: AbortSignal.timeout(8_000),
284
+ });
285
+ if (response.status === 401)
286
+ return { authenticated: false };
287
+ if (!response.ok)
288
+ return null;
289
+ const data = (await response.json());
290
+ const plan = data.plan_type === 'chat'
291
+ ? { INDIVIDUAL: 'Pro', TEAM: 'Team', EDU: 'Student', FREE: 'Free' }[data.plan_name || ''] || data.plan_name
292
+ : data.plan_name;
293
+ const account = accountSummary({ email: data.email || 'Mistral', plan });
294
+ return data.plan_name ? { authenticated: true, account } : null;
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
@@ -14,8 +14,8 @@
14
14
  * scraping is deliberately loose — the first https URL is the approval
15
15
  * link, a short hyphenated all-caps token is the device code — so cosmetic
16
16
  * output changes in either CLI degrade to "no URL yet" rather than to a
17
- * crash. Auth *status*, by contrast, is read from the commands both CLIs
18
- * commit to: `claude auth status --json` and `codex login status`.
17
+ * crash. Auth status comes from `claude auth status --json` or Codex's
18
+ * structured `account/read`, with `codex login status` as a fallback.
19
19
  *
20
20
  * One flow per harness runs at a time. Flow state lives in this process and
21
21
  * dies with it; the credentials themselves live where the harness put them,
@@ -23,10 +23,12 @@
23
23
  */
24
24
  import { spawn } from 'node:child_process';
25
25
  import os from 'node:os';
26
- import { resolveHarnessCommand } from "./harness-registry.js";
27
- /** Harnesses whose sign-in this module can run. Mirrors the shared
26
+ import { readCodexAccountStatus } from "./codex-account.js";
27
+ import { readVibeAccount, readLocalHarnessAccount, parseNativeHarnessAccount, NATIVE_ACCOUNT_COMMANDS, } from "./harness-account.js";
28
+ import { ACP_PROFILES, HARNESS_IDS, resolveHarnessCommand } from "./harness-registry.js";
29
+ /** Harnesses whose account status this module can inspect. Mirrors the shared
28
30
  * HARNESS_AUTH_HARNESSES list; cli/ cannot import cloudflare/shared. */
29
- export const HARNESS_AUTH_HARNESSES = ['claude', 'codex'];
31
+ export const HARNESS_AUTH_HARNESSES = HARNESS_IDS;
30
32
  const STATUS_TIMEOUT_MS = 30_000;
31
33
  const STATUS_CACHE_MS = 10_000;
32
34
  const FLOW_TIMEOUT_MS = 15 * 60_000;
@@ -140,7 +142,32 @@ export function createHarnessAuthManager() {
140
142
  return cached.answer;
141
143
  let answer = { authenticated: null };
142
144
  try {
145
+ if (harness !== 'claude' && harness !== 'codex') {
146
+ const local = readLocalHarnessAccount(harness);
147
+ const args = NATIVE_ACCOUNT_COMMANDS[harness];
148
+ let native = harness === 'vibe' ? await readVibeAccount() : null;
149
+ if (args) {
150
+ try {
151
+ const run = await runCommand(resolveHarnessCommand(harness, ACP_PROFILES[harness]), args, STATUS_TIMEOUT_MS);
152
+ if (!run.failed && !run.timedOut)
153
+ native = parseNativeHarnessAccount(harness, run.output);
154
+ }
155
+ catch {
156
+ // Keep saved metadata when this CLI version cannot answer status.
157
+ }
158
+ }
159
+ const result = native?.authenticated === false ? native : native?.account ? native : (local ?? native ?? answer);
160
+ statusCache.set(harness, { at: Date.now(), answer: result });
161
+ return result;
162
+ }
143
163
  const command = resolveHarnessCommand(harness);
164
+ // The structured account report includes the email, unlike login status.
165
+ // Older CLIs can still answer the existing human-readable status command.
166
+ const account = harness === 'codex' ? await readCodexAccountStatus(command) : null;
167
+ if (account) {
168
+ statusCache.set(harness, { at: Date.now(), answer: account });
169
+ return account;
170
+ }
144
171
  const args = harness === 'claude' ? ['auth', 'status', '--json'] : ['login', 'status'];
145
172
  const run = await runCommand(command, args, STATUS_TIMEOUT_MS);
146
173
  if (!run.failed && !run.timedOut) {
@@ -228,7 +255,10 @@ export function createHarnessAuthManager() {
228
255
  else {
229
256
  // The last output line is usually the CLI's own explanation. It has
230
257
  // secrets stripped by the caller like every other error string.
231
- const lines = stripAnsi(flow.tail).split('\n').map((line) => line.trim()).filter(Boolean);
258
+ const lines = stripAnsi(flow.tail)
259
+ .split('\n')
260
+ .map((line) => line.trim())
261
+ .filter(Boolean);
232
262
  finishFlow(harness, flow, 'failed', lines.at(-1) || 'The sign-in flow ended without succeeding.');
233
263
  }
234
264
  });
@@ -265,6 +295,9 @@ export function createHarnessAuthManager() {
265
295
  if (!HARNESS_AUTH_HARNESSES.includes(harness)) {
266
296
  throw new Error(`Sign-in is not supported for harness "${harness}".`);
267
297
  }
298
+ if (action !== 'status' && harness !== 'claude' && harness !== 'codex') {
299
+ throw new Error('Use this harness’s terminal sign-in; account status is read-only here.');
300
+ }
268
301
  if (action === 'login')
269
302
  startFlow(harness);
270
303
  else if (action === 'code')
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { executablePath } from "./executable-path.js";
4
5
  const HOME = os.homedir();
5
6
  // Turns owns this catalog. It mirrors the transports verified by
6
7
  // turns/scaffolding; queue execution must never depend on another product's
@@ -269,30 +270,55 @@ function executable(candidate) {
269
270
  return '';
270
271
  }
271
272
  }
272
- export function resolveHarnessCommand(harness, profile = {}) {
273
+ export const MACOS_BUNDLED_CODEX_PATH = '/Applications/Codex.app/Contents/Resources/codex';
274
+ /** The app owns this executable and updates it as part of the app bundle. */
275
+ export function codexCommandIsAppManaged(command) {
276
+ let candidate = path.resolve(command);
277
+ try {
278
+ candidate = fs.realpathSync(candidate);
279
+ }
280
+ catch { }
281
+ return /\/Codex\.app\/Contents\/Resources\/codex$/u.test(candidate.replaceAll('\\', '/'));
282
+ }
283
+ export function resolveHarnessCommand(harness, profile = {}, dependencies = {}) {
284
+ const env = dependencies.env || process.env;
285
+ const home = dependencies.home || HOME;
273
286
  const envName = `TURNS_${String(harness).toUpperCase().replaceAll('-', '_')}_BIN`;
274
- const configured = String(process.env[envName] || '').trim();
287
+ const configured = String(env[envName] || '').trim();
275
288
  if (configured) {
276
289
  const found = executable(path.resolve(configured));
277
290
  if (found)
278
291
  return found;
279
292
  throw new Error(`${envName} is not executable: ${configured}`);
280
293
  }
294
+ // The desktop app and its bundled CLI are released together. On macOS,
295
+ // prefer that known-compatible binary over independently installed npm or
296
+ // Homebrew copies while preserving TURNS_CODEX_BIN as the explicit escape
297
+ // hatch above.
298
+ if (harness === 'codex' && (dependencies.platform || process.platform) === 'darwin') {
299
+ const candidates = dependencies.bundledCodexPaths ||
300
+ [MACOS_BUNDLED_CODEX_PATH, path.join(home, 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex')];
301
+ for (const candidate of candidates) {
302
+ const found = executable(candidate);
303
+ if (found)
304
+ return found;
305
+ }
306
+ }
281
307
  const command = profile.command || ({ cursor: 'cursor-agent', antigravity: 'agy' }[harness] ?? harness);
282
- for (const directory of String(process.env.PATH || '').split(path.delimiter)) {
308
+ for (const directory of executablePath(env, home).split(path.delimiter)) {
283
309
  const found = executable(path.join(directory, command));
284
310
  if (found)
285
311
  return found;
286
312
  }
287
313
  for (const segments of profile.homePaths || []) {
288
- const found = executable(path.join(HOME, ...segments));
314
+ const found = executable(path.join(home, ...segments));
289
315
  if (found)
290
316
  return found;
291
317
  }
292
318
  if (harness === 'cursor') {
293
319
  for (const candidate of [
294
- path.join(HOME, '.local', 'bin', 'cursor-agent'),
295
- path.join(HOME, '.local', 'bin', 'agent'),
320
+ path.join(home, '.local', 'bin', 'cursor-agent'),
321
+ path.join(home, '.local', 'bin', 'agent'),
296
322
  ]) {
297
323
  const found = executable(candidate);
298
324
  if (found)
@@ -181,6 +181,34 @@ function fileUploadUrl(baseUrl, machine, absolutePath) {
181
181
  : encoded;
182
182
  return new URL(`/api/file/${encodeURIComponent(machine)}/${encodedPath}`, String(baseUrl));
183
183
  }
184
+ /** Read only the R2 object's upload metadata. Older servers, cache misses,
185
+ * and unavailable metadata conservatively retain the normal upload path. */
186
+ export async function storedFileMetadata({ baseUrl, options, machine, absolutePath, fetchImpl = fetch, }) {
187
+ const url = fileUploadUrl(baseUrl, machine, absolutePath);
188
+ url.pathname = url.pathname.replace('/api/file/', '/api/file-metadata/');
189
+ try {
190
+ const response = await fetchImpl(url, {
191
+ method: 'HEAD', headers: uploadHeaders(options), signal: AbortSignal.timeout(5000),
192
+ });
193
+ try {
194
+ if (!response.ok)
195
+ return null;
196
+ const size = response.headers.get('x-turns-file-size');
197
+ const bytes = Number(size);
198
+ const modifiedAt = response.headers.get('x-turns-file-modified-at') || '';
199
+ const contentType = response.headers.get('content-type') || '';
200
+ if (size === null || !Number.isSafeInteger(bytes) || bytes < 0 || !Number.isFinite(Date.parse(modifiedAt)) || !contentType)
201
+ return null;
202
+ return { bytes, modifiedAt: new Date(modifiedAt).toISOString(), contentType };
203
+ }
204
+ finally {
205
+ await response.body?.cancel();
206
+ }
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ }
184
212
  /** Kept in step with the explicit-browse transport constants in shared/files.ts. */
185
213
  export const MAX_SINGLE_BROWSE_UPLOAD_BYTES = 100 * 1024 * 1024;
186
214
  export const BROWSE_MULTIPART_PART_BYTES = 32 * 1024 * 1024;