@byokit/herdr 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.0 (2026-10-01)
6
+
7
+ - SECURITY: StartAgent and openSignInTab accept a clean launch environment with explicit unsets, verified before launch in idle shells on new or existing panes; credential values stay out of terminal commands, argv and errors, preventing inherited API key (billed per use) variables from overriding subscription sign-in.
8
+
5
9
  ## 0.4.1 (2026-10-01)
6
10
 
7
11
  - Dependency update: pins @byokit/relay 0.5.1.
package/README.md CHANGED
@@ -391,3 +391,10 @@ package, never touches a person's other AI tools. Its tests run against a fake H
391
391
  ## License
392
392
 
393
393
  Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](../../NOTICE).
394
+
395
+ `startAgent` and `openSignInTab` accept the accounts `launchEnv()` result as `env: launch`.
396
+ The host and Herdr must share a filesystem. New panes and existing idle POSIX shells apply
397
+ the settings and unsets through a private temporary file before launch; only the file path
398
+ is sent to the terminal. Busy panes and unsupported shells fail closed. Legacy record env
399
+ still travels on new-pane placement, and remains refused on existing panes. Credential
400
+ values must never be supplied in agent args.
package/dist/agents.js CHANGED
@@ -7,6 +7,7 @@ import { accessSync, constants as fsConstants, openSync, readSync, closeSync } f
7
7
  import { homedir } from 'node:os';
8
8
  import { delimiter, join } from 'node:path';
9
9
  import { words } from "./words.js";
10
+ import { prepareLaunchEnv } from "./launch-env.js";
10
11
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
11
12
  const DEFAULT_TIMEOUT_MS = 60_000;
12
13
  // `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
@@ -81,15 +82,19 @@ export function createAgents(ctx) {
81
82
  catch (error) {
82
83
  const reason = classifyStartFailure(error, { installExpected, stage });
83
84
  emit({ phase: 'launchFailed', kind: o.kind, reason, message: launchFailureWords(reason, o.kind) });
85
+ if (o.env && typeof o.env.env === 'object')
86
+ throw fail(codeOf(error) === 'env_mismatch' ? 'env_mismatch' : 'start_failed', words('agent.notReady'));
84
87
  throw error;
85
88
  }
86
89
  }
87
90
  async function startAgentInner(o, onStartPhase) {
88
- if ('pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
91
+ const launch = o.env && typeof o.env.env === 'object' && Array.isArray(o.env.unset)
92
+ ? o.env : undefined;
93
+ if (!launch && 'pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
89
94
  throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
90
95
  }
91
96
  const timeout = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
92
- const env = o.env === undefined ? {} : { env: o.env };
97
+ const env = o.env === undefined || launch ? {} : { env: o.env };
93
98
  let paneId;
94
99
  let created = false;
95
100
  if (o.worktree !== undefined) {
@@ -127,6 +132,20 @@ export function createAgents(ctx) {
127
132
  }
128
133
  if (paneId === undefined)
129
134
  throw new Error('herdr: the placement answered no pane id');
135
+ if (launch) {
136
+ try {
137
+ await prepareLaunchEnv(call, paneId, launch, timeout);
138
+ }
139
+ catch (error) {
140
+ if (created) {
141
+ try {
142
+ await call('pane.close', { pane_id: paneId });
143
+ }
144
+ catch { /* best effort */ }
145
+ }
146
+ throw error;
147
+ }
148
+ }
130
149
  onStartPhase?.();
131
150
  const name = agentName(o);
132
151
  const params = { pane_id: paneId, kind: o.kind, name,
@@ -148,6 +167,8 @@ export function createAgents(ctx) {
148
167
  }
149
168
  catch { /* rollback is best-effort */ }
150
169
  }
170
+ if (launch)
171
+ throw fail('start_failed', words('agent.notReady'));
151
172
  throw error;
152
173
  }
153
174
  }
@@ -0,0 +1,6 @@
1
+ import type { Call } from './agents.ts';
2
+ export type LaunchEnvironment = {
3
+ env: Record<string, string>;
4
+ unset: string[];
5
+ };
6
+ export declare function prepareLaunchEnv(call: Call, paneId: string, launch: LaunchEnvironment, timeout: number): Promise<void>;
@@ -0,0 +1,71 @@
1
+ // The pinned agent.start launches through the pane shell. Values stay in a private host file,
2
+ // never terminal text or argv. Only an idle POSIX shell may source it; preparation fails closed.
3
+ import { randomBytes } from 'node:crypto';
4
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ const quote = (v) => `'${v.replaceAll("'", "'\\''")}'`;
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ export async function prepareLaunchEnv(call, paneId, launch, timeout) {
10
+ let dir;
11
+ try {
12
+ if (!Number.isFinite(timeout) || timeout <= 0)
13
+ throw new Error();
14
+ const entries = Object.entries(launch.env);
15
+ const unset = [...launch.unset];
16
+ const names = [...entries.map(([key]) => key), ...unset];
17
+ if (names.some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
18
+ || entries.some(([, value]) => typeof value !== 'string' || /[\r\n\0]/.test(value))
19
+ || unset.some((key) => Object.hasOwn(launch.env, key)))
20
+ throw new Error();
21
+ const deadline = Date.now() + timeout;
22
+ // Do not send shell input to an agent or another foreground program.
23
+ for (;;) {
24
+ const result = await call('pane.process_info', { pane_id: paneId }, Math.max(1, deadline - Date.now()));
25
+ const info = result?.process_info;
26
+ if (info?.shell_pid && info.foreground_processes?.length === 1
27
+ && info.foreground_processes[0]?.pid === info.shell_pid
28
+ && /^(?:-)?(?:ba|z|da|k)?sh$/.test(info.foreground_processes[0].name))
29
+ break;
30
+ if (Date.now() >= deadline)
31
+ throw new Error();
32
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
33
+ }
34
+ const marker = `BYOKIT_ENV_${randomBytes(16).toString('hex')}`;
35
+ dir = mkdtempSync(join(tmpdir(), 'byokit-env-'));
36
+ const file = join(dir, 'prepare.sh');
37
+ const steps = [
38
+ 'set +x +v',
39
+ // Remove exports absent from the clean result too: a pane may inherit more than the host.
40
+ // The pipeline is private; neither values nor variable listings reach the terminal.
41
+ `for BYOKIT_ENV_KEY in $(/usr/bin/env | /usr/bin/cut -d= -f1); do
42
+ case "$BYOKIT_ENV_KEY" in ''|[!A-Za-z_]*|*[!A-Za-z0-9_]*) continue ;; esac
43
+ case "$BYOKIT_ENV_KEY" in ${entries.length ? entries.map(([key]) => key).join('|') : 'BYOKIT_ENV_KEY'}) ;; *) unset "$BYOKIT_ENV_KEY" 2>/dev/null || return 1 ;; esac
44
+ done`,
45
+ 'unset BYOKIT_ENV_KEY',
46
+ ...unset.map((key) => `unset ${key}`),
47
+ ...entries.map(([key, value]) => `export ${key}=${quote(value)}`),
48
+ ...unset.map((key) => `test "\${${key}+present}" = ''`),
49
+ ...entries.map(([key, value]) => `test "\${${key}+present}" = present && test "$${key}" = ${quote(value)}`),
50
+ `printf '%s\\n' ${quote(marker)}`,
51
+ ];
52
+ writeFileSync(file, steps.join(' &&\n') + '\n', { mode: 0o600, flag: 'wx' });
53
+ await call('pane.send_text', { pane_id: paneId, text: `set +x +v; . ${quote(file)}\n` }, Math.max(1, deadline - Date.now()));
54
+ for (;;) {
55
+ const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
56
+ format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
57
+ if (result?.read?.text?.split(/\r?\n/).some((line) => line.trim() === marker))
58
+ return;
59
+ if (Date.now() >= deadline)
60
+ throw new Error();
61
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
62
+ }
63
+ }
64
+ catch {
65
+ throw Object.assign(new Error('This pane could not use that sign-in.'), { code: 'env_mismatch' });
66
+ }
67
+ finally {
68
+ if (dir !== undefined)
69
+ rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ }
@@ -2,6 +2,7 @@
2
2
  // request per connection — the server closes after answering; only `events.subscribe` is held open.
3
3
  // Ported from muxr `perf/fake-herdr/server.mjs` with the UI plugins, title churn, graphics and perf
4
4
  // byte rates removed.
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { mkdirSync, unlinkSync } from 'node:fs';
6
7
  import { createServer } from 'node:net';
7
8
  import { join } from 'node:path';
@@ -235,6 +236,28 @@ export async function startFakeHerdr(options) {
235
236
  emitEvent('tab.renamed', { tab_id: tab.tab_id, workspace_id: tab.workspace_id, label: tab.label });
236
237
  return { type: 'tab_info', tab: tabView(tab) };
237
238
  },
239
+ 'pane.process_info': (p) => {
240
+ const pane = paneOf(p.pane_id);
241
+ const busy = live.agents.some((a) => a.pane_id === pane.pane_id);
242
+ return { process_info: { shell_pid: 1, foreground_processes: [{ pid: busy ? 2 : 1, name: busy ? 'agent' : 'sh' }] } };
243
+ },
244
+ 'pane.send_text': (p) => {
245
+ const pane = paneOf(p.pane_id);
246
+ const text = String(p.text ?? '');
247
+ // A real offline shell exercises the private launch file, never a real agent CLI.
248
+ if (!/^set \+x \+v; \. '/.test(text))
249
+ throw fail('unsupported', 'unsupported fake shell command');
250
+ const r = spawnSync('/bin/sh', ['-c', text + "printf '\\0'; /usr/bin/env -0"], {
251
+ env: pane.env ?? { PATH: '/usr/bin:/bin', HOME: dir }, encoding: 'utf8', timeout: 5000,
252
+ });
253
+ const [output, ...environment] = r.stdout.split('\0');
254
+ pane.text.push(output);
255
+ pane.env = Object.fromEntries(environment.filter(Boolean).map((entry) => {
256
+ const i = entry.indexOf('=');
257
+ return [entry.slice(0, i), entry.slice(i + 1)];
258
+ }));
259
+ return {};
260
+ },
238
261
  'pane.get': (p) => ({ pane: paneView(paneOf(p.pane_id)) }),
239
262
  'pane.close': (p) => {
240
263
  const pane = paneOf(p.pane_id);
package/dist/types.d.ts CHANGED
@@ -116,7 +116,10 @@ export type StartAgent = {
116
116
  base?: string;
117
117
  };
118
118
  args?: string[];
119
- env?: Record<string, string>;
119
+ env?: Record<string, string> | {
120
+ env: Record<string, string>;
121
+ unset: string[];
122
+ };
120
123
  timeoutMs?: number;
121
124
  onEvent?: (e: AgentStartEvent) => void;
122
125
  installProbe?: AgentInstallProbe;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Drive the Herdr on this computer — workspaces, tabs, panes, agents and blocked-approval answers — from an app, or hand it to a phone over @byokit/link.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",