@assuredloop/orca-bot-kit 0.27.0 → 0.28.0

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/src/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0034). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0046). Orca's CLI
2
2
  // changes often, so the kit reads `--json` and never the human text, and keeps
3
3
  // the parsing in one place.
4
4
  //
@@ -106,6 +106,15 @@ export function orcaTrouble() {
106
106
  if (asked.error) return `could not run Orca at ${orcaCli()}: ${asked.error.message}. ${where}`;
107
107
 
108
108
  const answer = parse(asked.stdout);
109
+ // A CLI that dies with no answer has not reached the app at all: it can fail
110
+ // on its own, as on a current folder macOS will not let it read, while Orca
111
+ // runs (#556). So what it said is passed on, and nobody is sent to start Orca.
112
+ if (answer === undefined && asked.status !== 0) {
113
+ const exit = asked.status ?? `signal ${asked.signal}`;
114
+ return `the Orca CLI at ${orcaCli()} failed before it answered status (exit ${exit}; `
115
+ + `stdout ${said(asked.stdout)}; stderr ${said(asked.stderr)}). `
116
+ + 'Fix what it says and run the command again, or point OBK_ORCA at another Orca CLI.';
117
+ }
109
118
  // Orca knowing what is wrong beats the kit guessing, so its own words go
110
119
  // first when it gave any.
111
120
  if (answer !== null && typeof answer === 'object' && answer.ok !== true && answer.error?.message) {
@@ -319,7 +328,7 @@ const CLIENT_KILL_MS = 3000;
319
328
  *
320
329
  * Orca's window re-reads only when its runtime says the projects changed, and
321
330
  * `setup-update` and `setup-delete` do not say so. `project.update` with no
322
- * changes does, and Orca's CLI does not offer it (ADR 0034). The caller prints
331
+ * changes does, and Orca's CLI does not offer it (ADR 0046). The caller prints
323
332
  * `RELOAD_LINE` either way. It does not take a removed project out of the
324
333
  * sidebar: `reloadWindow` does.
325
334
  */
@@ -330,7 +339,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
330
339
  * runtime's answer, or undefined when there is none to be had.
331
340
  *
332
341
  * It goes through Orca's own runtime client out of the installed app, run by
333
- * Orca's binary the way its `bin/orca` runs its CLI (ADR 0034). None of that is
342
+ * Orca's binary the way its `bin/orca` runs its CLI (ADR 0046). None of that is
334
343
  * Orca's published interface, so anything that goes wrong is a quiet
335
344
  * undefined: it never throws and is never tried twice.
336
345
  */
@@ -367,7 +376,7 @@ const RELOAD_KILL_MS = 5000;
367
376
 
368
377
  /**
369
378
  * Have Orca's window force-reload itself, after a project was removed: true
370
- * only when it did (#343, ADR 0034).
379
+ * only when it did (#343, ADR 0046).
371
380
  *
372
381
  * The window keeps a removed project in its sidebar until it is rebuilt, and
373
382
  * `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
@@ -524,7 +533,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
524
533
  * Whether the rows of a rendered screen hold a question of the harness's own.
525
534
  *
526
535
  * Every one seen is a numbered list of choices with the harness's pointer on
527
- * one (tech notes, section 1; ADR 0034). The same pointer starts the harness's
536
+ * one (tech notes, section 1; ADR 0046). The same pointer starts the harness's
528
537
  * input line and its echo of the user's past turns, and the input line is the
529
538
  * lowest of them whenever it is on screen, so the lowest pointer row is asked
530
539
  * about: a question counts while it stands in the input line's place, or
@@ -730,7 +739,7 @@ export const psCli = () => process.env.OBK_PS || '/bin/ps';
730
739
  *
731
740
  * `ps` is asked first. Where it cannot read the tab, as inside Codex's
732
741
  * sandbox, where it does not start at all, Orca's runtime is asked instead
733
- * (#298, ADR 0034). `readMs` bounds each call: a number, or a function that
742
+ * (#298, ADR 0046). `readMs` bounds each call: a number, or a function that
734
743
  * says what is left of a wait, asked before each one (#498). A `ps` that runs
735
744
  * out of it ends the look, `late`, with no runtime asked after it.
736
745
  */
@@ -770,7 +779,7 @@ function frontByOrca(handle, readMs) {
770
779
  * Who holds the terminal of the pane `ptyId`, as `ps` says it.
771
780
  *
772
781
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
773
- * gives that pid's terminal's foreground process group (ADR 0034).
782
+ * gives that pid's terminal's foreground process group (ADR 0046).
774
783
  * On macOS the pane is `login` with the shell as its child, so the shell is in
775
784
  * front when the group is the pane's own or that of a child of a `login` pane.
776
785
  * `diagnostics memory` is a diagnostics command and may change, so everything
@@ -6,7 +6,7 @@
6
6
  // body beside the bots folder, commit. A matching allow rule is settled before
7
7
  // that check (Claude Code's permissions docs), so the kit gives every bot a
8
8
  // default set, spelled with its real CLI and bots folder, with nobody asked
9
- // (#527, ADR 0036). A rule beyond it is the user's yes, which
9
+ // (#527, ADR 0050). A rule beyond it is the user's yes, which
10
10
  // `permission allow` keeps in bot.yaml's `allow`. The kit's code writes what
11
11
  // `allow` holds into `.claude/settings.json` in the bot folder, beside its hook
12
12
  // (ADR 0022). A model editing the file by hand may get the format wrong; code
@@ -24,7 +24,7 @@ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync
24
24
  import path from 'node:path';
25
25
 
26
26
  import { allowRules, leadsOutside } from './bot.js';
27
- import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT } from './commands.js';
27
+ import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT, REMOVED } from './commands.js';
28
28
  import { readSettings } from './hooks.js';
29
29
  import { harnessOf, ownCli, shellWord } from './launch.js';
30
30
 
@@ -36,11 +36,10 @@ const CODEX_RULES = '.codex/rules';
36
36
  const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
37
37
 
38
38
  /**
39
- * The rules every bot is given (ADR 0036): each of the kit's default commands,
40
- * and none kept back for the user's yes (ADR 0041), narrowed to this bots folder and
39
+ * The rules every bot is given (ADR 0050): each of the kit's default commands,
40
+ * and none kept back for the user's yes (ADR 0051), narrowed to this bots folder and
41
41
  * spelled as the kit prints them, which is what a bot runs; reading a long
42
- * message; committing; and the check Orca's mail notice tells a session to
43
- * run. `//` is Claude's form for an absolute path.
42
+ * message; and committing. `//` is Claude's form for an absolute path.
44
43
  */
45
44
  export function defaultRules(bots, cli = ownCli()) {
46
45
  const kit = shellWord(cli);
@@ -50,19 +49,27 @@ export function defaultRules(bots, cli = ownCli()) {
50
49
  `Read(/${bots}.messages/**)`,
51
50
  'Bash(git add:*)',
52
51
  'Bash(git commit:*)',
53
- 'Bash(orca orchestration check --run:*)',
54
52
  ];
55
53
  }
56
54
 
57
55
  /**
58
- * The rules of the commands that were defaults once and are kept back now
59
- * (#548), spelled as `defaultRules` spelled them, that the bot's `allow`
60
- * holds. The kit cannot tell its own old entry from a user's yes to the same
61
- * rule, so it names them and leaves them (ADR 0041).
56
+ * Orca's check, which the set held until #564: the kit's rules tell a bot to
57
+ * read its mail with `obk message check`, never with the bare check, which
58
+ * leaves it unread (ADR 0042).
59
+ */
60
+ const ORCA_CHECK = 'Bash(orca orchestration check --run:*)';
61
+
62
+ /**
63
+ * The rules that were defaults once and are not now, that the bot's `allow`
64
+ * holds: those of the commands kept back (#548, #615) or gone from the kit (#555),
65
+ * spelled as `defaultRules` spelled them, and Orca's check (#564). The kit
66
+ * cannot tell its own old entry from a user's yes to the same rule, so it
67
+ * names them and leaves them (ADR 0051, ADR 0050).
62
68
  */
63
69
  export function heldRules(bots, home, bot, cli = ownCli()) {
64
70
  const allowed = allowOf(home, bot);
65
- return NO_LONGER_DEFAULT.map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
71
+ return [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
72
+ .concat(ORCA_CHECK)
66
73
  .filter((rule) => allowed.includes(rule));
67
74
  }
68
75
 
@@ -220,9 +227,16 @@ export const runsOnCodex = (bot) => bot.harness === 'codex'
220
227
  */
221
228
  function missingDefaults(bots, home, bot) {
222
229
  const allowed = allowOf(home, bot);
223
- return defaultRules(bots)
224
- .filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined)
225
- .filter((rule) => !allowed.includes(rule));
230
+ return botDefaults(bots, bot).filter((rule) => !allowed.includes(rule));
231
+ }
232
+
233
+ /**
234
+ * The default rules a bot on Claude or Codex is given, in the default order:
235
+ * what `bot create` writes into a new bot's bot.yaml (#595).
236
+ */
237
+ export function botDefaults(bots, bot) {
238
+ if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
239
+ return defaultRules(bots).filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined);
226
240
  }
227
241
 
228
242
  /**
@@ -270,7 +284,7 @@ export function refuseNoCodexForm(bot, rules) {
270
284
  }
271
285
 
272
286
  /**
273
- * Add the default rules the bot is not allowed yet to its `allow` (ADR 0036),
287
+ * Add the default rules the bot is not allowed yet to its `allow` (ADR 0050),
274
288
  * then write what it is allowed into the files of the harnesses it runs on:
275
289
  * one entry per file, Claude's first, each with `defaults`, the rules this run
276
290
  * added, and `held`, the rules of commands no longer in the set that the bot
package/src/pi.js ADDED
@@ -0,0 +1,55 @@
1
+ // Whether Pi is ready to start a session: it runs, it can use a model, and it
2
+ // can use the one the session names (#578).
3
+ //
4
+ // The kit never handles Pi's credentials: the user signs in to Pi on their own,
5
+ // and the kit only asks Pi what it can use. `pi auth check` is not asked, since
6
+ // it does not know a provider an extension of Pi's brings, such as Cursor's;
7
+ // `pi --list-models` lists the models Pi can use, those included (tech notes,
8
+ // section 6).
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ /** How long each question to Pi is given. Each took about 0.3 s on Pi 1.1.0. */
13
+ const ASK_MS = 30_000;
14
+
15
+ /** Pi's thinking levels, which a model name may carry after a colon. */
16
+ const THINKING = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
17
+
18
+ /**
19
+ * Why Pi cannot start these sessions, or undefined when it can. `models` are
20
+ * the models they name; a session that names none takes Pi's own default.
21
+ */
22
+ export function piTrouble(models) {
23
+ const version = ask(['--version']);
24
+ if (version.error !== undefined || version.status !== 0) {
25
+ const why = version.error?.code === 'ENOENT' ? 'there is no pi on PATH' : (version.error?.message ?? ((version.stderr ?? '').trim() || `it exited with ${version.status}`));
26
+ return `Pi does not run here (${why}), so no Pi session was started. Install Pi, then run this again.`;
27
+ }
28
+
29
+ const any = ask(['--list-models']);
30
+ if (any.error !== undefined || any.status !== 0 || /^No models available\b/m.test(any.stdout ?? '')) {
31
+ return 'Pi has no model it can use: it is signed in to no provider. Sign in to Pi yourself (start pi and use /login, or set a provider\'s API key), then run this again. The kit never handles Pi\'s credentials.';
32
+ }
33
+
34
+ for (const model of new Set(models)) {
35
+ const found = ask(['--list-models', withoutThinking(model)]);
36
+ if (found.error !== undefined || found.status !== 0) {
37
+ const why = found.error?.message ?? ((found.stderr ?? '').trim() || `it exited with ${found.status ?? found.signal}`);
38
+ return `Pi cannot say whether it can use the model ${model}: pi --list-models ${withoutThinking(model)} failed (${why}), so no Pi session was started. Run that command yourself to see why, then run this again.`;
39
+ }
40
+ // Pi says it has no model at all before it searches, so the answer may be
41
+ // that one too, when a provider dropped out since the question above.
42
+ if (/^No models (matching|available)\b/m.test(found.stdout ?? '')) {
43
+ return `Pi cannot use the model ${model}: pi --list-models lists none matching it. Sign in to its provider in Pi yourself, or give the session a model Pi lists.`;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** The model without a trailing `:<thinking level>`, which `--list-models` does not match. */
50
+ function withoutThinking(model) {
51
+ const at = model.lastIndexOf(':');
52
+ return at > 0 && THINKING.includes(model.slice(at + 1)) ? model.slice(0, at) : model;
53
+ }
54
+
55
+ const ask = (args) => spawnSync('pi', args, { encoding: 'utf8', timeout: ASK_MS });
package/src/rules.js CHANGED
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { parse } from 'yaml';
22
22
 
23
- import { botDir, botNames, displayName, readBot } from './bot.js';
23
+ import { botDir, botNames, displayName, readBot, refuseOutside } from './bot.js';
24
24
  import { ownCli, shellWord } from './launch.js';
25
25
 
26
26
  /** The kit's own units, inside the installed package. */
@@ -85,6 +85,7 @@ export function buildAgents(bots, home, bot) {
85
85
 
86
86
  let state;
87
87
  try {
88
+ refuseOutside(home, file);
88
89
  state = writeBlock(file, built.body);
89
90
  } catch (error) {
90
91
  return { bot: bot.name, file, state: 'conflict', trouble: error.message };
@@ -416,7 +417,9 @@ export function agentsTrouble(bots, home, bot) {
416
417
  /** Whether two paths are the same file in the end. One that leads nowhere is not. */
417
418
  function sameFile(one, other) {
418
419
  try {
419
- return realpathSync(one) === realpathSync(other);
420
+ // Native, as in leadsOutside: the JS one takes `hop/..` away before `hop`
421
+ // is followed.
422
+ return realpathSync.native(one) === realpathSync.native(other);
420
423
  } catch {
421
424
  return false;
422
425
  }
package/src/skills.js CHANGED
@@ -24,11 +24,11 @@ import { fileURLToPath } from 'node:url';
24
24
  import { parse, parseDocument, stringify } from 'yaml';
25
25
 
26
26
  import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
27
- import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
27
+ import { botDir, botNames, changesExactly, leadsOutside, readBot, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
28
28
  import { harnessOf, ownCli, shellWord } from './launch.js';
29
- import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
29
+ import { orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
30
30
  import { listIn } from './rules.js';
31
- import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
31
+ import { cloneDir, isCloned, leavesClone, readSources, skillsIn, wrongClone } from './sources.js';
32
32
 
33
33
  /** The kit's own skills, inside the installed package. */
34
34
  const KIT_SKILLS = fileURLToPath(new URL('../skills', import.meta.url));
@@ -84,25 +84,28 @@ export function addSkill(bots, bot, ref) {
84
84
  throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
85
85
  }
86
86
 
87
- if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
88
-
89
- const was = readFileSync(file, 'utf8');
90
- const doc = parseDocument(was);
91
- const listed = doc.get('skills', true);
92
- if (listed?.items === undefined) {
93
- // No list to add to: an empty `skills:`, or no skills key at all.
94
- doc.set('skills', [ref]);
95
- } else {
96
- listed.flow = false;
97
- doc.addIn(['skills'], ref);
98
- }
87
+ return withBotTurn(home, () => {
88
+ if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
89
+ refuseOutside(home, file);
99
90
 
100
- const text = doc.toString(YAML_OUT);
101
- if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
102
- throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
103
- }
104
- writeFileSync(file, text);
105
- return { bot, home, skill: ref, state: 'added' };
91
+ const was = readFileSync(file, 'utf8');
92
+ const doc = parseDocument(was);
93
+ const listed = doc.get('skills', true);
94
+ if (listed?.items === undefined) {
95
+ // No list to add to: an empty `skills:`, or no skills key at all.
96
+ doc.set('skills', [ref]);
97
+ } else {
98
+ listed.flow = false;
99
+ doc.addIn(['skills'], ref);
100
+ }
101
+
102
+ const text = doc.toString(YAML_OUT);
103
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
104
+ throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
105
+ }
106
+ writeWhole(file, text);
107
+ return { bot, home, skill: ref, state: 'added' };
108
+ });
106
109
  }
107
110
 
108
111
  /**
@@ -118,19 +121,22 @@ export function removeSkill(bots, bot, ref) {
118
121
  throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there.`);
119
122
  }
120
123
 
121
- const index = listIn(file, 'skills').indexOf(ref);
122
- if (index === -1) return { bot, home, skill: ref, state: 'absent' };
124
+ return withBotTurn(home, () => {
125
+ const index = listIn(file, 'skills').indexOf(ref);
126
+ if (index === -1) return { bot, home, skill: ref, state: 'absent' };
127
+ refuseOutside(home, file);
123
128
 
124
- const was = readFileSync(file, 'utf8');
125
- const doc = parseDocument(was);
126
- doc.deleteIn(['skills', index]);
129
+ const was = readFileSync(file, 'utf8');
130
+ const doc = parseDocument(was);
131
+ doc.deleteIn(['skills', index]);
127
132
 
128
- const text = doc.toString(YAML_OUT);
129
- if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
130
- throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
131
- }
132
- writeFileSync(file, text);
133
- return { bot, home, skill: ref, state: 'removed' };
133
+ const text = doc.toString(YAML_OUT);
134
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
135
+ throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
136
+ }
137
+ writeWhole(file, text);
138
+ return { bot, home, skill: ref, state: 'removed' };
139
+ });
134
140
  }
135
141
 
136
142
  /**
@@ -472,7 +478,10 @@ function tellSessions(home, bot, linked) {
472
478
  const told = { session: session.name, harness };
473
479
  const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
480
  if (tab === undefined) return { ...told, state: 'not-up' };
475
- if (down !== undefined) return { ...told, state: 'unknown', trouble: `Orca is not answering at ${orcaCli()}, so the kit could not look at its tab, and nothing was typed` };
481
+ // Skills reach a Pi session in a later slice (#547), and nothing is typed
482
+ // into its tab before then.
483
+ if (harness === 'pi') return { ...told, state: 'unknown', trouble: 'it runs on Pi, and the kit does not give a Pi session skills yet, so nothing was typed' };
484
+ if (down !== undefined) return { ...told, state: 'unknown', trouble: `the kit could not look at its tab, and nothing was typed: ${down}` };
476
485
  let turn;
477
486
  try {
478
487
  // The session's turn for a line (#482), for the look and its line. Codex
@@ -564,7 +573,14 @@ function follow(bots, ref, sources) {
564
573
  }
565
574
  const wrong = wrongClone(bots, source);
566
575
  if (wrong !== undefined) throw new Error(`${ref}: ${wrong}`);
567
- return path.join(skillsIn(bots, source), after);
576
+ // The pinned clone is the only place a source's skill comes from. A path,
577
+ // a skill name or a link the repo committed can lead out of it (#571).
578
+ const inClone = path.join(skillsIn(bots, source), after);
579
+ const real = leavesClone(bots, named, inClone);
580
+ if (real !== undefined) {
581
+ throw new Error(`${ref} leads outside the clone of the source ${named}, to ${real}, and a skill from a source comes only from the version skills.yaml pins for it. Name a skill inside ${cloneDir(bots, named)} with no link out of it.`);
582
+ }
583
+ return inClone;
568
584
  },
569
585
  common: () => path.join(bots, 'skills', ref),
570
586
  // The user's own spelling: absolute, at their home, or read from the bots
package/src/sources.js CHANGED
@@ -14,11 +14,11 @@
14
14
  // file, so the difference is theirs to see.
15
15
 
16
16
  import { spawnSync } from 'node:child_process';
17
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { parseDocument } from 'yaml';
20
20
 
21
- import { changesExactly, requireBotsFolder, YAML_OUT } from './bot.js';
21
+ import { changesExactly, leadsOutside, requireBotsFolder, YAML_OUT } from './bot.js';
22
22
  import { ownCli, shellWord } from './launch.js';
23
23
 
24
24
  /** The user's file that lists where skills come from. */
@@ -65,6 +65,9 @@ export function addSource(bots, { name, repo, ref, path: inside }) {
65
65
  if (TAKEN.includes(name)) {
66
66
  throw new Error(`${name} is the kit's own shelf, which a bot names kit:<name>, so a source cannot be called that. Call it something else.`);
67
67
  }
68
+ if (inside !== undefined && !staysInRepo(inside)) {
69
+ throw new Error(`${inside} cannot be the path of the source ${name}: it leads out of the repo, and a path is a subfolder inside it, such as skills.`);
70
+ }
68
71
 
69
72
  const file = path.join(bots, SKILLS_YAML);
70
73
  // Read through the same reader every other command uses, so a file this one
@@ -151,7 +154,6 @@ function bring(bots, source, moving) {
151
154
  const wrong = wrongClone(bots, source);
152
155
  if (wrong !== undefined) {
153
156
  if (!moving) throw new Error(wrong);
154
- rmSync(dir, { recursive: true, force: true });
155
157
  return clone(bots, source, source.ref, false);
156
158
  }
157
159
 
@@ -174,11 +176,16 @@ function bring(bots, source, moving) {
174
176
  * Clone a source and check it out out of the way, and put it in place only once
175
177
  * both have worked. `<name>.fetching` is where it happens; a run that cannot
176
178
  * finish leaves that behind and not a source.
179
+ *
180
+ * A clone already there, from a repository the file no longer names, stays
181
+ * where it is until the new one is ready (#568). It may hold the user's own
182
+ * files, and a replacement that fails must not cost them.
177
183
  */
178
184
  function clone(bots, source, rev, recorded) {
179
185
  mkdirSync(sourcesDir(bots), { recursive: true });
180
186
  const dir = cloneDir(bots, source.name);
181
187
  const fetching = `${dir}.fetching`;
188
+ const replaced = `${dir}.replaced`;
182
189
 
183
190
  try {
184
191
  rmSync(fetching, { recursive: true, force: true });
@@ -186,7 +193,25 @@ function clone(bots, source, rev, recorded) {
186
193
  // repository and never one of git's own options, whatever it begins with.
187
194
  git(['clone', '--quiet', '--', source.repo, fetching], undefined, `${source.name}: ${source.repo} could not be cloned`);
188
195
  checkout(fetching, source, rev, recorded);
189
- renameSync(fetching, dir);
196
+ if (existsSync(dir)) {
197
+ rmSync(replaced, { recursive: true, force: true });
198
+ renameSync(dir, replaced);
199
+ try {
200
+ renameSync(fetching, dir);
201
+ } catch (error) {
202
+ renameSync(replaced, dir);
203
+ throw error;
204
+ }
205
+ // The new clone is in place, so the update has worked. An old copy that
206
+ // will not go stays beside it, where health names it as a leftover.
207
+ try {
208
+ rmSync(replaced, { recursive: true, force: true });
209
+ } catch {
210
+ // Nothing to undo.
211
+ }
212
+ } else {
213
+ renameSync(fetching, dir);
214
+ }
190
215
  } finally {
191
216
  rmSync(fetching, { recursive: true, force: true });
192
217
  }
@@ -250,6 +275,47 @@ function git(args, cwd, trouble) {
250
275
  return result.stdout.trim();
251
276
  }
252
277
 
278
+ /**
279
+ * Whether a source's path, as written, names a folder inside its repo: not an
280
+ * absolute path, and no `..` that climbs above the top (#571). A link inside the
281
+ * clone can still lead out; `skills build` follows it before linking.
282
+ */
283
+ const staysInRepo = (inside) => !path.isAbsolute(inside) && path.normalize(inside).split(path.sep)[0] !== '..';
284
+
285
+ /**
286
+ * Where a source's skill at `dir` really leads when a link takes it, or anything
287
+ * in it, out of the source's clone; undefined when all of it stays inside (#571).
288
+ * A harness reads any file in a skill, so the whole folder is walked, through
289
+ * the links that stay inside as well.
290
+ */
291
+ export function leavesClone(bots, name, dir) {
292
+ const clone = cloneDir(bots, name);
293
+ const seen = new Set();
294
+ const walk = (at) => {
295
+ const real = leadsOutside(clone, at);
296
+ if (real !== undefined) return real;
297
+ let entries;
298
+ try {
299
+ const where = realpathSync.native(at);
300
+ // A folder reached twice, through a link back into the skill, is walked once.
301
+ if (seen.has(where) || !statSync(where).isDirectory()) return undefined;
302
+ seen.add(where);
303
+ entries = readdirSync(where);
304
+ } catch (error) {
305
+ // Nothing there: whatever links it says so.
306
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
307
+ // A folder the kit may not read can hold a link out, and nothing shows it.
308
+ throw new Error(`${name}: the kit could not read ${at} (${error.code ?? error.message}), so it cannot tell whether anything in the skill leads out of the source's clone, and it links nothing from there.`);
309
+ }
310
+ for (const entry of entries) {
311
+ const found = walk(path.join(at, entry));
312
+ if (found !== undefined) return found;
313
+ }
314
+ return undefined;
315
+ };
316
+ return walk(dir);
317
+ }
318
+
253
319
  /** Where a source's skills sit inside its clone: its root, or the subfolder it names. */
254
320
  export const skillsIn = (bots, source) => path.join(cloneDir(bots, source.name), source.path ?? '');
255
321
 
@@ -331,6 +397,9 @@ export function readSources(file) {
331
397
  if (source.path !== undefined && typeof source.path !== 'string') {
332
398
  throw new Error(`${file}: the source ${name} has a path that is not a subfolder inside the repo.`);
333
399
  }
400
+ if (source.path !== undefined && !staysInRepo(source.path)) {
401
+ throw new Error(`${file}: the source ${name} has the path ${source.path}, which leads out of the repo, and a path is a subfolder inside it, such as skills.`);
402
+ }
334
403
  return source;
335
404
  });
336
405
  }