@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
@@ -0,0 +1,107 @@
1
+ // GitHub App mode: worca holds an App ID and private key and mints a short-lived
2
+ // installation token for EACH git/gh operation (github-credentials.mjs#githubEnv).
3
+ //
4
+ // WORCA_GH_APP_ID the App's numeric id
5
+ // WORCA_GH_APP_KEY_FILE path to the PEM private key (preferred), or
6
+ // WORCA_GH_APP_KEY_B64 base64 of the PEM, where only variables exist
7
+ // WORCA_GH_APP_INSTALLATION_ID optional; else resolved from the repository, or
8
+ // the App's only installation
9
+ //
10
+ // The token lives for at most an hour and is never cached, written or logged:
11
+ // minted, handed to one command's env, dropped. A run lasting days is fine, since
12
+ // only its short fetch/push/PR calls need a credential. Each token is scoped down
13
+ // to the role: read = contents/metadata/pull requests read; write adds contents
14
+ // and pull requests write.
15
+ import { createPrivateKey, createSign } from 'node:crypto';
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ const API = 'https://api.github.com';
19
+ const HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'worca' };
20
+ const PERMISSIONS = {
21
+ read: { contents: 'read', metadata: 'read', pull_requests: 'read' },
22
+ write: { contents: 'write', metadata: 'read', pull_requests: 'write' },
23
+ };
24
+
25
+ const val = (v) => (typeof v === 'string' ? v.trim() : '');
26
+
27
+ /** True when the App variables are present (the key itself is loaded lazily). Pure. */
28
+ export function appConfigured(env = process.env) {
29
+ return /^\d+$/.test(val(env.WORCA_GH_APP_ID)) && !!(val(env.WORCA_GH_APP_KEY_FILE) || val(env.WORCA_GH_APP_KEY_B64));
30
+ }
31
+
32
+ /** { appId, key (KeyObject), installationId|null } or throws with a message that names no secret. */
33
+ export function loadAppConfig(env = process.env, { readFile = readFileSync } = {}) {
34
+ const appId = val(env.WORCA_GH_APP_ID);
35
+ if (!/^\d+$/.test(appId)) throw new Error('WORCA_GH_APP_ID must be the App\'s numeric id');
36
+ let pem;
37
+ if (val(env.WORCA_GH_APP_KEY_FILE)) {
38
+ try { pem = readFile(val(env.WORCA_GH_APP_KEY_FILE), 'utf8'); } catch (e) { throw new Error(`cannot read WORCA_GH_APP_KEY_FILE (${e.code || 'error'})`); }
39
+ } else if (val(env.WORCA_GH_APP_KEY_B64)) {
40
+ pem = Buffer.from(val(env.WORCA_GH_APP_KEY_B64), 'base64').toString('utf8');
41
+ } else {
42
+ throw new Error('set WORCA_GH_APP_KEY_FILE or WORCA_GH_APP_KEY_B64');
43
+ }
44
+ let key;
45
+ try { key = createPrivateKey(pem); } catch { throw new Error('the App private key is not a valid PEM key'); }
46
+ if (key.asymmetricKeyType !== 'rsa') throw new Error('the App private key must be an RSA key');
47
+ const inst = val(env.WORCA_GH_APP_INSTALLATION_ID);
48
+ if (inst && !/^\d+$/.test(inst)) throw new Error('WORCA_GH_APP_INSTALLATION_ID must be numeric');
49
+ return { appId, key, installationId: inst || null };
50
+ }
51
+
52
+ const b64u = (x) => Buffer.from(typeof x === 'string' ? x : JSON.stringify(x)).toString('base64url');
53
+
54
+ /** The App's own JWT (RS256): issued 60 s in the past for clock drift, valid 9 minutes. */
55
+ export function appJwt({ appId, key }, nowMs = Date.now()) {
56
+ const now = Math.floor(nowMs / 1000);
57
+ const head = `${b64u({ alg: 'RS256', typ: 'JWT' })}.${b64u({ iat: now - 60, exp: now + 540, iss: appId })}`;
58
+ const sig = createSign('RSA-SHA256').update(head).sign(key).toString('base64url');
59
+ return `${head}.${sig}`;
60
+ }
61
+
62
+ async function api(fetchImpl, method, path, jwt, body) {
63
+ let res;
64
+ try {
65
+ res = await fetchImpl(`${API}${path}`, {
66
+ method, headers: { ...HEADERS, Authorization: `Bearer ${jwt}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
67
+ ...(body ? { body: JSON.stringify(body) } : {}),
68
+ signal: AbortSignal.timeout(20_000),
69
+ });
70
+ } catch (e) {
71
+ throw new Error(`GitHub API unreachable (${e.name === 'TimeoutError' ? 'timeout' : e.code || e.name})`);
72
+ }
73
+ let data = null;
74
+ try { data = await res.json(); } catch { /* not JSON */ }
75
+ return { status: res.status, data };
76
+ }
77
+
78
+ const OWNER_REPO_RE = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
79
+
80
+ /** Which installation: the configured id, else the repository's, else the App's only one. */
81
+ export async function resolveInstallation(cfg, { repo = null, fetchImpl = fetch, now = Date.now() } = {}) {
82
+ if (cfg.installationId) return cfg.installationId;
83
+ const jwt = appJwt(cfg, now);
84
+ if (repo && OWNER_REPO_RE.test(repo)) {
85
+ const r = await api(fetchImpl, 'GET', `/repos/${repo}/installation`, jwt);
86
+ if (r.status === 200 && r.data?.id) return String(r.data.id);
87
+ if (r.status === 404) throw new Error(`the GitHub App is not installed on ${repo}`);
88
+ throw new Error(`cannot find the App installation for ${repo} (HTTP ${r.status})`);
89
+ }
90
+ const r = await api(fetchImpl, 'GET', '/app/installations', jwt);
91
+ if (r.status !== 200 || !Array.isArray(r.data)) throw new Error(`cannot list the App's installations (HTTP ${r.status})`);
92
+ if (r.data.length === 1) return String(r.data[0].id);
93
+ throw new Error(r.data.length ? 'the App has several installations: set WORCA_GH_APP_INSTALLATION_ID' : 'the GitHub App is not installed anywhere');
94
+ }
95
+
96
+ /** A fresh installation token for `role`, scoped down to it. Never cached. */
97
+ export async function mintInstallationToken(cfg, { role = 'read', repo = null, fetchImpl = fetch, now = Date.now() } = {}) {
98
+ const installationId = await resolveInstallation(cfg, { repo, fetchImpl, now });
99
+ const r = await api(fetchImpl, 'POST', `/app/installations/${installationId}/access_tokens`, appJwt(cfg, now),
100
+ { permissions: PERMISSIONS[role === 'write' ? 'write' : 'read'] });
101
+ if (r.status === 201 && typeof r.data?.token === 'string') return { token: r.data.token, expiresAt: r.data.expires_at || null };
102
+ const why = r.status === 401 ? 'the App ID or private key was rejected'
103
+ : r.status === 404 ? `installation ${installationId} not found`
104
+ : r.status === 422 ? `the App lacks the ${role} permissions it asked for`
105
+ : `HTTP ${r.status}`;
106
+ throw new Error(`cannot mint a GitHub App token: ${why}`);
107
+ }
@@ -0,0 +1,93 @@
1
+ // GitHub credentials: which one worca holds, and the env for ONE git or gh call.
2
+ //
3
+ // GH_TOKEN / GITHUB_TOKEN single mode (today): one token for everything
4
+ // WORCA_GH_READ_TOKEN split mode: clone and fetch
5
+ // WORCA_GH_WRITE_TOKEN split mode: push and pull requests
6
+ // WORCA_GH_APP_* App mode (github-app.mjs): a token minted per call
7
+ //
8
+ // Two rules (plans: "Worca hosting: projects, GitHub identity, Ask Worca"):
9
+ // - no agent ever gets a credential: stripGithubCredentials() runs on every
10
+ // claude and script spawn, in every guardrail tier and every mode;
11
+ // - worca's own git and gh calls get the credential for their role in THAT
12
+ // call's env only (credentialEnv), through a credential helper that reads a
13
+ // variable only worca sets. No global `gh auth setup-git` is needed.
14
+ // credentialEnv is pure (token modes); githubEnv also mints in App mode.
15
+ import { appConfigured, loadAppConfig, mintInstallationToken } from './github-app.mjs';
16
+
17
+ /** Every variable that carries a GitHub credential. Never reaches an agent. */
18
+ export const GITHUB_CREDENTIAL_KEYS = Object.freeze([
19
+ 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN',
20
+ 'WORCA_GH_READ_TOKEN', 'WORCA_GH_WRITE_TOKEN', 'WORCA_GIT_TOKEN',
21
+ 'WORCA_GH_APP_ID', 'WORCA_GH_APP_KEY_FILE', 'WORCA_GH_APP_KEY_B64', 'WORCA_GH_APP_INSTALLATION_ID',
22
+ ]);
23
+
24
+ const val = (v) => (typeof v === 'string' ? v.trim() : '');
25
+
26
+ /** A copy of `env` without any GitHub credential. */
27
+ export function stripGithubCredentials(env) {
28
+ const out = { ...env };
29
+ for (const k of GITHUB_CREDENTIAL_KEYS) delete out[k];
30
+ return out;
31
+ }
32
+
33
+ /** { mode: 'none'|'single'|'split'|'app', read, write }: tokens by role (null when none, and
34
+ * always in App mode, where githubEnv mints one per call). Split mode falls back to the
35
+ * single token for a role it leaves unset. App mode wins over both. */
36
+ export function readGithubCredentials(env = process.env) {
37
+ if (appConfigured(env)) return { mode: 'app', read: null, write: null };
38
+ const single = val(env.GH_TOKEN) || val(env.GITHUB_TOKEN) || null;
39
+ const r = val(env.WORCA_GH_READ_TOKEN) || null;
40
+ const w = val(env.WORCA_GH_WRITE_TOKEN) || null;
41
+ const mode = r || w ? 'split' : single ? 'single' : 'none';
42
+ return { mode, read: r || single, write: w || single };
43
+ }
44
+
45
+ // The helper git runs for github.com: it answers `get` with the token worca put in
46
+ // WORCA_GIT_TOKEN for this one call, and nothing for store/erase.
47
+ export const GIT_CREDENTIAL_HELPER =
48
+ '!f() { test "$1" = get || exit 0; echo username=x-access-token; echo "password=$WORCA_GIT_TOKEN"; }; f';
49
+ const HELPER_KEY = 'credential.https://github.com.helper';
50
+
51
+ /**
52
+ * The env for one git or gh call that needs `role` ('read' | 'write'). Every
53
+ * credential is removed from `base`; the role's token (if any) is added back as
54
+ * GH_TOKEN for gh and, for git, as a github.com credential helper configured via
55
+ * GIT_CONFIG_* (appended after any the caller already set). The first helper
56
+ * entry is empty, which clears helpers from the user's and system config for
57
+ * github.com, so the token worca chose is the one used. With no token: the
58
+ * credential-free env, and git falls back to the user's own config, as before.
59
+ */
60
+ export function credentialEnv(role, base = process.env) {
61
+ const creds = readGithubCredentials(base);
62
+ return withToken(base, role === 'write' ? creds.write : creds.read);
63
+ }
64
+
65
+ /**
66
+ * credentialEnv for any mode: in App mode a fresh installation token for `role` is minted
67
+ * for this one call (`repo` "owner/name" helps find the installation). Never throws:
68
+ * `{ env, error }`, where a failed mint leaves a credential-free env and says why.
69
+ */
70
+ export async function githubEnv(role, { base = process.env, repo = null, fetchImpl = fetch, now = Date.now() } = {}) {
71
+ if (readGithubCredentials(base).mode !== 'app') return { env: credentialEnv(role, base), error: null };
72
+ try {
73
+ const { token } = await mintInstallationToken(loadAppConfig(base), { role, repo, fetchImpl, now });
74
+ return { env: withToken(base, token), error: null };
75
+ } catch (e) {
76
+ return { env: stripGithubCredentials(base), error: e.message };
77
+ }
78
+ }
79
+
80
+ function withToken(base, token) {
81
+ const env = stripGithubCredentials(base);
82
+ if (!token) return env;
83
+ const n = Number.parseInt(env.GIT_CONFIG_COUNT, 10);
84
+ const i = Number.isInteger(n) && n > 0 ? n : 0;
85
+ env[`GIT_CONFIG_KEY_${i}`] = HELPER_KEY;
86
+ env[`GIT_CONFIG_VALUE_${i}`] = '';
87
+ env[`GIT_CONFIG_KEY_${i + 1}`] = HELPER_KEY;
88
+ env[`GIT_CONFIG_VALUE_${i + 1}`] = GIT_CREDENTIAL_HELPER;
89
+ env.GIT_CONFIG_COUNT = String(i + 2);
90
+ env.WORCA_GIT_TOKEN = token;
91
+ env.GH_TOKEN = token;
92
+ return env;
93
+ }
@@ -39,7 +39,8 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
39
39
  import {
40
40
  runClaude, MOCK_WRITER_ROLES, MOCK_ROLE_CLARIFY, MOCK_ROLE_DECOMPOSER,
41
41
  } from '../claude-runner.mjs';
42
- import { planPath, reviewPath, writeStepQuestions, writeClarify } from '../artifacts.mjs';
42
+ import { planPath, reviewPath, writeStepQuestions, writeClarify, appendAuditById } from '../artifacts.mjs';
43
+ import { byActor } from '../identity.mjs';
43
44
  import { readReview, classifyAskPayload } from '../protocol.mjs';
44
45
  import { prepareFormAsk, formAnswerValidator, downgradeQuestion } from '../ask-forms.mjs';
45
46
  import {
@@ -771,7 +772,9 @@ export async function runClarifierExecution(ctx) {
771
772
  agentKey: node?.key, nodeId: node?.id, questions: gate.ask,
772
773
  });
773
774
  await writeClarify(ctx.pipelineId, { questions: gate.ask });
774
- const answerRow = { kind: 'form', form: gate.ask.form, version: gate.ask.version, values };
775
+ const formBy = clarifyAnswerer(ctx, `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`);
776
+ const answerRow = { kind: 'form', form: gate.ask.form, version: gate.ask.version, values, ...(formBy ? { answeredBy: formBy } : {}) };
777
+ auditClarify(ctx, meta, node, formBy);
775
778
  await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
776
779
  agentKey: node?.key, nodeId: node?.id, answers: answerRow,
777
780
  });
@@ -804,10 +807,13 @@ export async function runClarifierExecution(ctx) {
804
807
  });
805
808
  answers = normalizeAnswers(answerPayload, questions);
806
809
  if (ctx.pipelineId) {
810
+ const by = clarifyAnswerer(ctx, `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`);
811
+ const row = { answers, ...(by ? { answeredBy: by } : {}) };
807
812
  await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
808
- agentKey: node?.key, nodeId: node?.id, answers: { answers },
813
+ agentKey: node?.key, nodeId: node?.id, answers: row,
809
814
  });
810
- await writeClarify(ctx.pipelineId, { answers: { answers } });
815
+ await writeClarify(ctx.pipelineId, { answers: row });
816
+ auditClarify(ctx, meta, node, by);
811
817
  }
812
818
  }
813
819
 
@@ -816,6 +822,18 @@ export async function runClarifierExecution(ctx) {
816
822
  return { outputs: publishable(ports, outputs), questions, answers, sessionId, prompt, warnings };
817
823
  }
818
824
 
825
+ /** Who answered a clarifier question (the orchestrator's answeredBy seam), or null. */
826
+ function clarifyAnswerer(ctx, id) {
827
+ try { const by = typeof ctx.answeredBy === 'function' ? ctx.answeredBy(id) : null; return typeof by === 'string' && by ? by : null; }
828
+ catch { return null; }
829
+ }
830
+
831
+ /** The audit line for a human clarifier answer (actor stored always; named unless 'local'). */
832
+ function auditClarify(ctx, meta, node, by) {
833
+ if (!by || !ctx.pipelineId) return;
834
+ appendAuditById(ctx.pipelineId, `${meta?.displayName || node?.key || 'Clarify'}: questions answered${byActor(by)}.`, { actor: by });
835
+ }
836
+
819
837
  // ── flow executors (pure engine: instant, $0, no process spawn) ───────────────
820
838
 
821
839
  /** Write a file, creating its directory. Synchronous on purpose: the flow cards run
@@ -19,6 +19,8 @@ import { readVerdict, missingVerdictWarning } from './exec-io.mjs';
19
19
  import { AWAIT_PORT, PARAMS_PORT } from '../../shared/graph/constants.mjs';
20
20
  import { DEFAULT_EXIT_CODES, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, SCRIPT_RUNTIMES, pythonMissingSentence, overlayWiredParams } from '../../shared/graph/script-meta.mjs';
21
21
  import { probePython } from './python-probe.mjs';
22
+ import { stripGithubCredentials } from '../github-credentials.mjs';
23
+ import { agentIdentity, agentSpawn, killAgentGroupSync } from '../agent-user.mjs';
22
24
 
23
25
  const CHILD_PATH = fileURLToPath(new URL('./script-child.mjs', import.meta.url));
24
26
  /** The `python` harness (workbench spec §7), spawned as `<python> -u worca_script.py <program.py>`. */
@@ -152,7 +154,8 @@ export function envForShell(envelope, baseEnv = process.env) {
152
154
  * environment than the agents beside it. Scrub off: the server's env (D11). */
153
155
  export function scriptBaseEnv(claudeOpts, platform = process.platform) {
154
156
  const scrubbed = buildSpawnEnv(claudeOpts?.envScrub, claudeOpts?.envAllowlist);
155
- return scrubbed ? { ...scrubbedEnv(platform), ...scrubbed } : process.env;
157
+ // Never a GitHub credential, like the agents (src/core/github-credentials.mjs).
158
+ return stripGithubCredentials(scrubbed ? { ...scrubbedEnv(platform), ...scrubbed } : process.env);
156
159
  }
157
160
 
158
161
  export function parseFrame(text) {
@@ -247,9 +250,17 @@ export function killTree(child, platform = process.platform) {
247
250
  }
248
251
  return;
249
252
  }
253
+ // A script under the agent's uid (agent-user.mjs): the server can signal sudo but not the script, so the
254
+ // group goes down through sudo too. Only while sudo still runs: after it exits the pid may be reused.
255
+ const agent = agentOf.get(child);
256
+ const alive = child.exitCode === null && child.signalCode === null;
250
257
  try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* gone */ } }
258
+ if (agent && alive) killAgentGroupSync(child.pid, agent);
251
259
  }
252
260
 
261
+ /** Children spawned under the agent's uid -> that identity (for killTree). */
262
+ const agentOf = new WeakMap();
263
+
253
264
  /** How long a killed spawn may keep its pipes before we stop waiting for `'close'`. */
254
265
  const KILL_GRACE_MS = 2000;
255
266
 
@@ -271,15 +282,25 @@ process.on('exit', () => { for (const [child, platform] of live) killTree(child,
271
282
  export function spawnScript({ file, args, cwd, env, stdin = null, timeoutMs, signal, onLine, stdoutMode, platform = process.platform }) {
272
283
  return new Promise((resolve, reject) => {
273
284
  const started = Date.now();
274
- const child = spawn(file, args, {
285
+ // Scripts run the agents' code (tests, builds): under the agent's uid when the container has one.
286
+ const agent = platform !== 'win32' ? agentIdentity() : null;
287
+ let spawnFile = file;
288
+ let spawnArgs = args;
289
+ let spawnEnv = env;
290
+ if (agent) {
291
+ try { ({ file: spawnFile, args: spawnArgs, env: spawnEnv } = agentSpawn(file, args, env || process.env, agent)); }
292
+ catch (err) { reject(err); return; }
293
+ }
294
+ const child = spawn(spawnFile, spawnArgs, {
275
295
  cwd,
276
- env,
296
+ env: spawnEnv,
277
297
  detached: platform !== 'win32',
278
298
  windowsHide: true,
279
299
  windowsVerbatimArguments: platform === 'win32' && stdoutMode === 'capture',
280
300
  stdio: [stdin == null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
281
301
  });
282
302
  live.set(child, platform);
303
+ if (agent) agentOf.set(child, agent);
283
304
  const capture = createCapture();
284
305
  const partial = { out: '', err: '' };
285
306
  let frameOut = '';
@@ -40,6 +40,7 @@ const NORMAL_PROTECTED = [
40
40
  '*.pem', '*.key', // TLS / private key material
41
41
  'id_rsa', 'id_ed25519', // bare SSH keys checked into odd places
42
42
  '*.p12', '*.pfx', // bundled cert+key stores
43
+ '//run/secrets/**', // container secrets (docker/compose `secrets:`); `//` = absolute
43
44
  ];
44
45
 
45
46
  // Irreversible-publication commands no pipeline role ever needs.
@@ -0,0 +1,83 @@
1
+ // Who started this: attribution, not authorisation (everyone signed in keeps the
2
+ // same rights). The first source that applies wins, and nothing is ever guessed:
3
+ //
4
+ // 1. the identity the server VERIFIED for this request (a Cloudflare Access
5
+ // token, remote-access.mjs -> req.worcaUser)
6
+ // 2. WORCA_IDENTITY_HEADER: a header an identity proxy in front sets (oauth2-proxy,
7
+ // Tailscale, an IAP). Read only when named: a header means nothing unless the
8
+ // proxy verifies it and strips any incoming copy, so unset = ignored even if sent
9
+ // 3. WORCA_IDENTITY_NAME: a name the operator declares (a self-hoster with no proxy)
10
+ // 4. 'local': the CLI, in-container callers, a local install
11
+ //
12
+ // Pure: reads only the request and `env`.
13
+
14
+ const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,64}$/;
15
+ // A display name or email: printable, one line, bounded. It lands in PR bodies and the UI.
16
+ const VALUE_RE = /^[^\u0000-\u001f\u007f-\u009f\u2028\u2029<>]{1,200}$/;
17
+
18
+ const clean = (v) => {
19
+ const s = typeof v === 'string' ? v.trim() : '';
20
+ return VALUE_RE.test(s) ? s : null;
21
+ };
22
+
23
+ /** { name, source: 'access'|'header'|'operator'|'local' } for this request. */
24
+ export function resolveIdentity(req, env = process.env) {
25
+ const verified = clean(req?.worcaUser?.email);
26
+ if (verified) return { name: verified, source: 'access' };
27
+ const header = String(env.WORCA_IDENTITY_HEADER || '').trim();
28
+ if (HEADER_NAME_RE.test(header)) {
29
+ const v = req?.headers?.[header.toLowerCase()];
30
+ const name = clean(Array.isArray(v) ? v[0] : v);
31
+ if (name) return { name, source: 'header' };
32
+ }
33
+ const operator = clean(env.WORCA_IDENTITY_NAME);
34
+ if (operator) return { name: operator, source: 'operator' };
35
+ return { name: 'local', source: 'local' };
36
+ }
37
+
38
+ /** The startedBy a run records: the resolved name ('local' when nothing else applies). */
39
+ export function startedByOf(req, env = process.env) {
40
+ return resolveIdentity(req, env).name;
41
+ }
42
+
43
+ /** The PR body footer naming who started the run, or '' when nobody in particular did. */
44
+ export function prAttributionFooter(startedBy) {
45
+ const who = clean(startedBy);
46
+ return who && who !== 'local' ? `\n\n---\nStarted by ${who} via worca` : '';
47
+ }
48
+
49
+ /** Who did this: the actor string an action records ('local' when nothing applies). */
50
+ export function actorOf(req, env = process.env) {
51
+ return resolveIdentity(req, env).name;
52
+ }
53
+
54
+ /** The name an action's actor shows as in text (audit lines, exports), or '' for 'local' /
55
+ * unknown / invalid values — the caller then says nothing about who. */
56
+ export function actorLabel(actor) {
57
+ const who = clean(actor);
58
+ return who && who !== 'local' ? who : '';
59
+ }
60
+
61
+ /** " by <name>" for an audit line, or '' when nobody in particular did it. */
62
+ export function byActor(actor) {
63
+ const who = actorLabel(actor);
64
+ return who ? ` by ${who}` : '';
65
+ }
66
+
67
+ /** True for a real per-person sign-in (a verified token or a named trusted header): the
68
+ * deployments where "who" can have more than one answer, so ownership and per-person
69
+ * state apply. 'operator' and 'local' are one person by definition. */
70
+ export function isSharedIdentity(source) {
71
+ return source === 'access' || source === 'header';
72
+ }
73
+
74
+ const PLATFORM_NAMES = { slack: 'Slack', telegram: 'Telegram', discord: 'Discord', teams: 'Teams', msteams: 'Teams' };
75
+
76
+ /** The actor for a chat command: "ada via Slack" (sanitised like every other value), or
77
+ * "someone via Slack" when the platform gave no usable name. */
78
+ export function chatActor({ platform, userName, userId } = {}) {
79
+ const p = String(platform || '').trim().toLowerCase();
80
+ const via = PLATFORM_NAMES[p] || clean(platform) || 'chat';
81
+ const who = clean(userName) || clean(userId != null ? String(userId) : '') || 'someone';
82
+ return clean(`${who} via ${via}`) || `someone via ${via}`;
83
+ }
@@ -0,0 +1,127 @@
1
+ # Worca team metrics: pull-request events for the delivery timeline.
2
+ #
3
+ # Writes one small file per pull request, .worca-metrics/prs/<number>.json, to the
4
+ # worca-metrics branch whenever a PR is opened, reopened or closed, so the Team metrics
5
+ # Timeline knows when work merged without anyone needing the GitHub CLI. Run it by hand
6
+ # ("Run workflow") to backfill the PRs updated in the last N days.
7
+ #
8
+ # Installed by `worca metrics pr-workflow`; see docs/team-metrics.md ("Merge tracking").
9
+ # Safe on pull_request_target: it never checks out or runs code from the pull request,
10
+ # it only reads the event's metadata. It does nothing when the repository has no
11
+ # worca-metrics branch. Like Worca itself, it needs worca-metrics to be exempt from
12
+ # branch protection.
13
+ name: Worca metrics · PR events
14
+
15
+ on:
16
+ pull_request_target:
17
+ types: [opened, reopened, closed]
18
+ workflow_dispatch:
19
+ inputs:
20
+ days:
21
+ description: Backfill the pull requests updated in the last N days
22
+ default: '90'
23
+
24
+ permissions:
25
+ contents: write
26
+ pull-requests: read
27
+
28
+ concurrency:
29
+ group: worca-metrics-pr-events
30
+ cancel-in-progress: false
31
+
32
+ jobs:
33
+ record:
34
+ runs-on: ubuntu-latest
35
+ timeout-minutes: 10
36
+ steps:
37
+ - uses: actions/github-script@v8
38
+ with:
39
+ script: |
40
+ const BRANCH = 'worca-metrics';
41
+ const DIR = '.worca-metrics/prs';
42
+ const { owner, repo } = context.repo;
43
+ const headRef = async () => (await github.rest.git.getRef({ owner, repo, ref: `heads/${BRANCH}` })).data.object.sha;
44
+ try { await headRef(); } catch (e) {
45
+ if (e.status === 404) { core.notice(`No ${BRANCH} branch here: team metrics are not enabled, nothing to record.`); return; }
46
+ throw e;
47
+ }
48
+ // The team's attribution choice (set when team metrics was enabled): with "none", no
49
+ // author is recorded, as for the run records.
50
+ let attribution = 'git-user';
51
+ try {
52
+ const cfg = await github.rest.repos.getContent({ owner, repo, path: '.worca-metrics/config.json', ref: BRANCH });
53
+ attribution = JSON.parse(Buffer.from(cfg.data.content, 'base64').toString('utf8')).attribution || attribution;
54
+ } catch (e) { core.info(`No readable .worca-metrics/config.json (${e.status || e.message}); recording authors.`); }
55
+ const clean = (s) => String(s || '').replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, ' ').trim().slice(0, 200);
56
+ // Who made the PR, the way Worca names the author of a run: the git author of most of its
57
+ // commits (name, plus a key = sha256("worca:" + email), never the address itself). Git
58
+ // identities are the same on every host; the GitHub login is kept only as a fallback.
59
+ // Machine identities (Worca's own run commits, orchestrator@local) are skipped.
60
+ const who = new Map(); // PR number → { name, key }
61
+ const personKey = (email) => {
62
+ const e = String(email || '').trim().toLowerCase();
63
+ if (!e.includes('@') || /@local$/.test(e)) return null;
64
+ return require('crypto').createHash('sha256').update(`worca:${e}`).digest('hex').slice(0, 16);
65
+ };
66
+ const loadAuthors = async (numbers) => {
67
+ for (let i = 0; i < numbers.length; i += 50) {
68
+ const batch = numbers.slice(i, i + 50);
69
+ const q = `query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { ${batch.map((n, j) => `p${j}: pullRequest(number: ${Number(n)}) { commits(first: 100) { nodes { commit { author { name email } } } } }`).join(' ')} } }`;
70
+ let data;
71
+ try { data = await github.graphql(q, { owner, repo }); } catch (e) { data = e.data; core.info(`Some commit authors could not be read: ${e.message}`); }
72
+ batch.forEach((n, j) => {
73
+ const nodes = data?.repository?.[`p${j}`]?.commits?.nodes || [];
74
+ const tally = new Map(); // key → { n, names: Map(name → n) }
75
+ for (const c of nodes) {
76
+ const a = c?.commit?.author;
77
+ const key = personKey(a?.email);
78
+ if (!key) continue;
79
+ const t = tally.get(key) || { n: 0, names: new Map() };
80
+ t.n += 1;
81
+ if (a.name) t.names.set(a.name, (t.names.get(a.name) || 0) + 1);
82
+ tally.set(key, t);
83
+ }
84
+ const top = [...tally.entries()].sort((x, y) => y[1].n - x[1].n)[0];
85
+ if (top) who.set(n, { key: top[0], name: [...top[1].names.entries()].sort((x, y) => y[1] - x[1])[0]?.[0] || null });
86
+ });
87
+ }
88
+ };
89
+ const toEvent = (pr) => ({
90
+ v: 1, kind: 'pr', repo: `${owner}/${repo}`, number: pr.number, url: pr.html_url, title: clean(pr.title),
91
+ head: pr.head.ref, base: pr.base.ref, author: attribution === 'none' ? null : (pr.user && pr.user.login) || null,
92
+ authorName: attribution === 'none' ? null : clean(who.get(pr.number)?.name) || null,
93
+ authorKey: attribution === 'none' ? null : who.get(pr.number)?.key || null,
94
+ state: pr.merged_at ? 'MERGED' : pr.state === 'closed' ? 'CLOSED' : 'OPEN',
95
+ createdAt: pr.created_at, mergedAt: pr.merged_at || null, closedAt: pr.closed_at || null, updatedAt: pr.updated_at,
96
+ });
97
+ let prs = [];
98
+ if (context.eventName === 'workflow_dispatch') {
99
+ const days = Math.max(1, Math.min(3650, Number(context.payload.inputs?.days) || 90));
100
+ const since = Date.now() - days * 86400000;
101
+ outer: for await (const page of github.paginate.iterator(github.rest.pulls.list, { owner, repo, state: 'all', sort: 'updated', direction: 'desc', per_page: 100 })) {
102
+ for (const pr of page.data) { if (Date.parse(pr.updated_at) < since) break outer; prs.push(pr); }
103
+ }
104
+ } else prs = [context.payload.pull_request];
105
+ prs = prs.filter((pr) => pr && pr.head && pr.head.ref);
106
+ if (attribution !== 'none') await loadAuthors(prs.map((pr) => pr.number));
107
+ for (let i = 0; i < prs.length; i += 200) {
108
+ const chunk = prs.slice(i, i + 200);
109
+ const tree = chunk.map((pr) => ({ path: `${DIR}/${pr.number}.json`, mode: '100644', type: 'blob', content: `${JSON.stringify(toEvent(pr))}\n` }));
110
+ const message = chunk.length === 1 ? `worca-metrics: PR #${chunk[0].number} ${toEvent(chunk[0]).state.toLowerCase()}` : `worca-metrics: ${chunk.length} PR events`;
111
+ for (let attempt = 1; ; attempt++) {
112
+ const parent = await headRef();
113
+ const base = (await github.rest.git.getCommit({ owner, repo, commit_sha: parent })).data.tree.sha;
114
+ const next = (await github.rest.git.createTree({ owner, repo, base_tree: base, tree })).data.sha;
115
+ if (next === base) { core.info('Already up to date.'); break; }
116
+ const commit = (await github.rest.git.createCommit({ owner, repo, message, tree: next, parents: [parent] })).data.sha;
117
+ try {
118
+ await github.rest.git.updateRef({ owner, repo, ref: `heads/${BRANCH}`, sha: commit, force: false });
119
+ core.info(`${message} (${chunk.length} file${chunk.length === 1 ? '' : 's'})`);
120
+ break;
121
+ } catch (e) {
122
+ // 422: someone (a teammate's Worca) pushed in between. Rebuild on the new tip.
123
+ if (e.status !== 422 || attempt >= 5) throw e;
124
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
125
+ }
126
+ }
127
+ }