@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 +4 -0
- package/README.md +7 -0
- package/dist/agents.js +23 -2
- package/dist/launch-env.d.ts +6 -0
- package/dist/launch-env.js +71 -0
- package/dist/testing/fake-herdr/server.js +23 -0
- package/dist/types.d.ts +4 -1
- package/package.json +1 -1
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
|
-
|
|
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,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.
|
|
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",
|