@worca/app 1.4.0-rc.1 → 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.
- package/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- 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
|
|
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:
|
|
813
|
+
agentKey: node?.key, nodeId: node?.id, answers: row,
|
|
809
814
|
});
|
|
810
|
-
await writeClarify(ctx.pipelineId, { 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
|
-
|
|
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
|
-
|
|
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 = '';
|
package/src/core/guardrails.mjs
CHANGED
|
@@ -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
|
+
}
|