breakaway 1.4.0-main.53 → 1.4.0-main.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.4.0-main.53",
3
+ "version": "1.4.0-main.55",
4
4
  "description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -30,6 +30,7 @@
30
30
  "src/prompt.js",
31
31
  "src/redact.js",
32
32
  "src/repos.js",
33
+ "src/session-report.js",
33
34
  "src/specs.js",
34
35
  "src/versions.js",
35
36
  "prompts/*.md",
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The systems the install supports (CLI-3, decided in BRK-140): macOS, Linux, and Windows through WSL. The commands
3
+ * that write a secret run `npx wrangler` without a shell, and `setup` runs a `#!/bin/sh` script, so on Windows
4
+ * itself (PowerShell or cmd) they stop before doing anything. Where wrangler can't run, the reason it gives is what's
5
+ * reported, not a guess. Pure, so it's tested without Windows or wrangler.
6
+ */
7
+
8
+ export const WSL_URL = 'https://learn.microsoft.com/windows/wsl/install';
9
+
10
+ /**
11
+ * Null on a supported system; on Windows itself, the message that stops `command`.
12
+ *
13
+ * @param {string} command
14
+ * @param {string} platform `process.platform`
15
+ * @returns {string | null}
16
+ */
17
+ export function unsupportedSystem(command, platform) {
18
+ if (platform !== 'win32') return null;
19
+ return `npx breakaway ${command} doesn't run on Windows itself, so nothing was changed. Install WSL (${WSL_URL}), then run it in WSL's terminal: the board supports macOS, Linux, and Windows through WSL.`;
20
+ }
21
+
22
+ /** Colours and wrangler's box-drawing around its messages. */
23
+ const NOISE = /\u001b\[[0-9;]*m|[│┌┐└┘─╭╮╰╯]/gu;
24
+
25
+ /** Lines that say nothing about the failure: wrangler's banner and npm's warnings. */
26
+ const CHATTER = /^(\S+\s+)?wrangler \d+\.\d+|^npm (warn|notice)|^Update available/iu;
27
+
28
+ /** @param {string | null | undefined} text */
29
+ const meaningful = (text) =>
30
+ (text ?? '')
31
+ .replace(NOISE, '')
32
+ .split('\n')
33
+ .map((line) => line.trim())
34
+ .filter((line) => line && !CHATTER.test(line));
35
+
36
+ /**
37
+ * Why a `spawnSync('npx', ['wrangler', …])` failed, in one line: npx missing or not runnable, else wrangler's own
38
+ * error (its `✘ [ERROR]` line, or its last line of output), else its exit code.
39
+ *
40
+ * @param {{ error?: (Error & { code?: string }) | undefined, status: number | null, signal?: string | null, stdout?: string | null, stderr?: string | null }} res
41
+ * @returns {string}
42
+ */
43
+ export function wranglerFailure(res) {
44
+ if (res.error?.code === 'ENOENT')
45
+ return "npx isn't on this machine's PATH; install Node.js 20 or newer, then run this again";
46
+ if (res.error) return `npx couldn't run (${res.error.message})`;
47
+ const stderr = meaningful(res.stderr);
48
+ const stdout = meaningful(res.stdout);
49
+ const error = [...stderr, ...stdout].find((line) => /^✘\s*\[ERROR\]/u.test(line));
50
+ if (error) return `wrangler said: ${error.replace(/^✘\s*\[ERROR\]\s*/u, '')}`;
51
+ const last = (stderr.length ? stderr : stdout).at(-1);
52
+ if (last) return `wrangler said: ${last}`;
53
+ if (res.signal) return `wrangler stopped (${res.signal})`;
54
+ return `wrangler stopped with exit code ${res.status}`;
55
+ }
package/scripts/tasks.mjs CHANGED
@@ -39,10 +39,12 @@ import {
39
39
  textFields,
40
40
  } from './tasks/structure.js';
41
41
  import { looksLikeSecret } from '../src/ping.js';
42
+ import { sessionReport, shortHash, stubText } from '../src/session-report.js';
42
43
  import { promptPathOf } from '../src/repos.js';
43
44
  import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
44
45
  import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
45
46
  import { appInPlace } from './tasks/github-connect.js';
47
+ import { unsupportedSystem, wranglerFailure } from './tasks/platform.js';
46
48
  import { checkInstall } from './tasks/install-check.js';
47
49
  import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
48
50
  import {
@@ -142,6 +144,12 @@ async function ensureBoardInstall(command, health = null) {
142
144
  if (result.warning) console.error(`tasks: ${result.warning}`);
143
145
  }
144
146
 
147
+ /** Stops `command` before it does anything on a system the install doesn't support: Windows outside WSL (CLI-3). */
148
+ function ensureSupportedSystem(command) {
149
+ const message = unsupportedSystem(command, process.platform);
150
+ if (message) fail(message);
151
+ }
152
+
145
153
  /** A file in this checkout, or null when it isn't there. */
146
154
  function readOptional(path) {
147
155
  try {
@@ -623,6 +631,18 @@ async function startSessionLog(t) {
623
631
  );
624
632
  }
625
633
 
634
+ /**
635
+ * In a cloud session, what its claim tells the board about its environment, so the routine that started it reads
636
+ * Verified on Connections (BRK-142): whether a name was set, where the token came from, and the hash of this
637
+ * checkout's copy of the stub. Never a value. Null outside a cloud session.
638
+ */
639
+ async function environmentReport() {
640
+ if (process.env.CLAUDE_CODE_REMOTE !== 'true') return null;
641
+ const path = ['tools/tasks/prompts/stub.md', 'prompts/stub.md'].map((p) => join(REPO, p)).find((p) => existsSync(p));
642
+ const stub = path ? await shortHash(stubText(readFileSync(path, 'utf8'))) : null;
643
+ return sessionReport({ env: process.env, file: fileEnv, named: Boolean(opts.as), stub });
644
+ }
645
+
626
646
  /** The session hook couldn't post this checkout's live output: say so where the agent and the owner see it (BRK-86). */
627
647
  function warnHookFailure() {
628
648
  try {
@@ -1250,10 +1270,12 @@ const commands = {
1250
1270
  async claim() {
1251
1271
  // The board refuses a task of another repository than the one sent (--all doesn't widen a claim).
1252
1272
  const { slug: repo } = await checkoutRepo();
1273
+ const session = await environmentReport();
1253
1274
  const { task } = await call('POST', `tasks/${enc(need(args[0], 'task'))}/claim`, {
1254
1275
  agent: agent(),
1255
1276
  force: Boolean(opts.force),
1256
1277
  ...(repo ? { repo } : {}),
1278
+ ...(session ? { session } : {}),
1257
1279
  });
1258
1280
  markSession(task);
1259
1281
  await startSessionLog(task);
@@ -1539,6 +1561,8 @@ const commands = {
1539
1561
  /** Every connection with its state and, for each that isn't working, the fix (IDEA-14). Read only. */
1540
1562
  async connections() {
1541
1563
  const STATE = { working: 'Working', attention: 'Needs attention', off: 'Not connected' };
1564
+ // A routine's row says whether a session it started has reported back (BRK-142).
1565
+ const READING = { verified: 'Verified', unverified: 'Not verified yet' };
1542
1566
  const GROUP = {
1543
1567
  repos: 'Repositories',
1544
1568
  cloudflare: 'Cloudflare',
@@ -1568,7 +1592,7 @@ const commands = {
1568
1592
  out.push('', GROUP[group] ?? group);
1569
1593
  }
1570
1594
  const when = c.at ? ` (${c.at.slice(0, 16).replace('T', ' ')})` : '';
1571
- out.push(` ${STATE[c.state].padEnd(16)} ${c.name}: ${c.detail}${when}`);
1595
+ out.push(` ${(READING[c.reading] ?? STATE[c.state] ?? c.state).padEnd(16)} ${c.name}: ${c.detail}${when}`);
1572
1596
  if (c.fix) out.push(` ${''.padEnd(16)} Fix: ${c.fix}`);
1573
1597
  if (c.fix && c.link) out.push(` ${''.padEnd(16)} ${c.link}`);
1574
1598
  }
@@ -1578,6 +1602,7 @@ const commands = {
1578
1602
  });
1579
1603
  },
1580
1604
  async setup() {
1605
+ ensureSupportedSystem('setup');
1581
1606
  const clientId = setting('CLIENT_ID');
1582
1607
  const secret = setting('SECRET');
1583
1608
  if (!clientId || !secret)
@@ -1620,6 +1645,7 @@ const commands = {
1620
1645
  * values. The new values are saved before the server switches, so they can't be lost.
1621
1646
  */
1622
1647
  async 'rotate-sync'() {
1648
+ ensureSupportedSystem('rotate-sync');
1623
1649
  const env = readEnvFile();
1624
1650
  if (!readSetting('SECRET', { file: env })) fail(`${ENV_FILE} has no sync secret; this is for the owner's machine.`);
1625
1651
  const health = await call('GET', 'health');
@@ -1654,6 +1680,7 @@ const commands = {
1654
1680
  },
1655
1681
  /** Owner: a new API token. Every browser is signed out; cloud environments need the new one. */
1656
1682
  async 'rotate-token'() {
1683
+ ensureSupportedSystem('rotate-token');
1657
1684
  await ensureBoardInstall('rotate-token');
1658
1685
  const env = readEnvFile();
1659
1686
  const token = randomBytes(32).toString('base64url');
@@ -1686,6 +1713,7 @@ const commands = {
1686
1713
  * the install's ROUTINES secret (BREAKAWAY_ROUTINES with breakaway's prefix), JSON keyed by slug, merged with this machine's copy of the others.
1687
1714
  */
1688
1715
  async 'agents-connect'() {
1716
+ ensureSupportedSystem('agents-connect');
1689
1717
  const { repos, default: fallback } = await call('GET', 'repos');
1690
1718
  const slug = opts.repo ? String(opts.repo).toLowerCase() : fallback;
1691
1719
  if (!repos.some((r) => r.slug === slug)) fail(unknownRepo(slug, repos));
@@ -1706,13 +1734,11 @@ const commands = {
1706
1734
  fail("that isn't a routine token; generate one in the routine's API trigger.");
1707
1735
  if (!others) {
1708
1736
  if (!updateSecretsStore({ ROUTINE_URL: url, ROUTINE_TOKEN: token }))
1709
- fail("couldn't update the Secrets Store (is wrangler logged in?).");
1737
+ fail("couldn't update the Secrets Store (the reason is above), so the routine isn't connected.");
1710
1738
  } else {
1711
1739
  const next = { ...others, [slug]: { url, token } };
1712
1740
  if (!updateSecretsStore({ ROUTINES: JSON.stringify(next) }))
1713
- fail(
1714
- `couldn't update the Secrets Store (is wrangler logged in, and does ${secretName(installConfig(), 'ROUTINES')} exist?).`,
1715
- );
1741
+ fail("couldn't update the Secrets Store (the reason is above), so the routine isn't connected.");
1716
1742
  writePrivate(ROUTINES_FILE, `${JSON.stringify(next, null, 2)}\n`);
1717
1743
  }
1718
1744
  console.log(
@@ -1726,6 +1752,7 @@ const commands = {
1726
1752
  * the App's ID, key, and webhook secret, and pipe them into the Secrets Store.
1727
1753
  */
1728
1754
  async 'github-connect'() {
1755
+ ensureSupportedSystem('github-connect');
1729
1756
  const code = need(args[0], 'code');
1730
1757
  // Before the code is traded: it works once, and the keys it gives have to go into the board's own secrets.
1731
1758
  await ensureBoardInstall('github-connect');
@@ -2162,8 +2189,10 @@ async function promptAnswers() {
2162
2189
  async function removeRepo(slug, signer) {
2163
2190
  const { default: fallback } = await call('GET', 'repos');
2164
2191
  // Dropping a routine this machine connected rewrites the ROUTINES secret: check before anything changes.
2165
- if (existsSync(ROUTINES_FILE) && JSON.parse(readFileSync(ROUTINES_FILE, 'utf8'))[slug])
2192
+ if (existsSync(ROUTINES_FILE) && JSON.parse(readFileSync(ROUTINES_FILE, 'utf8'))[slug]) {
2193
+ ensureSupportedSystem('repos remove');
2166
2194
  await ensureBoardInstall('repos remove');
2195
+ }
2167
2196
  const res = await call('DELETE', `repos/${enc(slug)}`, { by: signer, ...(opts.force ? { force: true } : {}) });
2168
2197
  const lines = [
2169
2198
  `Took ${res.removed.slug} (${res.removed.github}) off the board. Its tasks stay, readable, and its slug and prefixes (${res.removed.areas.map((a) => a.prefix).join(', ')}) stay its own.`,
@@ -2184,7 +2213,7 @@ async function removeRepo(slug, signer) {
2184
2213
  delete next[res.removed.slug];
2185
2214
  if (!updateSecretsStore({ ROUTINES: JSON.stringify(next) }))
2186
2215
  lines.push(
2187
- `Couldn't drop its routine from the Secrets Store (is wrangler logged in?); the board doesn't use it any more.`,
2216
+ `Couldn't drop its routine from the Secrets Store (the reason is above); the board doesn't use it any more.`,
2188
2217
  );
2189
2218
  else {
2190
2219
  writePrivate(ROUTINES_FILE, `${JSON.stringify(next, null, 2)}\n`);
@@ -2266,7 +2295,7 @@ function updateSecretsStore(values) {
2266
2295
  { cwd: REPO, encoding: 'utf8' },
2267
2296
  );
2268
2297
  if (list.status !== 0) {
2269
- console.error(`tasks: couldn't list the Secrets Store (is wrangler logged in?)`);
2298
+ console.error(`tasks: couldn't list the Secrets Store: ${wranglerFailure(list)}`);
2270
2299
  return false;
2271
2300
  }
2272
2301
  const ids = Object.fromEntries(
@@ -2286,7 +2315,7 @@ function updateSecretsStore(values) {
2286
2315
  { cwd: REPO, encoding: 'utf8', input: value },
2287
2316
  );
2288
2317
  if (res.status !== 0) {
2289
- console.error(`tasks: couldn't update ${name} in the Secrets Store`);
2318
+ console.error(`tasks: couldn't update ${name} in the Secrets Store: ${wranglerFailure(res)}`);
2290
2319
  ok = false;
2291
2320
  }
2292
2321
  }
@@ -2307,7 +2336,7 @@ function updateWorkerSecrets(install, values) {
2307
2336
  input: value,
2308
2337
  });
2309
2338
  if (res.status !== 0) {
2310
- console.error(`tasks: couldn't set ${binding} on the Worker ${install.worker} (is wrangler logged in?)`);
2339
+ console.error(`tasks: couldn't set ${binding} on the Worker ${install.worker}: ${wranglerFailure(res)}`);
2311
2340
  ok = false;
2312
2341
  }
2313
2342
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A cloud session's report on its own environment (BRK-142, docs/specs/IDEA-33-onboarding-hardening.md): the CLI
3
+ * sends it with a board-started agent's claim, and the routine that started the session reads Verified on
4
+ * Connections. Only yes/no facts and a hash of a public file are sent, never a value: whether a name was set,
5
+ * where the token came from, and the sha-256 of the checkout's copy of the stub. Pure, so it's tested without a
6
+ * Worker; the CLI builds a report with `sessionReport`, the board checks it with `checkReport`
7
+ * and `judgeStub` when it arrives, and reads it back with `reportProblems`.
8
+ */
9
+
10
+ /** Where the session's token came from: the cloud environment's API credential, a variable, or a tasks.env file. */
11
+ export const TOKEN_SOURCES = Object.freeze(['credential', 'variable', 'file']);
12
+
13
+ /** The stub's text the way it's hashed on both sides: line endings and trailing space don't count. */
14
+ export const stubText = (text) => String(text).replace(/\r\n/gu, '\n').trim();
15
+
16
+ /**
17
+ * The report a cloud session's CLI sends with its claim, or null outside a cloud session. `env` is the environment,
18
+ * `file` the tasks.env settings, `named` whether --as named the agent, and `stub` the hash of the checkout's
19
+ * stub (null when the checkout has none).
20
+ * @param {{ env: Record<string, string | undefined>, file?: Record<string, string>, named?: boolean, stub?: string | null }} input
21
+ */
22
+ export function sessionReport({ env, file = {}, named = false, stub = null }) {
23
+ if (env.CLAUDE_CODE_REMOTE !== 'true') return null;
24
+ const token = env.BREAKAWAY_TOKEN ? 'variable' : file.BREAKAWAY_TOKEN ? 'file' : 'credential';
25
+ return { token, agent: Boolean(named || env.BREAKAWAY_AGENT || file.BREAKAWAY_AGENT), stub: stub ?? null };
26
+ }
27
+
28
+ /**
29
+ * The report as the board keeps it, or null when it isn't one: only the known facts, so nothing else a client
30
+ * sends is ever stored.
31
+ * @returns {{ token: string, agent: boolean, stub: string | null } | null}
32
+ */
33
+ export function checkReport(input) {
34
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
35
+ if (!TOKEN_SOURCES.includes(input.token) || typeof input.agent !== 'boolean') return null;
36
+ const stub = typeof input.stub === 'string' && /^[0-9a-f]{16}$/u.test(input.stub) ? input.stub : null;
37
+ return { token: input.token, agent: input.agent, stub };
38
+ }
39
+
40
+ /**
41
+ * The checkout's stub against the board's, both hashes: `same`, `different`, or `missing` (the checkout has none),
42
+ * and null when the board has no copy to compare with, so the stub isn't judged.
43
+ * @returns {'same' | 'different' | 'missing' | null}
44
+ */
45
+ export function judgeStub(stub, boardStub) {
46
+ if (!boardStub) return null;
47
+ if (!stub) return 'missing';
48
+ return stub === boardStub ? 'same' : 'different';
49
+ }
50
+
51
+ /**
52
+ * What's wrong in a kept report, each with its fix: no agent name, the token in a variable or a file instead of
53
+ * the API credential, and a stub that's missing from the checkout or differs from the board's (`stub`, judgeStub's
54
+ * verdict). `slug` names the repository and `host` the board in the fixes.
55
+ * @param {{ token: string, agent: boolean, stub: string | null }} report
56
+ * @param {{ slug: string, host: string }} board
57
+ * @returns {{ what: string, fix: string }[]}
58
+ */
59
+ export function reportProblems(report, { slug, host }) {
60
+ const out = [];
61
+ if (!report.agent)
62
+ out.push({
63
+ what: 'it had no agent name (BREAKAWAY_AGENT)',
64
+ fix: 'Make the routine’s instructions the stub from the Agents view (Copy stub): it sends each agent to the prompt that sets BREAKAWAY_AGENT to the name the board gave it.',
65
+ });
66
+ if (report.token !== 'credential')
67
+ out.push({
68
+ what:
69
+ report.token === 'variable'
70
+ ? 'it read the board’s token from BREAKAWAY_TOKEN, a variable anyone using the environment can see'
71
+ : 'it read the board’s token from a tasks.env file',
72
+ fix: `In the routine’s cloud environment on claude.ai, add the token as an API credential for ${host} and remove BREAKAWAY_TOKEN: the proxy then adds it, and the token never enters the session.`,
73
+ });
74
+ if (report.stub === 'different' || report.stub === 'missing')
75
+ out.push({
76
+ what:
77
+ report.stub === 'different'
78
+ ? 'its checkout’s copy of the stub differs from the board’s'
79
+ : 'its checkout has no copy of the stub',
80
+ fix: `Bring ${slug}’s copy of the board’s files up to date (npx breakaway repos init ${slug} --update opens a pull request; in the board’s own repository, update the board to the same release), then paste the stub from the Agents view (Copy stub) as the routine’s instructions.`,
81
+ });
82
+ return out;
83
+ }
84
+
85
+ /** The first 16 hex digits of the sha-256 of `text`, with Web Crypto (the Worker and Node alike). */
86
+ export async function shortHash(text) {
87
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
88
+ return [...new Uint8Array(digest)]
89
+ .map((b) => b.toString(16).padStart(2, '0'))
90
+ .join('')
91
+ .slice(0, 16);
92
+ }