@volter/supercode-orchestrator 0.5.53 → 0.5.55
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/bin/runtime.mjs +6 -1
- package/board/cli.mjs +11 -8
- package/board/credential-guard.mjs +41 -0
- package/board/engine.mjs +7 -1
- package/board/store.mjs +4 -1
- package/installed-apps.mjs +7 -4
- package/package.json +1 -1
package/bin/runtime.mjs
CHANGED
|
@@ -478,7 +478,12 @@ async function appsMain(argv) {
|
|
|
478
478
|
process.stderr.write(`no rh2-agent is installed for ${opts.profile}: install it with apps install rh2-agent --profile ${opts.profile}\n`);
|
|
479
479
|
process.exit(1);
|
|
480
480
|
}
|
|
481
|
-
const
|
|
481
|
+
const program = rh2Program();
|
|
482
|
+
if (!program) {
|
|
483
|
+
process.stderr.write('apps mcp: no rh2 CLI in an absolute PATH folder or ~/.local/bin\n');
|
|
484
|
+
process.exit(127);
|
|
485
|
+
}
|
|
486
|
+
const child = spawn(program, ['mcp'], { stdio: 'inherit', env: { ...process.env, ...rh2AgentEnv(record) } });
|
|
482
487
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => child.kill(signal));
|
|
483
488
|
child.on('error', (error) => { process.stderr.write(`apps mcp: ${error.message}\n`); process.exit(127); });
|
|
484
489
|
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
|
package/board/cli.mjs
CHANGED
|
@@ -50,13 +50,16 @@ import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose,
|
|
|
50
50
|
import { evaluate } from './expr.mjs';
|
|
51
51
|
import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
|
|
52
52
|
|
|
53
|
+
// Text a verb stores, read from --file/--body-file PATH (`-` for stdin) when given: a shell never expands what it
|
|
54
|
+
// never sees, as it does `$(...)` and `$VAR` in an argument.
|
|
55
|
+
const textFrom = (file, inline) => file === undefined ? inline : readFileSync(file === '-' ? 0 : String(file), 'utf8');
|
|
53
56
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
54
57
|
|
|
55
58
|
The board's verbs, with their flags:
|
|
56
59
|
init [--backing sqlite|files|ztrack] [--document <arcs.md>]
|
|
57
60
|
migrate --backing ztrack --document <arcs.md> --status-map <json>
|
|
58
61
|
boards list|create|rm|switch|show|rename|set-default-workdir|export|import
|
|
59
|
-
create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
62
|
+
create <title> [--body | --body-file PATH|-] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
|
|
60
63
|
[--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
|
|
61
64
|
[--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
|
|
62
65
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
@@ -64,14 +67,14 @@ The board's verbs, with their flags:
|
|
|
64
67
|
set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
65
68
|
reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
|
|
66
69
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
67
|
-
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
70
|
+
comment <id> (<text> | --file PATH|-) [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
68
71
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
69
72
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
70
73
|
pause <id> --reason <why> resume <id> cancel <id> --reason <why>
|
|
71
74
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
72
75
|
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
73
76
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
74
|
-
specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
77
|
+
specify <id> [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
75
78
|
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
76
79
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
77
80
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
@@ -437,7 +440,7 @@ export async function runWorkflow(argv) {
|
|
|
437
440
|
if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
|
|
438
441
|
// --goal / --no-goal are the create's words; without either, the workflow's initial decides
|
|
439
442
|
const created = withBoard((b) => engine.createCard(b, {
|
|
440
|
-
title: rest.join(' '), body: args.body, assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
443
|
+
title: rest.join(' '), body: textFrom(args['body-file'], args.body), assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
441
444
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
442
445
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
443
446
|
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? null,
|
|
@@ -700,8 +703,8 @@ export async function runWorkflow(argv) {
|
|
|
700
703
|
}
|
|
701
704
|
|
|
702
705
|
case 'comment': {
|
|
703
|
-
need(2, 'a card id and the text');
|
|
704
|
-
let text = rest.slice(1).join(' ');
|
|
706
|
+
need(args.file !== undefined ? 1 : 2, 'a card id and the text (or --file PATH, - for stdin)');
|
|
707
|
+
let text = textFrom(args.file, rest.slice(1).join(' '));
|
|
705
708
|
if (args['max-len']) text = text.slice(0, Number(args['max-len']));
|
|
706
709
|
const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text));
|
|
707
710
|
say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
|
|
@@ -829,8 +832,8 @@ export async function runWorkflow(argv) {
|
|
|
829
832
|
if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
|
|
830
833
|
const results = [];
|
|
831
834
|
for (const t of targets) {
|
|
832
|
-
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined || args.machine !== undefined)) {
|
|
833
|
-
const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, machine: args.machine ?? null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
835
|
+
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args['body-file'] !== undefined || args.workspace !== undefined || args.branch !== undefined || args.machine !== undefined)) {
|
|
836
|
+
const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: textFrom(args['body-file'], args.body) ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, machine: args.machine ?? null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
834
837
|
results.push({ id: t, specified: true, status: moved.to });
|
|
835
838
|
} else if (verb === 'decompose' && args.child?.length) {
|
|
836
839
|
const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A comment, a card's title or body, or a verb's payload that looks like a credential or environment dump is refused
|
|
2
|
+
// before it is stored, naming why (never the value). The test is the Teams upload redactor's (@volter/teams/redact:
|
|
3
|
+
// gitleaks' rule set and this machine's custody values), plus the shape of an environment listing: several NAME=value
|
|
4
|
+
// lines in a row. A shell that expanded `$(env)` or a pasted .env into a comment once put live keys on a card, where
|
|
5
|
+
// every reader, every launch prompt and the board's own remote received them.
|
|
6
|
+
import { redactText } from '@volter/teams/redact';
|
|
7
|
+
|
|
8
|
+
const ENV_LINE = /^\s*(?:export\s+|declare\s+-x\s+)?[A-Z][A-Z0-9_]{2,}=\S/;
|
|
9
|
+
const ENV_LINES = 5;
|
|
10
|
+
|
|
11
|
+
/** Why `text` looks like a credential or environment dump, or null. */
|
|
12
|
+
export function credentialDumpIn(text) {
|
|
13
|
+
if (typeof text !== 'string' || text.length < 8) return null;
|
|
14
|
+
const lines = text.split('\n').filter((line) => ENV_LINE.test(line)).length;
|
|
15
|
+
if (lines >= ENV_LINES) return `${lines} NAME=value lines, the shape of an environment listing`;
|
|
16
|
+
const redacted = redactText(text);
|
|
17
|
+
if (redacted === text) return null;
|
|
18
|
+
const before = new Set(text.match(/\[redacted:[^\]]+\]/g) ?? []);
|
|
19
|
+
const found = [...new Set((redacted.match(/\[redacted:[^\]]+\]/g) ?? []).filter((marker) => !before.has(marker)))]
|
|
20
|
+
.map((marker) => marker.slice('[redacted:'.length, -1));
|
|
21
|
+
return found.length ? `it holds what reads as a secret (${found.slice(0, 5).join(', ')})` : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stringsIn = (value, at = '') => typeof value === 'string' ? [[at, value]]
|
|
25
|
+
: Array.isArray(value) ? value.flatMap((item, index) => stringsIn(item, `${at}[${index}]`))
|
|
26
|
+
: value && typeof value === 'object' ? Object.entries(value).flatMap(([key, item]) => stringsIn(item, at ? `${at}.${key}` : key))
|
|
27
|
+
: [];
|
|
28
|
+
|
|
29
|
+
/** Throw, naming `what` and why, when any string in `value` looks like a dump; nothing is stored. */
|
|
30
|
+
export function refuseCredentialDump(what, value) {
|
|
31
|
+
for (const [at, text] of stringsIn(value)) {
|
|
32
|
+
const why = credentialDumpIn(text);
|
|
33
|
+
if (why) throw Object.assign(new Error(`${what}${at ? ` (${at})` : ''} was not stored: ${why}. Remove the values and point to where they are kept instead.`), { code: 'credential_dump' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `text`, or a line saying it was withheld, for text stored before this check that goes into a prompt. */
|
|
38
|
+
export function withheldIfDump(text) {
|
|
39
|
+
const why = credentialDumpIn(text);
|
|
40
|
+
return why ? `[withheld from this prompt: ${why}]` : text;
|
|
41
|
+
}
|
package/board/engine.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
15
15
|
import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
|
|
16
16
|
import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
17
17
|
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
18
|
+
import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
|
|
18
19
|
|
|
19
20
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
20
21
|
export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
|
|
@@ -182,6 +183,7 @@ export function bodyTasks(body) {
|
|
|
182
183
|
export function createCard(board, spec) {
|
|
183
184
|
const title = String(spec.title ?? '').trim();
|
|
184
185
|
if (!title) throw new Error('a card has a title');
|
|
186
|
+
refuseCredentialDump('the card', { title, body: spec.body ?? null });
|
|
185
187
|
const steps = spec.subtaskOf ? [] : bodyTasks(spec.body).tasks;
|
|
186
188
|
if (steps.length) spec = { ...spec, body: bodyTasks(spec.body).body };
|
|
187
189
|
const ws = parseWorkspace(spec.workspace ?? 'scratch');
|
|
@@ -402,6 +404,7 @@ export function setCaller(fn) { callerRoles = fn; }
|
|
|
402
404
|
export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
|
|
403
405
|
const entry = await loadWorkflow(board.root);
|
|
404
406
|
if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
|
|
407
|
+
refuseCredentialDump(name === 'comment' ? `the comment on ${id}` : `${name.replace(/_/g, '-')} on ${id}`, payload);
|
|
405
408
|
refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
406
409
|
const evidencePhase = ['complete', 'request_review', 'request_state'].includes(name) ? completionPhase(board, id, entry, name, payload, roles ?? callerRoles(board.db, id)) : null;
|
|
407
410
|
if (evidencePhase) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload, evidencePhase) };
|
|
@@ -633,6 +636,7 @@ export const promote = (board, id, { actor = 'user', reason = null, force = fals
|
|
|
633
636
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
634
637
|
export const archive = (board, id, { roles, reason } = {}) => act(board, id, 'archive', { roles, payload: { reason } });
|
|
635
638
|
export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, machine = null, author = 'user', roles } = {}) {
|
|
639
|
+
refuseCredentialDump(`${id}'s specification`, { title, body });
|
|
636
640
|
let declaration = null;
|
|
637
641
|
if (workspace != null || branch != null || machine != null) {
|
|
638
642
|
const task = getTask(board.db, id);
|
|
@@ -797,7 +801,9 @@ export function showCard(db, id) {
|
|
|
797
801
|
};
|
|
798
802
|
}
|
|
799
803
|
|
|
800
|
-
|
|
804
|
+
// Stored text goes into a launch prompt only once it passes the credential check: what was stored before the check
|
|
805
|
+
// existed is withheld there, naming why, so no prompt carries it.
|
|
806
|
+
const cap = (text, n) => { text = text ? withheldIfDump(text) : text; return text && text.length > n ? `${text.slice(0, n)}… (${text.length - n} more characters)` : text; };
|
|
801
807
|
|
|
802
808
|
/** What a session starting on a card reads first: the card, its parents' results, earlier attempts and the thread. */
|
|
803
809
|
export function cardContext(db, id) {
|
package/board/store.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
|
|
11
11
|
// native transaction boundary and retries an optimistic conflict.
|
|
12
12
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
13
|
+
import { redactValue } from '@volter/teams/redact';
|
|
13
14
|
import { randomBytes } from 'node:crypto';
|
|
14
15
|
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
15
16
|
import { hostname } from 'node:os';
|
|
@@ -318,8 +319,10 @@ export function event(db, taskId, kind, payload = null, runId = null) {
|
|
|
318
319
|
}
|
|
319
320
|
}
|
|
320
321
|
const said = actor && !(payload && 'by' in payload) ? { ...(payload ?? {}), by: actor } : payload;
|
|
322
|
+
// An event carries what the board observed (a command's stderr, a session's screen), which a verb's text check never
|
|
323
|
+
// saw: a secret in it is stored as its marker (@volter/teams/redact), never as its value.
|
|
321
324
|
const r = db.prepare('INSERT INTO task_events (task_id, run_id, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
322
|
-
.run(taskId, runId, kind, said ? JSON.stringify(said) : null, now());
|
|
325
|
+
.run(taskId, runId, kind, said ? JSON.stringify(redactValue(said)) : null, now());
|
|
323
326
|
return Number(r.lastInsertRowid);
|
|
324
327
|
}
|
|
325
328
|
|
package/installed-apps.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `apps/` folder (owner-only); no `platforms:` block attaches an integration. Uninstalling on the other side revokes the
|
|
5
5
|
// token, and the channel stops; `remove` forgets the record here.
|
|
6
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { delimiter, join } from 'node:path';
|
|
7
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
|
|
10
10
|
export const APPS_DIR = 'apps';
|
|
@@ -65,10 +65,13 @@ function rh2Origin(origin) {
|
|
|
65
65
|
return /^https:\/\/|^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(String(origin ?? ''));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** The `rh2` CLI: on this process's PATH, else where RH2's installer puts it
|
|
68
|
+
/** The `rh2` CLI, by absolute path: in an absolute folder on this process's PATH, else where RH2's installer puts it
|
|
69
|
+
* (`~/.local/bin`); null when neither has it. A relative PATH entry is never searched (a session's working directory
|
|
70
|
+
* may be a checkout that carries its own `rh2`, which would then run with the agent's token), and neither is a bare
|
|
71
|
+
* name, which the OS would look up the same way. */
|
|
69
72
|
export function rh2Program() {
|
|
70
|
-
const folders = [...String(process.env.PATH ?? '').split(delimiter), join(homedir(), '.local', 'bin')].filter(
|
|
71
|
-
return folders.map((folder) => join(folder, 'rh2')).find((file) => existsSync(file)) ??
|
|
73
|
+
const folders = [...String(process.env.PATH ?? '').split(delimiter), join(homedir(), '.local', 'bin')].filter((folder) => folder && isAbsolute(folder));
|
|
74
|
+
return folders.map((folder) => join(folder, 'rh2')).find((file) => existsSync(file)) ?? null;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/** The environment the `rh2` CLI reads to act as an installed agent: its RH2 origin, its organization and its token. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.55",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|