@worca/app 1.4.0 → 1.5.0-rc.2

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.
Files changed (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -52,6 +52,8 @@ import { writeFile, mkdir, appendFile, readFile, access, readdir } from 'node:fs
52
52
  import { constants as FS, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
53
53
  import { dirname, join } from 'node:path';
54
54
  import { tmpdir } from 'node:os';
55
+ import { stripGithubCredentials } from './github-credentials.mjs';
56
+ import { agentIdentity, agentSpawn, killAgentGroup, shareWithAgent } from './agent-user.mjs';
55
57
 
56
58
  const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
57
59
 
@@ -395,6 +397,9 @@ export async function runClaude(o = {}) {
395
397
  appendSubagentSystemPrompt,
396
398
  addDirs,
397
399
  argvInlineLimit,
400
+ // A pipeline agent (phases.mjs): runs as WORCA_AGENT_USER when the container set one
401
+ // up (agent-user.mjs). Server-side helpers and Ask Worca leave it unset.
402
+ asAgent,
398
403
  bin = DEFAULT_BIN,
399
404
  } = o;
400
405
 
@@ -441,6 +446,7 @@ export async function runClaude(o = {}) {
441
446
  appendSubagentSystemPrompt,
442
447
  addDirs,
443
448
  argvInlineLimit,
449
+ asAgent,
444
450
  });
445
451
  }
446
452
 
@@ -505,7 +511,7 @@ export function buildClaudeArgs({
505
511
  if (tools.length) {
506
512
  args.push('--allowedTools', tools.join(','));
507
513
  }
508
- // Model bridge (model-bridge-design.md §5.3): a translated (openai-chat)
514
+ // Model bridge (model-bridge-design.md §5.3): a translated (openai-chat / openai-responses)
509
515
  // model has no server-side web tools, so the runner withholds them outright —
510
516
  // a deny outranks any allow, frontmatter grant included. Absent/empty ⇒
511
517
  // nothing emitted, so every other argv stays byte-identical.
@@ -595,7 +601,7 @@ export function stageClaudeInvocation(opts, { bin = DEFAULT_BIN, limit = ARGV_IN
595
601
  return { ...plan, dir };
596
602
  }
597
603
 
598
- function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, disallowedTools, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, addDirs, argvInlineLimit }) {
604
+ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, disallowedTools, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, addDirs, argvInlineLimit, asAgent }) {
599
605
  return new Promise((resolveP, rejectP) => {
600
606
  // Per-model routing env (design §4.4), prepared BEFORE argv: reserved keys
601
607
  // are re-dropped here defensively — the write path already rejects them, so
@@ -704,6 +710,10 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
704
710
  // drop it — WORCA_ is not an allowlisted prefix — so it is added AFTER).
705
711
  if (guardOn) spawnEnv = { ...(spawnEnv ?? process.env), WORCA_HOST_PID: String(process.pid) };
706
712
 
713
+ // No GitHub credential reaches claude, in any guardrail tier, from a per-project allowlist or a
714
+ // model env alike (src/core/github-credentials.mjs): pushes and PRs are worca's own calls.
715
+ spawnEnv = stripGithubCredentials(spawnEnv ?? process.env);
716
+
707
717
  // Opt-in spawn diagnostics (WORCA_DEBUG_SPAWN, default off — byte-identical spawn
708
718
  // path when unset). Everything here is derived from values already computed above
709
719
  // (safeModelEnv is null or non-empty, so the routing field is either the described
@@ -723,10 +733,20 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
723
733
  safeEmit(onEvent, { type: 'stderr', stream: 'err', text: summary });
724
734
  }
725
735
 
736
+ // Agent isolation (agent-user.mjs): the same command under the agent's uid, via sudo, in its
737
+ // own process group so a stuck agent can still be SIGKILLed through sudo.
738
+ const agentId = asAgent ? agentIdentity() : null;
726
739
  let child;
727
740
  try {
728
- child = spawn(resolved.bin, args, {
741
+ let file = resolved.bin;
742
+ let argv = args;
743
+ if (agentId) {
744
+ if (plan.dir) shareWithAgent(plan.dir, agentId);
745
+ ({ file, args: argv, env: spawnEnv } = agentSpawn(resolved.bin, args, spawnEnv, agentId));
746
+ }
747
+ child = spawn(file, argv, {
729
748
  cwd, stdio: [plan.stdin != null ? 'pipe' : 'ignore', 'pipe', 'pipe'], ...(spawnEnv ? { env: spawnEnv } : {}),
749
+ ...(agentId ? { detached: true } : {}),
730
750
  });
731
751
  } catch (err) {
732
752
  cleanupStaged();
@@ -761,11 +781,14 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
761
781
  }
762
782
  // Escalate if it ignores SIGTERM.
763
783
  setTimeout(() => {
784
+ // Only a sudo still running holds the group: after it exits the pid may be reused.
785
+ const stuck = child.exitCode === null && child.signalCode === null;
764
786
  try {
765
787
  child.kill('SIGKILL');
766
788
  } catch {
767
789
  /* ignore */
768
790
  }
791
+ if (agentId && stuck) killAgentGroup(child.pid, agentId);
769
792
  }, sigkillGraceMs()).unref?.();
770
793
  };
771
794
  if (signal) {
@@ -0,0 +1,139 @@
1
+ // Add a project by cloning a repository into the projects folder (docs/deploy-railway.md).
2
+ // On a hosted worca this replaces a `railway ssh` session: the clone runs in the server,
3
+ // as the server's user, with the read credential for that one command
4
+ // (github-credentials.mjs#githubEnv), and lands in a NEW folder directly under the
5
+ // projects root. Registration is the ordinary add-project step.
6
+ //
7
+ // URLs https only; no credentials inside the URL (use the token variables)
8
+ // allow WORCA_CLONE_ALLOW, e.g. "github.com/acme/*,github.com/you/app"; unset = any
9
+ // where <projects root>/<name>; name defaults to the repository name, is sanitised,
10
+ // and an existing folder is refused, never overwritten
11
+ // size --filter=blob:none (partial clone), a timeout (default 10 minutes), and a
12
+ // failed clone removes its folder
13
+ import { spawn } from 'node:child_process';
14
+ import { existsSync } from 'node:fs';
15
+ import { rm } from 'node:fs/promises';
16
+ import { join, resolve, sep } from 'node:path';
17
+ import { githubEnv, stripGithubCredentials } from './github-credentials.mjs';
18
+
19
+ export const CLONE_TIMEOUT_MS = 10 * 60_000;
20
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
21
+ const SEG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
22
+ const BRANCH_RE = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[A-Za-z0-9._\/-]{1,200}(?<![./])$/;
23
+
24
+ /** A clone refusal: `code` is one of invalid | not-allowed | exists | auth-failed | not-found | timeout | failed. */
25
+ export class CloneError extends Error {
26
+ constructor(code, message) { super(message); this.code = code; }
27
+ }
28
+
29
+ /** WORCA_CLONE_ALLOW -> ["github.com/acme/*", "github.com/you/app"] (lowercased). */
30
+ export function parseCloneAllow(value) {
31
+ return String(value || '').split(',').map((s) => s.trim().toLowerCase().replace(/\/+$/, '')).filter(Boolean);
32
+ }
33
+
34
+ /** True when host/owner/repo matches an allow entry ("host/owner/*" or "host/owner/repo"). Empty list = any. */
35
+ export function cloneAllowed(allow, { host, owner, repo }) {
36
+ if (!allow.length) return true;
37
+ const full = `${host}/${owner}/${repo}`.toLowerCase();
38
+ return allow.some((p) => (p.endsWith('/*') ? full.startsWith(p.slice(0, -1)) : full === p));
39
+ }
40
+
41
+ /**
42
+ * Validate a clone request. Pure (besides existsSync on the target). Returns
43
+ * { url, host, owner, repo, name, dir, branch } or throws CloneError('invalid'|'not-allowed'|'exists').
44
+ */
45
+ export function planClone({ url, branch = null, name = null } = {}, { projectsRoot, env = process.env, exists = existsSync } = {}) {
46
+ if (typeof url !== 'string' || !url.trim()) throw new CloneError('invalid', 'a repository URL is required');
47
+ let u;
48
+ try { u = new URL(url.trim()); } catch { throw new CloneError('invalid', 'not a valid URL'); }
49
+ if (u.protocol !== 'https:') throw new CloneError('invalid', 'only https:// repository URLs are supported');
50
+ if (u.username || u.password) {
51
+ throw new CloneError('invalid', 'the URL contains credentials; remove them and set the GitHub token or App variables where worca is deployed');
52
+ }
53
+ if (u.search || u.hash) throw new CloneError('invalid', 'the URL must not have a query or fragment');
54
+ if (u.port) throw new CloneError('invalid', 'the URL must not name a port');
55
+ const host = u.hostname.toLowerCase();
56
+ const segs = u.pathname.replace(/\/+$/, '').replace(/\.git$/i, '').split('/').filter(Boolean);
57
+ if (segs.length !== 2 || !segs.every((s) => SEG_RE.test(s))) {
58
+ throw new CloneError('invalid', 'the URL must name one repository, like https://github.com/owner/repo');
59
+ }
60
+ const [owner, repo] = segs;
61
+ if (!cloneAllowed(parseCloneAllow(env.WORCA_CLONE_ALLOW), { host, owner, repo })) {
62
+ throw new CloneError('not-allowed', `${host}/${owner}/${repo} is not in WORCA_CLONE_ALLOW`);
63
+ }
64
+ if (branch != null && branch !== '' && (typeof branch !== 'string' || !BRANCH_RE.test(branch.trim()))) {
65
+ throw new CloneError('invalid', 'not a valid branch name');
66
+ }
67
+ const folder = name == null || name === '' ? repo : String(name).trim();
68
+ if (!NAME_RE.test(folder) || folder === '.' || folder === '..') {
69
+ throw new CloneError('invalid', 'the folder name may use letters, digits, ".", "_" and "-" (up to 100)');
70
+ }
71
+ if (!projectsRoot) throw new CloneError('invalid', 'no projects folder is configured');
72
+ const root = resolve(projectsRoot);
73
+ const dir = join(root, folder);
74
+ if (!dir.startsWith(root + sep)) throw new CloneError('invalid', 'the folder must be directly under the projects folder');
75
+ if (exists(dir)) throw new CloneError('exists', `${dir} already exists; pick another folder name`);
76
+ return {
77
+ url: `https://${host}/${owner}/${repo}.git`, host, owner, repo, name: folder, dir,
78
+ branch: branch ? branch.trim() : null,
79
+ };
80
+ }
81
+
82
+ function classify(stderr) {
83
+ const s = String(stderr || '');
84
+ if (/Authentication failed|could not read Username|terminal prompts disabled|403/i.test(s)) {
85
+ return new CloneError('auth-failed', 'GitHub refused the credential: check the token or that the App is installed on this repository');
86
+ }
87
+ if (/Remote branch .* not found/i.test(s)) return new CloneError('not-found', 'that branch does not exist');
88
+ if (/Repository not found|not found|404/i.test(s)) {
89
+ return new CloneError('not-found', 'repository not found, or the credential cannot see it');
90
+ }
91
+ const last = s.trim().split('\n').filter(Boolean).pop() || 'git clone failed';
92
+ return new CloneError('failed', last.replace(/https:\/\/[^@\s]+@/g, 'https://'));
93
+ }
94
+
95
+ /** Run `git clone` for a plan. Resolves the plan; rejects with CloneError after removing the folder. */
96
+ export async function runClone(plan, { timeoutMs = CLONE_TIMEOUT_MS, credential = githubEnv, spawnImpl = spawn } = {}) {
97
+ // GitHub gets the read credential for this one command; any other host gets no GitHub credential.
98
+ const cred = plan.host === 'github.com'
99
+ ? await credential('read', { repo: `${plan.owner}/${plan.repo}` })
100
+ : { env: stripGithubCredentials(process.env), error: null };
101
+ if (cred.error) throw new CloneError('auth-failed', cred.error);
102
+ const args = ['clone', '--filter=blob:none', ...(plan.branch ? ['--branch', plan.branch] : []), '--', plan.url, plan.dir];
103
+ const env = { ...cred.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', LC_ALL: 'C' };
104
+ const { code, stderr, timedOut } = await new Promise((done) => {
105
+ let err = '';
106
+ let child;
107
+ try { child = spawnImpl('git', args, { env, stdio: ['ignore', 'ignore', 'pipe'] }); }
108
+ catch (e) { done({ code: -1, stderr: e.message, timedOut: false }); return; }
109
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } done({ code: -1, stderr: err, timedOut: true }); }, timeoutMs);
110
+ child.stderr?.on('data', (b) => { if (err.length < 64_000) err += b.toString(); });
111
+ child.on('error', (e) => { clearTimeout(timer); done({ code: -1, stderr: err || e.message, timedOut: false }); });
112
+ child.on('close', (c) => { clearTimeout(timer); done({ code: c ?? -1, stderr: err, timedOut: false }); });
113
+ });
114
+ if (code === 0) return plan;
115
+ await rm(plan.dir, { recursive: true, force: true }).catch(() => {});
116
+ if (timedOut) throw new CloneError('timeout', `the clone took longer than ${Math.round(timeoutMs / 60_000)} minutes`);
117
+ throw classify(stderr);
118
+ }
119
+
120
+ /**
121
+ * Plan, clone, register: the one path the API, the CLI and Ask Worca's card share. The
122
+ * project name (= folder name) is checked against registered projects BEFORE cloning, so
123
+ * a clash never leaves a stray folder. Resolves { project, plan }; rejects with CloneError.
124
+ */
125
+ export async function cloneProject(req, { projectsRoot, env = process.env, listProjects, addProject, run = runClone } = {}) {
126
+ const plan = planClone(req, { projectsRoot, env });
127
+ const existing = await listProjects();
128
+ if (existing.some((p) => p && typeof p.name === 'string' && p.name.toLowerCase() === plan.name.toLowerCase())) {
129
+ throw new CloneError('exists', `a project named "${plan.name}" already exists; pick another folder name`);
130
+ }
131
+ await run(plan);
132
+ try {
133
+ const projects = await addProject({ name: plan.name, path: plan.dir });
134
+ return { project: projects.find((p) => p.path === plan.dir) || { name: plan.name, path: plan.dir }, plan };
135
+ } catch (e) {
136
+ await rm(plan.dir, { recursive: true, force: true }).catch(() => {});
137
+ throw new CloneError('failed', e.message);
138
+ }
139
+ }
@@ -16,7 +16,7 @@ import { getDb, prepare, tx } from './db.mjs';
16
16
  import { projectKey } from './store.mjs';
17
17
  import { AUTO_WORKFLOW_ID } from './graph/builtin-workflows.mjs';
18
18
  import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
19
- import { EFFORTS, prepareModelEnv, withTierModelEnv, isSubagentModelValue, subagentModelIssue, BRIDGE_ROUTING_KEYS, bridgeExcludedTools } from './model-env.mjs';
19
+ import { EFFORTS, prepareModelEnv, withTierModelEnv, withProviderModesOff, PROVIDER_MODE_ENV_KEYS, isSubagentModelValue, subagentModelIssue, BRIDGE_ROUTING_KEYS, bridgeExcludedTools, isTranslatedApi } from './model-env.mjs';
20
20
  import { findBridgedEntry, providerReadiness } from './bridge/registry.mjs';
21
21
  import { bridgeBaseUrl, bridgeSecret } from './bridge/server.mjs';
22
22
  import { listGlobalModels, addGlobalModel, removeGlobalModel, hideBuiltinModels, readSettings, memoryDefragModel, setMemoryDefragModel } from './settings.mjs';
@@ -70,11 +70,13 @@ export { EFFORTS };
70
70
  * stored pin on the retired id to the successor, so nothing keeps it here. Opus 5.5
71
71
  * (`claude-opus-5-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
72
72
  * carry no `[1m]` twin. Opus 5.5 replaced Opus 5 (`claude-opus-5`) on 2026-09-22
73
- * (verified to resolve via `claude --model`, CLI 2.1.280); db.mjs V35 moves the
74
- * stored pins the same way.
73
+ * (verified to resolve via `claude --model`, CLI 2.1.280); db.mjs V35 moved the
74
+ * stored pins the same way. Opus 5 came back beside it on 2026-09-23 so both can
75
+ * be picked; V35 is shipped and stays, so pins it already moved stay on Opus 5.5.
75
76
  */
76
77
  export const PREDEFINED_MODELS = [
77
78
  { id: 'claude-opus-5-5', label: 'Opus 5.5', efforts: ['medium', 'high', 'xhigh', 'max'] },
79
+ { id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
78
80
  { id: 'claude-fable-5-1', label: 'Fable 5.1 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
79
81
  { id: 'claude-opus-4-8', label: 'Opus 4.8', efforts: ['medium', 'high', 'xhigh', 'max'] },
80
82
  { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
@@ -291,7 +293,7 @@ export function modelHasBaseUrlRouting(modelId) {
291
293
  * The bridge facts for a model id (model-bridge-design.md §4.2/§8.5), or null
292
294
  * when it is not a bridged entry: `{id, provider, api, upstreamModel,
293
295
  * excludeTools, ready, reason?, message?}`. `excludeTools` are the CLI built-ins
294
- * the runner must withhold (web tools have no chat/completions equivalent);
296
+ * the runner must withhold (web tools have no chat/completions or Responses API equivalent);
295
297
  * `ready` is the provider's sign-in state — a spawn fails fast on it instead
296
298
  * of with an opaque 401 mid-run. Synchronous; never throws.
297
299
  */
@@ -498,6 +500,7 @@ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined
498
500
  export const PREDEFINED_LIST_PRICES = Object.freeze({
499
501
  'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
500
502
  'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5, cacheWrite1h: 8 },
503
+ 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
501
504
  'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
502
505
  'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
503
506
  'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
@@ -582,7 +585,9 @@ export function resolveModelEnv(modelId, { tag } = {}) {
582
585
  for (const k of dropped) {
583
586
  console.warn(`[worca] model ${JSON.stringify(bridged.id)}: dropping env key ${JSON.stringify(k)} (reserved or unresolvable \${VAR} ref)`);
584
587
  }
585
- for (const k of BRIDGE_ROUTING_KEYS) delete extra[k];
588
+ // The bridge owns the CLI's transport too: an entry-level cloud switch would
589
+ // send the CLI past the loopback bridge, so it is dropped and forced off below.
590
+ for (const k of [...BRIDGE_ROUTING_KEYS, ...PROVIDER_MODE_ENV_KEYS]) delete extra[k];
586
591
  const env = {
587
592
  ...extra,
588
593
  ANTHROPIC_BASE_URL: bridgeBaseUrl(bridged.id, { tag }),
@@ -595,7 +600,7 @@ export function resolveModelEnv(modelId, { tag } = {}) {
595
600
  // fails its first call). Its ToolSearch is client-side (schemas come back
596
601
  // as tool_result text), so it works through the translation layer; the
597
602
  // entry's own env may still turn it off.
598
- if (bridged.upstream.api === 'openai-chat' && !('ENABLE_TOOL_SEARCH' in env)) env.ENABLE_TOOL_SEARCH = 'true';
603
+ if (isTranslatedApi(bridged.upstream.api) && !('ENABLE_TOOL_SEARCH' in env)) env.ENABLE_TOOL_SEARCH = 'true';
599
604
  // A bridged id is never a model name the CLI knows, so it assumes a 200k
600
605
  // window and compacts only once the upstream rejects a request — on a 32k
601
606
  // local model that means turns whose reply is cut to a few hundred tokens
@@ -603,7 +608,7 @@ export function resolveModelEnv(modelId, { tag } = {}) {
603
608
  const caps = bridged.upstream.capabilities || {};
604
609
  if (caps.maxPromptTokens && !('CLAUDE_CODE_MAX_CONTEXT_TOKENS' in env)) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(caps.maxPromptTokens);
605
610
  if (caps.maxOutputTokens && !('CLAUDE_CODE_MAX_OUTPUT_TOKENS' in env)) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(caps.maxOutputTokens);
606
- return withTierModelEnv(env, bridged.id);
611
+ return withProviderModesOff(withTierModelEnv(env, bridged.id));
607
612
  }
608
613
  if (entry && entry.env) {
609
614
  rawEnv = entry.env;
@@ -639,8 +644,10 @@ export function resolveModelEnv(modelId, { tag } = {}) {
639
644
  // Endpoint-routed entries also carry the CLI's internal tier keys, pointed at
640
645
  // this entry's own wire id (#422, model-env.mjs#withTierModelEnv) — so the
641
646
  // CLI's session-title / alias / probe calls never fall back to a first-party
642
- // id the endpoint has never heard of. Keys the entry sets itself win.
643
- return withTierModelEnv(env, canonicalId);
647
+ // id the endpoint has never heard of. They also turn the CLI's cloud
648
+ // transports off (withProviderModesOff), which would bypass the base URL.
649
+ // Keys the entry sets itself win.
650
+ return withProviderModesOff(withTierModelEnv(env, canonicalId));
644
651
  }
645
652
 
646
653
  /**
package/src/core/db.mjs CHANGED
@@ -55,7 +55,7 @@ const OPEN_BACKOFF_MS = 15;
55
55
  /** Latest schema version. Bump + append a new migration step when the DDL grows.
56
56
  * Exported so migration tests assert "reached the module's current version"
57
57
  * instead of hardcoding the number — a schema bump then touches no test file. */
58
- export const SCHEMA_VERSION = 35;
58
+ export const SCHEMA_VERSION = 39;
59
59
 
60
60
  /** Absolute path to the database file: <worcaHome>/worca-cc.db. */
61
61
  export function dbPath() {
@@ -834,7 +834,8 @@ const INCREMENTAL_COLUMNS = {
834
834
  outcome: 'TEXT',
835
835
  scheduled_for: 'TEXT', schedule_id: 'TEXT', // v31: scheduled-run provenance (NULL = started by hand)
836
836
  policy_state: 'TEXT', // v32: team-policy run state (JSON: home, sha, overrides, exceeded, deviations, reason)
837
- human_hours: 'REAL NOT NULL DEFAULT 0' }, // v33: Σ pipeline_steps.human_hours (money-saved design §6)
837
+ human_hours: 'REAL NOT NULL DEFAULT 0', // v33: Σ pipeline_steps.human_hours (money-saved design §6)
838
+ started_by: 'TEXT' }, // v36: who started the run (identity.mjs; NULL = before attribution)
838
839
  pipeline_steps: { session_id: 'TEXT', skills: 'TEXT', graphify_count: 'INTEGER',
839
840
  execution_id: 'TEXT', exec_kind: 'TEXT', agent_key: 'TEXT', ended_at: 'TEXT',
840
841
  exec_trigger: 'TEXT', exec_result: 'TEXT', exec_meta: 'TEXT',
@@ -848,12 +849,17 @@ const INCREMENTAL_COLUMNS = {
848
849
  ask_attachments: { kind: "TEXT NOT NULL DEFAULT 'text'", // v27: text | image | binary (#398)
849
850
  mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
850
851
  project_config: { human_in_loop: 'INTEGER NOT NULL DEFAULT 1' }, // v28: the Auto entry's human-in-the-loop switch
851
- diff_comments: { parent_id: 'TEXT REFERENCES diff_comments(id) ON DELETE CASCADE' }, // v29: reply threads; NULL = thread root
852
+ diff_comments: { parent_id: 'TEXT REFERENCES diff_comments(id) ON DELETE CASCADE', // v29: reply threads; NULL = thread root
853
+ author_name: 'TEXT' }, // v37: who wrote it (identity.mjs actor); NULL = before attribution / Ask
854
+ ask_threads: { created_by: 'TEXT' }, // v37: the thread's owner (identity.mjs actor); NULL = ownerless (legacy)
855
+ pipeline_events: { actor: 'TEXT' }, // v38: who did it (identity.mjs actor); NULL = the run itself / before attribution
852
856
  workspaces: { metrics_project: 'TEXT', // v30: team-metrics home (member absolute path); NULL = no home
853
857
  policy_project: 'TEXT' }, // v32: team-policy home (member absolute path); NULL = no home
854
- schedules: { ask_thread_id: 'TEXT', ask_card_id: 'TEXT' }, // v31: the Ask Worca card a series came from
858
+ schedules: { ask_thread_id: 'TEXT', ask_card_id: 'TEXT', // v31: the Ask Worca card a series came from
859
+ created_by: 'TEXT', updated_by: 'TEXT' }, // v39: who made / last changed it (identity.mjs actor)
855
860
  scheduled_runs: { after_kind: 'TEXT', after_id: 'TEXT', after_policy: "TEXT NOT NULL DEFAULT 'done'",
856
- source_from_previous: 'INTEGER NOT NULL DEFAULT 0' }, // v34: run chains
861
+ source_from_previous: 'INTEGER NOT NULL DEFAULT 0', // v34: run chains
862
+ created_by: 'TEXT', updated_by: 'TEXT' }, // v39: who made / last changed it
857
863
  };
858
864
 
859
865
  /** v23: per-loop-wire cycle budgets, the graph-engine twin of
@@ -879,6 +885,17 @@ CREATE TABLE IF NOT EXISTS config_workflow_wires (
879
885
  * several tables (ASK_DDL, DIFF_COMMENTS_DDL) is listed under EACH of them, so a
880
886
  * DB missing only one is healed; repairSchemaGaps de-duplicates at exec time.
881
887
  */
888
+ /** v37: per-person read state of notifications, used only on a shared deployment (a real
889
+ * per-person sign-in, identity.mjs#isSharedIdentity); local installs keep notifications.read_at. */
890
+ const NOTIFICATION_READS_DDL = `
891
+ CREATE TABLE IF NOT EXISTS notification_reads (
892
+ notification_id INTEGER NOT NULL,
893
+ reader TEXT NOT NULL,
894
+ read_at TEXT NOT NULL,
895
+ PRIMARY KEY (notification_id, reader)
896
+ );
897
+ `;
898
+
882
899
  const INCREMENTAL_TABLES = {
883
900
  config_workflow_wires: CONFIG_WORKFLOW_WIRES_DDL,
884
901
  step_questions: STEP_QUESTIONS_DDL,
@@ -897,6 +914,7 @@ const INCREMENTAL_TABLES = {
897
914
  schedules: SCHEDULED_RUNS_DDL,
898
915
  scheduled_runs: SCHEDULED_RUNS_DDL,
899
916
  notifications: SCHEDULED_RUNS_DDL,
917
+ notification_reads: NOTIFICATION_READS_DDL,
900
918
  };
901
919
 
902
920
  /**
@@ -1306,6 +1324,39 @@ function applySchemaV35(db) {
1306
1324
  for (const [from, to] of V35_MODEL_RENAMES) renameStoredModelPins(db, from, to);
1307
1325
  }
1308
1326
 
1327
+ /** v36 (attribution): pipelines.started_by, a plain additive column declared in
1328
+ * INCREMENTAL_COLUMNS, applySchemaV30's shape. NULL on every existing row = started
1329
+ * before attribution existed. */
1330
+ function applySchemaV36(db) {
1331
+ repairSchemaGaps(db, schemaGaps(db));
1332
+ }
1333
+
1334
+ /** v37 (attribution, step 1): diff_comments.author_name, ask_threads.created_by
1335
+ * (INCREMENTAL_COLUMNS) and notification_reads (INCREMENTAL_TABLES), applySchemaV30's
1336
+ * shape. NULL / no rows everywhere existing = before attribution. */
1337
+ function applySchemaV37(db) {
1338
+ repairSchemaGaps(db, schemaGaps(db));
1339
+ }
1340
+
1341
+ /** v38 (attribution, step 3): pipeline_events.actor — who did a human action on a run
1342
+ * (INCREMENTAL_COLUMNS), applySchemaV30's shape. NULL on every existing row. */
1343
+ function applySchemaV38(db) {
1344
+ repairSchemaGaps(db, schemaGaps(db));
1345
+ }
1346
+
1347
+ /** v39 (attribution): schedules/scheduled_runs created_by + updated_by (INCREMENTAL_COLUMNS, the v36
1348
+ * shape), then a backfill: a series or ticket whose stored request carries internal.startedBy (who
1349
+ * scheduled it, recorded since attribution) gets that as created_by. Fenced like v33's backfill. */
1350
+ function applySchemaV39(db) {
1351
+ repairSchemaGaps(db, schemaGaps(db));
1352
+ for (const t of ['schedules', 'scheduled_runs']) {
1353
+ try {
1354
+ db.exec(`UPDATE ${t} SET created_by = json_extract(request, '$.internal.startedBy')
1355
+ WHERE created_by IS NULL AND json_valid(request) AND json_type(request, '$.internal.startedBy') = 'text'`);
1356
+ } catch { /* a hand-seeded table without request/JSON1: attribution is decoration */ }
1357
+ }
1358
+ }
1359
+
1309
1360
  /** Move every stored pin on model id `from` (lower-case) to `to`. Each table
1310
1361
  * is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
1311
1362
  * fs->db import) reach this step without some of them. */
@@ -1697,6 +1748,10 @@ export function migrate(db) {
1697
1748
  if (current < 33) applySchemaV33(db); // money saved: human_hours columns
1698
1749
  if (current < 34) applySchemaV34(db); // run chains: scheduled_runs.after_* + source_from_previous
1699
1750
  if (current < 35) applySchemaV35(db); // Opus 5 pins -> Opus 5.5 (catalog swap)
1751
+ if (current < 36) applySchemaV36(db); // attribution: pipelines.started_by
1752
+ if (current < 37) applySchemaV37(db); // attribution: comment authors, thread owners, per-person reads
1753
+ if (current < 38) applySchemaV38(db); // attribution: who did each human action on a run
1754
+ if (current < 39) applySchemaV39(db); // attribution: schedules/tickets created_by + updated_by
1700
1755
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
1701
1756
  db.exec('COMMIT');
1702
1757
  } catch (err) {
@@ -0,0 +1,33 @@
1
+ // How this worca runs, for Ask Worca's context and rules (docs/deploy-railway.md).
2
+ //
3
+ // local a desktop install: the default, and what nothing else says otherwise
4
+ // container the worca image (WORCA_CONTAINER=1 from docker/Dockerfile, or
5
+ // WORCA_DATA_DIR from a single-volume host) without remote access
6
+ // hosted remote access is on (WORCA_ALLOWED_HOSTS names a public host):
7
+ // people reach it through an identity proxy, not from this machine
8
+ //
9
+ // Pure: reads only `env` and the remote-mode flag the server already computed.
10
+ import { readGithubCredentials } from './github-credentials.mjs';
11
+
12
+ const on = (v) => /^(1|true|yes|on)$/i.test(String(v || '').trim());
13
+
14
+ /** 'local' | 'container' | 'hosted'. */
15
+ export function detectDeployment(env = process.env, { remoteMode = false } = {}) {
16
+ if (remoteMode) return 'hosted';
17
+ if (on(env.WORCA_CONTAINER) || String(env.WORCA_DATA_DIR || '').trim()) return 'container';
18
+ return 'local';
19
+ }
20
+
21
+ /** Which GitHub credential worca holds: 'none' | 'single' | 'split' (src/core/github-credentials.mjs).
22
+ * Names the mode only; never the token. */
23
+ export function githubMode(env = process.env) {
24
+ return readGithubCredentials(env).mode;
25
+ }
26
+
27
+ /** The facts Ask Worca's context header shows about this deployment, or null for a
28
+ * local install (whose header stays exactly as before). */
29
+ export function deploymentFacts(env = process.env, { remoteMode = false, projectsRoot = null } = {}) {
30
+ const deployment = detectDeployment(env, { remoteMode });
31
+ if (deployment === 'local') return null;
32
+ return { deployment, projectsRoot: projectsRoot || null, github: githubMode(env) };
33
+ }
@@ -28,12 +28,21 @@ export class DiffCommentError extends Error {
28
28
  constructor(message) { super(message); this.name = 'DiffCommentError'; }
29
29
  }
30
30
 
31
+ /** A person's name on a 'user' comment only (one line, bounded); Ask comments carry none. */
32
+ function authorNameOf(author, name) {
33
+ if (author !== 'user' || typeof name !== 'string') return null;
34
+ const t = name.trim();
35
+ return t && t.length <= 200 && !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(t) ? t : null;
36
+ }
37
+
31
38
  function rowToComment(r) {
32
39
  return {
33
40
  id: r.id, storeKey: r.store_key, pipelineId: r.pipeline_id,
34
41
  projectKey: r.project_key ?? null, path: r.path, oldPath: r.old_path ?? null,
35
42
  side: r.side, line: r.line_no, lineText: r.line_text ?? '',
36
43
  body: r.body, author: r.author,
44
+ // Who wrote it (identity.mjs actor) for a 'user' comment; null for Ask and pre-v37 rows.
45
+ authorName: r.author_name ?? null,
37
46
  resolved: !!r.resolved, resolvedAt: r.resolved_at ?? null,
38
47
  sentRunId: r.sent_run_id ?? null, createdAt: r.created_at,
39
48
  parentId: r.parent_id ?? null,
@@ -128,7 +137,7 @@ function cleanBody(body) {
128
137
  * have one error type to map onto 400 / tool-error.
129
138
  */
130
139
  export function addDiffComment({
131
- storeKey, pipelineId, patchText, project = null, path, side, line, body, author,
140
+ storeKey, pipelineId, patchText, project = null, path, side, line, body, author, authorName = null,
132
141
  } = {}) {
133
142
  if (patchText == null || String(patchText) === '') {
134
143
  throw new DiffCommentError('this run has no stored diff — comments cannot be created on it');
@@ -148,10 +157,10 @@ export function addDiffComment({
148
157
  getDb();
149
158
  prepare(`INSERT INTO diff_comments
150
159
  (id, store_key, pipeline_id, project_key, path, old_path, side, line_no, line_text,
151
- body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at)
152
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, NULL, NULL, ?)`)
160
+ body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, author_name)
161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, NULL, NULL, ?, ?)`)
153
162
  .run(id, String(storeKey), String(pipelineId), anchor.project, anchor.path, anchor.oldPath,
154
- anchor.side, anchor.line, anchor.lineText, text, author, ts);
163
+ anchor.side, anchor.line, anchor.lineText, text, author, ts, authorNameOf(author, authorName));
155
164
  notify(String(storeKey), String(pipelineId));
156
165
  return getDiffComment(id);
157
166
  }
@@ -166,7 +175,7 @@ export function addDiffComment({
166
175
  * archiving deletes a run's comments, pipeline-delete.mjs.)
167
176
  * @throws {DiffCommentError}
168
177
  */
169
- export function addDiffCommentReply({ parentId, body, author } = {}) {
178
+ export function addDiffCommentReply({ parentId, body, author, authorName = null } = {}) {
170
179
  const parent = getDiffComment(parentId);
171
180
  if (!parent) throw new DiffCommentError('comment not found');
172
181
  if (parent.parentId) throw new DiffCommentError('replies cannot be nested — reply to the thread\'s first comment');
@@ -177,11 +186,11 @@ export function addDiffCommentReply({ parentId, body, author } = {}) {
177
186
  getDb();
178
187
  prepare(`INSERT INTO diff_comments
179
188
  (id, store_key, pipeline_id, project_key, path, old_path, side, line_no, line_text,
180
- body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, parent_id)
181
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`)
189
+ body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, parent_id, author_name)
190
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)`)
182
191
  .run(id, parent.storeKey, parent.pipelineId, parent.projectKey, parent.path, parent.oldPath,
183
192
  parent.side, parent.line, parent.lineText, text, author,
184
- parent.resolved ? 1 : 0, parent.resolvedAt, ts, parent.id);
193
+ parent.resolved ? 1 : 0, parent.resolvedAt, ts, parent.id, authorNameOf(author, authorName));
185
194
  notify(parent.storeKey, parent.pipelineId);
186
195
  return getDiffComment(id);
187
196
  }
@@ -9,13 +9,14 @@ import { spawn } from 'node:child_process';
9
9
  import { mkdtemp, writeFile, rm } from 'node:fs/promises';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
+ import { githubEnv, readGithubCredentials } from './github-credentials.mjs';
12
13
 
13
14
  /** Default runner: spawn `cmd args` in `cwd`, resolve { ok, stdout, stderr, code }. */
14
- function defaultRun(cmd, args, { cwd, timeout = 0 } = {}) {
15
+ function defaultRun(cmd, args, { cwd, timeout = 0, env = null } = {}) {
15
16
  return new Promise((resolve) => {
16
17
  let child;
17
18
  try {
18
- child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
19
+ child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], ...(env ? { env } : {}) });
19
20
  } catch (err) {
20
21
  resolve({ ok: false, stdout: '', stderr: err.message, code: -1 });
21
22
  return;
@@ -160,9 +161,26 @@ export async function hasGh() {
160
161
  return _ghCache;
161
162
  }
162
163
 
164
+ /** "owner/name" of `remote` on github.com in App mode (helps the App find its installation), else null. */
165
+ async function githubRepoOf(projectDir, remote) {
166
+ if (readGithubCredentials().mode !== 'app') return null;
167
+ const r = await _run('git', ['remote', 'get-url', remote], { cwd: projectDir });
168
+ const p = r.ok ? parseRemoteUrl(r.stdout.trim()) : null;
169
+ return p && p.host === 'github.com' ? `${p.owner}/${p.repo}` : null;
170
+ }
171
+
172
+ /** "owner/name" of a github.com PR URL, or null. */
173
+ const ownerRepoOfPrUrl = (url) => { const m = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(String(url || '')); return m ? m[1] : null; };
174
+
175
+ /** "owner/name" from gh's `[HOST/]OWNER/REPO`, or null. */
176
+ const ownerRepo = (repo) => (repo ? String(repo).split('/').slice(-2).join('/') : null);
177
+
163
178
  /** Push the branch to `remote` (default origin) and set upstream. Idempotent; surfaces stderr. */
164
179
  export async function pushBranch(projectDir, branch, remote = 'origin') {
165
- const r = await _run('git', ['push', '-u', remote || 'origin', branch], { cwd: projectDir });
180
+ const r0 = remote || 'origin';
181
+ const cred = await githubEnv('write', { repo: await githubRepoOf(projectDir, r0) });
182
+ if (cred.error) return { ok: false, stderr: cred.error };
183
+ const r = await _run('git', ['push', '-u', r0, branch], { cwd: projectDir, env: cred.env });
166
184
  return { ok: r.ok, stderr: (r.stderr || '').trim() };
167
185
  }
168
186
 
@@ -182,14 +200,16 @@ export async function createPr({ projectDir, base, head, title, body = '', repo
182
200
  const repoArgs = repo ? ['--repo', repo] : [];
183
201
  const args = ['pr', 'create', ...repoArgs, '--base', base, '--head', headRef,
184
202
  '--title', title || head, '--body', body || title || head];
185
- const r = await _run('gh', args, { cwd: projectDir });
203
+ const cred = await githubEnv('write', { repo: ownerRepo(repo) });
204
+ if (cred.error) return { ok: false, error: cred.error };
205
+ const r = await _run('gh', args, { cwd: projectDir, env: cred.env });
186
206
  if (r.ok) {
187
207
  // gh prints the PR URL as the last stdout line.
188
208
  const url = (r.stdout.trim().split(/\r?\n/).pop() || '').trim();
189
209
  return { ok: true, url, existed: false };
190
210
  }
191
211
  if (/already exists/i.test(r.stderr || '')) {
192
- const v = await _run('gh', ['pr', 'view', headRef, ...repoArgs, '--json', 'url', '-q', '.url'], { cwd: projectDir });
212
+ const v = await _run('gh', ['pr', 'view', headRef, ...repoArgs, '--json', 'url', '-q', '.url'], { cwd: projectDir, env: (await githubEnv('read', { repo: ownerRepo(repo) })).env });
193
213
  if (v.ok && v.stdout.trim()) return { ok: true, url: v.stdout.trim(), existed: true };
194
214
  // gh's message ends with the existing PR's URL ("… already exists:\n<url>");
195
215
  // use it when the view selector cannot resolve (e.g. a PR opened from another fork).
@@ -285,7 +305,7 @@ export async function prMergeable({ projectDir, head, repo = null, headOwner = n
285
305
  const selector = prUrl || (head ? prHeadRef(head, headOwner) : '');
286
306
  if (!selector) return 'UNKNOWN';
287
307
  const repoArgs = !prUrl && repo ? ['--repo', repo] : [];
288
- const r = await _run('gh', ['pr', 'view', selector, ...repoArgs, '--json', 'mergeable', '-q', '.mergeable'], { cwd: projectDir });
308
+ const r = await _run('gh', ['pr', 'view', selector, ...repoArgs, '--json', 'mergeable', '-q', '.mergeable'], { cwd: projectDir, env: (await githubEnv('read', { repo: prUrl ? ownerRepoOfPrUrl(prUrl) : ownerRepo(repo) })).env });
289
309
  if (!r.ok) return 'UNKNOWN';
290
310
  return normalizeMergeable(r.stdout.trim());
291
311
  }
@@ -316,7 +336,7 @@ const normalizePr = (pr) => ({
316
336
  export async function findPrForBranch({ projectDir, head, prUrl = null } = {}) {
317
337
  if (!projectDir || !head) return null;
318
338
  if (prUrl) {
319
- const v = await _run('gh', ['pr', 'view', prUrl, '--json', 'number,state,url'], { cwd: projectDir });
339
+ const v = await _run('gh', ['pr', 'view', prUrl, '--json', 'number,state,url'], { cwd: projectDir, env: (await githubEnv('read', { repo: ownerRepoOfPrUrl(prUrl) })).env });
320
340
  if (v.ok) {
321
341
  let obj = null;
322
342
  try { obj = JSON.parse(v.stdout || 'null'); } catch { obj = null; }