@volter/supercode-orchestrator 0.5.54 → 0.5.56

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/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
+ }
@@ -2341,7 +2341,9 @@ function readMailbox(board, address, report) {
2341
2341
  answer = `${clean.slice(0, 2).join(' ')}: ${(r.status === 0 ? r.stdout : r.stderr || r.stdout).trim().slice(-600) || `exit ${r.status}`}`;
2342
2342
  }
2343
2343
  report.mailed.push([envelope.from, argv?.[0] ?? '?']);
2344
- report.answers.push(send(board, address, board.label, envelope.from, answer, `board-answer-${envelope.id}`).catch((error) => { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }));
2344
+ // The answer is filed in the sender's mailbox like every other board mail (D138 step 6): a filing that fails is
2345
+ // reported and leaves the round going, since the verb above is already applied.
2346
+ try { fileMail(board, envelope.from, answer, `board-answer-${envelope.id}`); } catch (error) { report.undelivered.push(['mail', envelope.from, String(error.message).slice(-200)]); }
2345
2347
  }
2346
2348
  }
2347
2349
 
@@ -2565,8 +2567,6 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
2565
2567
  for (const slug of boards) {
2566
2568
  if (stopping) break;
2567
2569
  const report = { board: slug, ms: { fleet: fleetMs }, mailed: [], started: [], queued: [], failed: [], lost: [], crashed: [], timed_out: [], reclaimed: [], auto_blocked: [], promoted: 0, unassigned: [], nonspawnable: [], notified: [], undelivered: [], fleet: fleet.ok ? 'read' : fleet.error };
2568
- // the mailbox's answers in flight (not part of the report the caller prints)
2569
- Object.defineProperty(report, 'answers', { value: [], enumerable: false });
2570
2570
  const path = boardPath(root, slug);
2571
2571
  const marker = `${path}.corrupt-seen`;
2572
2572
  if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
@@ -2610,7 +2610,6 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
2610
2610
  report.error = error.message;
2611
2611
  if (CORRUPT.test(error.message)) writeFileSync(marker, fingerprint(path));
2612
2612
  } finally {
2613
- await Promise.all(report.answers);
2614
2613
  board?.close();
2615
2614
  }
2616
2615
  out.push(report);
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
- const cap = (text, n) => (text && text.length > n ? `${text.slice(0, n)}… (${text.length - n} more characters)` : text);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.54",
3
+ "version": "0.5.56",
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": {