@botbuddy/cli 1.13.2 → 1.14.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/package.json +1 -1
- package/src/agent-credential-store.mjs +78 -13
- package/src/cli-credentials.mjs +113 -0
- package/src/commands.mjs +1 -1
- package/src/config.mjs +45 -13
- package/src/pw/run.mjs +2 -2
- package/src/wait.mjs +69 -2
package/package.json
CHANGED
|
@@ -93,20 +93,59 @@ function writePasswordPrompt(command, args, password, spawnProcess) {
|
|
|
93
93
|
export async function writeKeychainSecret(service, token, {
|
|
94
94
|
spawnProcess = spawn,
|
|
95
95
|
execFileImpl = execFileAsync,
|
|
96
|
-
|
|
96
|
+
keychain = null,
|
|
97
|
+
createOnly = false,
|
|
98
|
+
readSecret = (svc) => readKeychainSecret(svc, { execFileImpl, keychain }),
|
|
97
99
|
} = {}) {
|
|
98
100
|
if (!service) throw new ProfileCredentialStoreError("keychain write requires a service name");
|
|
101
|
+
// BOT-1569 (Codex P2): `-U` makes `add-generic-password` UPDATE an existing
|
|
102
|
+
// item. `login` wants that (re-login overwrites the owner token), but the
|
|
103
|
+
// config→Keychain migration must NOT: between its empty-slot read and this
|
|
104
|
+
// write, another process could complete a fresh login, and `-U` would then
|
|
105
|
+
// overwrite that newly-issued token with the stale config value. `createOnly`
|
|
106
|
+
// drops `-U` so the write CREATES only — `security` fails with "already exists"
|
|
107
|
+
// (mapped to `keychain_item_exists`) if the slot filled concurrently, and the
|
|
108
|
+
// migration refuses instead of downgrading.
|
|
109
|
+
const update = createOnly ? [] : ["-U"];
|
|
99
110
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
if (keychain) {
|
|
112
|
+
// BOT-1570: an explicit keychain path is used only by the integration tests
|
|
113
|
+
// against a scratch `security create-keychain` (never the login keychain).
|
|
114
|
+
// `security -w` PROMPT mode reads the secret from stdin, but its optional
|
|
115
|
+
// argument greedily consumes the trailing [keychain] positional — so prompt
|
|
116
|
+
// mode and an explicit keychain cannot coexist. With a keychain path the
|
|
117
|
+
// value is written inline (throwaway scratch token); production (no keychain
|
|
118
|
+
// path) keeps the detached stdin double-feed that keeps the real credential
|
|
119
|
+
// off argv.
|
|
120
|
+
// `-A` grants every application access to the item without an ACL prompt.
|
|
121
|
+
// That is deliberately insecure and reserved for the throwaway scratch
|
|
122
|
+
// keychain the integration tests target — it keeps `security` fully
|
|
123
|
+
// headless (no SecurityAgent GUI dialog on the read-back). Production never
|
|
124
|
+
// takes this branch, so the login keychain is never written with `-A`.
|
|
125
|
+
await execFileImpl(
|
|
126
|
+
"security",
|
|
127
|
+
["add-generic-password", ...update, "-A", "-s", service, "-a", userInfo().username, "-w", token, keychain],
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
await writePasswordPrompt(
|
|
131
|
+
"security",
|
|
132
|
+
["add-generic-password", ...update, "-s", service, "-a", userInfo().username, "-w"],
|
|
133
|
+
token,
|
|
134
|
+
spawnProcess,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
106
137
|
} catch (error) {
|
|
138
|
+
const detail = String(error?.stderr ?? error?.message ?? error);
|
|
139
|
+
// createOnly: a concurrent writer already filled the slot. Surface a typed
|
|
140
|
+
// code so the migration refuses rather than overwriting the newer token.
|
|
141
|
+
if (createOnly && /already exists/i.test(detail)) {
|
|
142
|
+
throw new ProfileCredentialStoreError(
|
|
143
|
+
`the macOS Keychain already holds an item for ${service}`,
|
|
144
|
+
{ cause: error, code: "keychain_item_exists" },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
107
147
|
// `security` cancels its authorization when the login keychain is locked in
|
|
108
148
|
// a headless session; guide the user to unlock it rather than re-login.
|
|
109
|
-
const detail = String(error?.message ?? error);
|
|
110
149
|
if (/authorization was cancell?ed/i.test(detail)) {
|
|
111
150
|
throw new ProfileCredentialStoreError(
|
|
112
151
|
"macOS Keychain authorization was canceled — unlock your login keychain (`security unlock-keychain`) and retry; a headless session needs it already unlocked",
|
|
@@ -128,20 +167,46 @@ export async function writeKeychainSecret(service, token, {
|
|
|
128
167
|
}
|
|
129
168
|
}
|
|
130
169
|
|
|
131
|
-
|
|
170
|
+
// `security find-generic-password` exits non-zero both when the item is genuinely
|
|
171
|
+
// absent (exit 44 / "could not be found") AND on a real failure (locked keychain,
|
|
172
|
+
// authorization temporarily unavailable). The two must be distinguished by any
|
|
173
|
+
// caller that would WRITE based on "absent" — treating a transient read failure
|
|
174
|
+
// as "empty" can overwrite a live credential (BOT-1569 Codex P2).
|
|
175
|
+
function isKeychainItemNotFound(error) {
|
|
176
|
+
// Only the ACTUAL not-found indicators: exit 44, or the "could not be found"
|
|
177
|
+
// message. The `SecKeychainSearchCopyNext` API-function prefix alone is NOT a
|
|
178
|
+
// sufficient signal — it can front other diagnostics — so every other failure
|
|
179
|
+
// stays fail-closed (Codex P2).
|
|
180
|
+
if (Number(error?.code) === 44) return true;
|
|
181
|
+
const detail = String(error?.stderr ?? error?.message ?? error ?? "");
|
|
182
|
+
return /could not be found/i.test(detail);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function readKeychainSecret(service, { execFileImpl = execFileAsync, keychain = null, strict = false } = {}) {
|
|
132
186
|
if (!service) return null;
|
|
187
|
+
// BOT-1570: an explicit keychain path (scratch keychain, tests only) is the
|
|
188
|
+
// trailing positional `security` searches; production omits it and searches
|
|
189
|
+
// the default search list.
|
|
190
|
+
const args = ["find-generic-password", "-s", service, "-a", userInfo().username, "-w"];
|
|
191
|
+
if (keychain) args.push(keychain);
|
|
133
192
|
try {
|
|
134
|
-
const { stdout } = await execFileImpl("security",
|
|
193
|
+
const { stdout } = await execFileImpl("security", args);
|
|
135
194
|
return stdout.trim() || null;
|
|
136
|
-
} catch {
|
|
195
|
+
} catch (error) {
|
|
196
|
+
// Non-strict (default, back-compat): any failure reads as "no value". Strict:
|
|
197
|
+
// only a genuine not-found is null; a real read failure is re-thrown so the
|
|
198
|
+
// caller can fail closed instead of mistaking it for an empty slot.
|
|
199
|
+
if (strict && !isKeychainItemNotFound(error)) throw error;
|
|
137
200
|
return null;
|
|
138
201
|
}
|
|
139
202
|
}
|
|
140
203
|
|
|
141
|
-
export async function deleteKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
|
|
204
|
+
export async function deleteKeychainSecret(service, { execFileImpl = execFileAsync, keychain = null } = {}) {
|
|
142
205
|
if (!service) return false;
|
|
206
|
+
const args = ["delete-generic-password", "-s", service, "-a", userInfo().username];
|
|
207
|
+
if (keychain) args.push(keychain); // BOT-1570: scoped to the scratch keychain in tests
|
|
143
208
|
try {
|
|
144
|
-
await execFileImpl("security",
|
|
209
|
+
await execFileImpl("security", args);
|
|
145
210
|
return true;
|
|
146
211
|
} catch {
|
|
147
212
|
return false;
|
package/src/cli-credentials.mjs
CHANGED
|
@@ -50,6 +50,119 @@ export function sanitizeConfigSecrets(config, { platform = process.platform, exi
|
|
|
50
50
|
return { config: out, changed };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// BOT-1569: migrate, never drop. `sanitizeConfigSecrets` only ever *removed* a
|
|
54
|
+
// config.json `access_token` on a Keychain-available host — it never copied the
|
|
55
|
+
// value into the Keychain first. So a `login` that stored the token in
|
|
56
|
+
// config.json (Keychain unavailable/headless) lost the credential the next time
|
|
57
|
+
// any command ran where the Keychain was reachable. This async variant is what
|
|
58
|
+
// `loadConfig` runs: on a Keychain-available host it COPIES a config.json owner
|
|
59
|
+
// token into the Keychain and read-back verifies it BEFORE removing it from
|
|
60
|
+
// config.json; if the migration can't be verified (locked/failing Keychain) the
|
|
61
|
+
// config copy is preserved so the sole live credential is never deleted. On a
|
|
62
|
+
// host without a Keychain the token stays put (the documented fallback store).
|
|
63
|
+
// `api_key` (written by the retired `register`) has no Keychain home and is
|
|
64
|
+
// always dropped.
|
|
65
|
+
export async function migrateConfigSecrets(config, {
|
|
66
|
+
platform = process.platform,
|
|
67
|
+
exists,
|
|
68
|
+
// `createOnly`: the migration write must not overwrite a token another process
|
|
69
|
+
// wrote after our empty-slot read (BOT-1569 Codex P2 TOCTOU).
|
|
70
|
+
keychainWrite = (value) => writeKeychainSecret(OWNER_TOKEN_SERVICE, value, { createOnly: true }),
|
|
71
|
+
// `strict`: a transient READ failure throws (fail closed) instead of reading as
|
|
72
|
+
// an empty slot, so the migration never overwrites a live stored token because
|
|
73
|
+
// the lookup momentarily failed (BOT-1569 Codex P2).
|
|
74
|
+
keychainRead = () => readKeychainSecret(OWNER_TOKEN_SERVICE, { strict: true }),
|
|
75
|
+
warn = (message) => console.error(message),
|
|
76
|
+
} = {}) {
|
|
77
|
+
const out = { ...(config ?? {}) };
|
|
78
|
+
let changed = false;
|
|
79
|
+
let migrated = false;
|
|
80
|
+
|
|
81
|
+
if ("api_key" in out) { delete out.api_key; changed = true; }
|
|
82
|
+
|
|
83
|
+
const keychain = keychainAvailable(platform, exists);
|
|
84
|
+
if ("access_token" in out) {
|
|
85
|
+
const token = out.access_token;
|
|
86
|
+
if (!keychain) {
|
|
87
|
+
// Documented fallback store: keep the token in the 0600 config.json. This
|
|
88
|
+
// is the BOT-1569 fix — no BOTBUDDY_NO_KEYCHAIN needed to avoid data loss.
|
|
89
|
+
} else if (typeof token !== "string" || token.length === 0) {
|
|
90
|
+
// Not a live credential (null/empty/garbage) — safe to drop.
|
|
91
|
+
delete out.access_token; changed = true;
|
|
92
|
+
} else {
|
|
93
|
+
let existing = null;
|
|
94
|
+
let readFailed = false;
|
|
95
|
+
try {
|
|
96
|
+
existing = await keychainRead();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Codex P2: a transient Keychain READ failure (locked keychain,
|
|
99
|
+
// authorization momentarily unavailable) must NOT be misread as an empty
|
|
100
|
+
// slot — that would let the migration overwrite a possibly-newer stored
|
|
101
|
+
// token with the config value. Fail closed: preserve both copies and try
|
|
102
|
+
// again on a later command once the Keychain is readable.
|
|
103
|
+
readFailed = true;
|
|
104
|
+
warn(
|
|
105
|
+
`⚠ Could not read the macOS Keychain to check for an existing owner token (${error?.message ?? error}); leaving the ~/.botbuddy/config.json copy in place. It will migrate once the Keychain is readable.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (readFailed) {
|
|
109
|
+
// no write, no delete — nothing changes for access_token this pass.
|
|
110
|
+
} else if (existing === token) {
|
|
111
|
+
// Already migrated (this exact token is in the Keychain): drop the
|
|
112
|
+
// config copy without a redundant write.
|
|
113
|
+
delete out.access_token; changed = true; migrated = true;
|
|
114
|
+
} else if (typeof existing === "string" && existing.length > 0) {
|
|
115
|
+
// Codex P2: the Keychain already holds a DIFFERENT owner token. We cannot
|
|
116
|
+
// prove which is newer — and `login` always writes the Keychain FIRST, so
|
|
117
|
+
// overwriting it with the config value could DOWNGRADE a freshly-issued
|
|
118
|
+
// credential to an older one left on disk by a failed metadata save. Fail
|
|
119
|
+
// closed: never overwrite the Keychain, and keep the config copy too so
|
|
120
|
+
// NEITHER token is lost. resolveOwnerToken prefers the Keychain on darwin,
|
|
121
|
+
// so which token authenticates stays deterministic.
|
|
122
|
+
//
|
|
123
|
+
// Codex P2 (follow-up): the preserved metadata — `token_expires_at` — was
|
|
124
|
+
// written for the OLD (config) token. resolveOwnerToken pairs the Keychain
|
|
125
|
+
// token with config's expiry, so a stale past expiry from the old
|
|
126
|
+
// credential would make api.mjs/stack.mjs reject the still-valid Keychain
|
|
127
|
+
// token as expired. Disassociate the expiry (a null expiry skips the client
|
|
128
|
+
// precheck; the Keychain token's real lifetime is enforced server-side).
|
|
129
|
+
if ("token_expires_at" in out) { delete out.token_expires_at; changed = true; }
|
|
130
|
+
warn(
|
|
131
|
+
"⚠ A different owner token is already stored in the macOS Keychain; leaving the ~/.botbuddy/config.json copy in place (and clearing its stale expiry so the Keychain token is used) rather than overwriting it. Run `botbuddy logout` then `botbuddy login` to settle on a single authoritative credential.",
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
// Keychain empty: migrate the config token in and verify the read-back
|
|
135
|
+
// (AC2) BEFORE removing it from config.json.
|
|
136
|
+
try {
|
|
137
|
+
await keychainWrite(token);
|
|
138
|
+
if ((await keychainRead()) === token) {
|
|
139
|
+
delete out.access_token; changed = true; migrated = true;
|
|
140
|
+
} else {
|
|
141
|
+
warn(
|
|
142
|
+
"⚠ Could not migrate the owner token from ~/.botbuddy/config.json into the macOS Keychain (read-back did not match); leaving it in config.json. Run `botbuddy login` to re-establish it in the Keychain.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error?.code === "keychain_item_exists") {
|
|
147
|
+
// Codex P2 TOCTOU: another process wrote a token between our empty-slot
|
|
148
|
+
// read and this create-only write. Never overwrite it — preserve the
|
|
149
|
+
// config copy so the concurrently-issued credential wins.
|
|
150
|
+
warn(
|
|
151
|
+
"⚠ Another owner token was written to the macOS Keychain concurrently; leaving the ~/.botbuddy/config.json copy in place rather than overwriting it. Re-run `botbuddy login` to settle on a single credential.",
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
// Fail-safe: a locked or failing Keychain must never cost the credential.
|
|
155
|
+
warn(
|
|
156
|
+
`⚠ Could not migrate the owner token from ~/.botbuddy/config.json into the macOS Keychain (${error?.message ?? error}); leaving it in config.json for now.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { config: out, changed, migrated };
|
|
164
|
+
}
|
|
165
|
+
|
|
53
166
|
// Persist the owner OAuth token. Non-secret metadata always goes to config.json;
|
|
54
167
|
// the token itself goes to the Keychain on darwin, or (with a warning) stays in
|
|
55
168
|
// the 0600 config.json where no Keychain exists.
|
package/src/commands.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export async function run(argv, {
|
|
|
31
31
|
warnStale = maybeWarnStale,
|
|
32
32
|
errorLog = (line) => console.error(line),
|
|
33
33
|
} = {}) {
|
|
34
|
-
load();
|
|
34
|
+
await load();
|
|
35
35
|
const [command, ...args] = argv;
|
|
36
36
|
if (shouldCheckForUpdates(argv)) await warnStale({ version: VERSION });
|
|
37
37
|
|
package/src/config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
-
import {
|
|
4
|
+
import { migrateConfigSecrets } from "./cli-credentials.mjs";
|
|
5
5
|
|
|
6
6
|
const CONFIG_DIR = join(homedir(), ".botbuddy");
|
|
7
7
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
@@ -14,28 +14,60 @@ export const SERVER_URL = process.env.BOTBUDDY_SERVER_URL
|
|
|
14
14
|
|
|
15
15
|
let config = {};
|
|
16
16
|
|
|
17
|
-
export function loadConfig({ platform = process.platform, warn = (m) => console.error(m) } = {}) {
|
|
17
|
+
export async function loadConfig({ platform = process.platform, warn = (m) => console.error(m) } = {}) {
|
|
18
18
|
let raw = {};
|
|
19
|
+
let rawText = null;
|
|
19
20
|
try {
|
|
20
21
|
if (existsSync(CONFIG_FILE)) {
|
|
21
|
-
|
|
22
|
+
rawText = readFileSync(CONFIG_FILE, "utf-8");
|
|
23
|
+
raw = JSON.parse(rawText);
|
|
22
24
|
}
|
|
23
25
|
} catch {
|
|
24
26
|
raw = {};
|
|
25
27
|
}
|
|
26
|
-
// BOT-1520 migration: never silently trust
|
|
27
|
-
//
|
|
28
|
-
// is always dropped; `access_token` is
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// BOT-1520/BOT-1569 migration: never silently trust — or silently DROP —
|
|
29
|
+
// secrets left in a plaintext config.json. `api_key` (written by the retired
|
|
30
|
+
// `register`) is always dropped; a config.json owner `access_token` is MIGRATED
|
|
31
|
+
// into the Keychain (write + read-back) before removal, and preserved in
|
|
32
|
+
// config.json if the migration can't be verified or no Keychain is available.
|
|
33
|
+
const { config: sanitized, changed, migrated } = await migrateConfigSecrets(raw, { platform, warn });
|
|
34
|
+
// Adopt the migrated config in memory unconditionally — a failed best-effort
|
|
32
35
|
// rewrite (read-only FS, permissions) must not drop the non-secret metadata.
|
|
33
36
|
config = sanitized;
|
|
34
37
|
if (changed) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// BOT-1569 (Codex P2): the migration above can span real Keychain I/O, so a
|
|
39
|
+
// concurrent `botbuddy login` may have rewritten config.json (fresh
|
|
40
|
+
// client_id / token_expires_at) since our initial read. An unconditional
|
|
41
|
+
// whole-file save would clobber that newer metadata with our stale snapshot,
|
|
42
|
+
// and resolveOwnerToken would then pair the new Keychain token with an old
|
|
43
|
+
// expiry. Compare-and-swap: only rewrite if config.json is still byte-for-byte
|
|
44
|
+
// what we read; otherwise the newer on-disk file wins and the (idempotent)
|
|
45
|
+
// migration re-runs on the next command. Not a full lock — it narrows the
|
|
46
|
+
// window to an in-process read→write with no I/O between — but it closes the
|
|
47
|
+
// credential/metadata-clobber race.
|
|
48
|
+
let persisted = false;
|
|
49
|
+
try {
|
|
50
|
+
const nowText = existsSync(CONFIG_FILE) ? readFileSync(CONFIG_FILE, "utf-8") : null;
|
|
51
|
+
if (nowText === rawText) {
|
|
52
|
+
saveConfig(sanitized);
|
|
53
|
+
persisted = true;
|
|
54
|
+
} else if (nowText) {
|
|
55
|
+
// BOT-1569 (Codex P2): a concurrent writer (e.g. a `login` saving fresh
|
|
56
|
+
// client_id / token_expires_at) won the file. Don't keep our stale
|
|
57
|
+
// migration snapshot as the process-wide config — adopt the newer on-disk
|
|
58
|
+
// file so THIS command uses the fresh metadata (e.g. resolveOwnerToken
|
|
59
|
+
// pairs the new Keychain token with the new expiry). The idempotent
|
|
60
|
+
// migration re-runs on the next command to finish clearing any plaintext.
|
|
61
|
+
try { config = JSON.parse(nowText); } catch { /* malformed: keep sanitized */ }
|
|
62
|
+
}
|
|
63
|
+
} catch { /* best-effort: clear the secret at rest */ }
|
|
64
|
+
if (persisted) {
|
|
65
|
+
warn(
|
|
66
|
+
migrated
|
|
67
|
+
? "✓ Migrated the owner token from ~/.botbuddy/config.json into the macOS Keychain."
|
|
68
|
+
: "⚠ Removed a legacy plaintext credential from ~/.botbuddy/config.json. Run `botbuddy login` (and `botbuddy profile setup <profile>` for agents) to re-establish credentials in the Keychain.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
39
71
|
}
|
|
40
72
|
return config;
|
|
41
73
|
}
|
package/src/pw/run.mjs
CHANGED
|
@@ -20,7 +20,7 @@ function redact(value, secretValues = []) { return secretValues.reduce((text, se
|
|
|
20
20
|
// profile agent the gate historically demanded. bb-pw is not always launched
|
|
21
21
|
// through the `botbuddy` entrypoint (bb-pw.mjs imports runPw directly), so load
|
|
22
22
|
// the config here rather than assuming it is already in memory.
|
|
23
|
-
function readRegisteredAgentId() { try { loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
|
|
23
|
+
async function readRegisteredAgentId() { try { await loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
|
|
24
24
|
async function gate({ env, host, lane, deps }) {
|
|
25
25
|
if (env.BB_PW_NO_LOCK === "1") return { allowed: true };
|
|
26
26
|
let profile, identity;
|
|
@@ -39,7 +39,7 @@ async function gate({ env, host, lane, deps }) {
|
|
|
39
39
|
// identity. A foreign operator's agent is in none of these, so the gate stays a
|
|
40
40
|
// real refusal (AC-3).
|
|
41
41
|
const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
|
|
42
|
-
const registeredAgentId = (deps.readSessionAgentId ?? readRegisteredAgentId)();
|
|
42
|
+
const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
|
|
43
43
|
const selfAgentIds = new Set([identity.agentId, sessionAgentId, registeredAgentId].filter(Boolean));
|
|
44
44
|
try {
|
|
45
45
|
const status = await coordinator.status({ host, slot: lane });
|
package/src/wait.mjs
CHANGED
|
@@ -22,9 +22,13 @@ import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
|
22
22
|
// A protocol is deliberately distinct from package semver: compatible pinned
|
|
23
23
|
// clients keep working until the server raises this minimum, while a stale
|
|
24
24
|
// implementation gets a typed, safe upgrade instruction.
|
|
25
|
-
|
|
25
|
+
// BOT-1554: protocol 2 makes --session-id mandatory for every non-timer wait (the
|
|
26
|
+
// server keeps MINIMUM_WAIT_PROTOCOL=1 so pre-2 installs are not 426'd).
|
|
27
|
+
export const WAIT_PROTOCOL_VERSION = 2;
|
|
26
28
|
const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
|
|
27
29
|
const MIN_RECEIPT_MAX_BYTES = 512;
|
|
30
|
+
// BOT-1554: identical to the server's session-id shape check.
|
|
31
|
+
const SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
32
|
|
|
29
33
|
const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
|
|
30
34
|
|
|
@@ -104,7 +108,8 @@ OPTIONS
|
|
|
104
108
|
--heartbeat keep this agent session alive while waiting (so it is not reaped)
|
|
105
109
|
--url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
|
|
106
110
|
--profile <name> tenant-bound machine profile (normally read from .botbuddy-agent.json)
|
|
107
|
-
--session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID
|
|
111
|
+
--session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
|
|
112
|
+
REQUIRED for any non-timer wait (a wait must name the work agent that armed it).
|
|
108
113
|
--token <key> explicit agent key override; otherwise the profile-specific env is used
|
|
109
114
|
--help show this help
|
|
110
115
|
|
|
@@ -248,6 +253,10 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
248
253
|
// only ever park to timeout, so it's a hard stop, not the live-only fallback.
|
|
249
254
|
"invalid_session_agent",
|
|
250
255
|
"session_agent_requires_profile",
|
|
256
|
+
// BOT-1554: the server refuses a protocol-2 register with no session id (the
|
|
257
|
+
// CLI fails fast before this, but a mismatched/forced body could reach it) —
|
|
258
|
+
// a hard stop, never the untracked live-only fallback.
|
|
259
|
+
"session_id_required",
|
|
251
260
|
]);
|
|
252
261
|
if (INVALID_CONDITION_CODES.has(body.error)) {
|
|
253
262
|
const err = new Error(body.detail || body.error);
|
|
@@ -264,6 +273,16 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
264
273
|
err.errorCode = body.error;
|
|
265
274
|
throw err;
|
|
266
275
|
}
|
|
276
|
+
// BOT-1554: the credential (or its session's agent) is a shared type='service'
|
|
277
|
+
// carrier — it can hold locks/MCP sessions but cannot be a wait's actor. Carry
|
|
278
|
+
// the carrier id so runWait can name it in the fix. Auth-shaped → exit 3.
|
|
279
|
+
if (body.error === "wait_actor_required") {
|
|
280
|
+
const err = new Error(body.detail || body.error);
|
|
281
|
+
err.auth = true;
|
|
282
|
+
err.errorCode = "wait_actor_required";
|
|
283
|
+
err.carrierAgentId = body.carrier_agent_id ?? null;
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
267
286
|
const err = new Error(body.detail || body.message || body.error || "forbidden");
|
|
268
287
|
err.auth = true;
|
|
269
288
|
err.errorCode = body.error || "forbidden";
|
|
@@ -637,6 +656,27 @@ export async function runWait(argv) {
|
|
|
637
656
|
|
|
638
657
|
const needsRelay = conditions.some((c) => c.type !== "timer");
|
|
639
658
|
if (needsRelay) {
|
|
659
|
+
// BOT-1554: a relay wait MUST name its arming work-graph session. Fail fast —
|
|
660
|
+
// before any profile resolution or network call — so a wait can never be filed
|
|
661
|
+
// under whatever credential the machine holds (the "/waits all Megan" bug). A
|
|
662
|
+
// timer-only wait needs no relay and no session id (handled by !needsRelay).
|
|
663
|
+
if (!opts.sessionId) {
|
|
664
|
+
process.stderr.write(
|
|
665
|
+
"botbuddy wait: --session-id is required (or set $BOTBUDDY_SESSION_ID) — the work-graph session id returned by register_agent\n",
|
|
666
|
+
);
|
|
667
|
+
emitReceipt({
|
|
668
|
+
schema_version: 1,
|
|
669
|
+
outcome: "error",
|
|
670
|
+
error: "session_id_required",
|
|
671
|
+
recovery: "register_agent → export BOTBUDDY_SESSION_ID=<session_id>",
|
|
672
|
+
});
|
|
673
|
+
process.exit(EXIT.INVALID);
|
|
674
|
+
}
|
|
675
|
+
if (!SESSION_UUID.test(opts.sessionId)) {
|
|
676
|
+
process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}')\n`);
|
|
677
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid" });
|
|
678
|
+
process.exit(EXIT.INVALID);
|
|
679
|
+
}
|
|
640
680
|
try {
|
|
641
681
|
opts.agentProfile = await resolveAgentProfile({
|
|
642
682
|
explicitProfile: opts.profile,
|
|
@@ -759,6 +799,33 @@ export async function runWait(argv) {
|
|
|
759
799
|
process.exit(EXIT.INVALID);
|
|
760
800
|
}
|
|
761
801
|
if (err && err.auth) {
|
|
802
|
+
// BOT-1554: these are all SESSION-IDENTITY failures — the supplied session id
|
|
803
|
+
// (or the credential behind it) is a shared service carrier, an
|
|
804
|
+
// ended/foreign session, or a wrong-tenant session. Re-running profile setup
|
|
805
|
+
// and retrying with the SAME $BOTBUDDY_SESSION_ID just repeats the 403 (that
|
|
806
|
+
// command can't replace the parent shell's session variable), so lead with the
|
|
807
|
+
// reliable fix: register a WORK agent and export its NEW session id.
|
|
808
|
+
const SESSION_IDENTITY_ERRORS = new Set([
|
|
809
|
+
"wait_actor_required", "session_agent_forbidden", "session_agent_tenant_mismatch",
|
|
810
|
+
]);
|
|
811
|
+
if (SESSION_IDENTITY_ERRORS.has(err.errorCode)) {
|
|
812
|
+
const cause = err.errorCode === "wait_actor_required"
|
|
813
|
+
? `the wait was armed by a shared service carrier (${err.carrierAgentId})`
|
|
814
|
+
: err.errorCode === "session_agent_tenant_mismatch"
|
|
815
|
+
? "the session id ($BOTBUDDY_SESSION_ID) resolves to a different tenant than this wait"
|
|
816
|
+
: "the session id ($BOTBUDDY_SESSION_ID) is not a live agent session you own";
|
|
817
|
+
process.stderr.write(
|
|
818
|
+
`botbuddy wait: ${cause}; register a work agent (register_agent) and export the returned id as $BOTBUDDY_SESSION_ID — re-running '${profileRecovery(opts.agentProfile)}' will not replace the shell's session variable\n`,
|
|
819
|
+
);
|
|
820
|
+
emitReceipt(withPrincipalReceipt({
|
|
821
|
+
schema_version: 1,
|
|
822
|
+
outcome: "error",
|
|
823
|
+
error: err.errorCode,
|
|
824
|
+
...(err.carrierAgentId ? { carrier_agent_id: err.carrierAgentId } : {}),
|
|
825
|
+
recovery: "register_agent → export BOTBUDDY_SESSION_ID=<new session_id>",
|
|
826
|
+
}, opts.agentProfile, { sessionTenant: opts.agentProfile.tenant, agentId: null }));
|
|
827
|
+
process.exit(EXIT.AUTH);
|
|
828
|
+
}
|
|
762
829
|
const error = typedProfileError(err.errorCode);
|
|
763
830
|
process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run '${profileRecovery(opts.agentProfile)}'\n`);
|
|
764
831
|
emitReceipt(profileErrorReceipt(opts.agentProfile, error));
|