@botbuddy/cli 1.8.7 → 1.9.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 +45 -8
- package/src/api.mjs +28 -17
- package/src/auth.mjs +15 -8
- package/src/cli-credentials.mjs +135 -0
- package/src/codex-bridge.mjs +28 -9
- package/src/commands.mjs +29 -35
- package/src/config.mjs +19 -3
- package/src/stack.mjs +16 -8
- package/src/wait-core.mjs +57 -0
- package/src/wait.mjs +59 -2
package/package.json
CHANGED
|
@@ -12,8 +12,20 @@ const LOCK_MAX_ATTEMPTS = 100;
|
|
|
12
12
|
const STALE_LOCK_MS = 30_000;
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
14
14
|
|
|
15
|
+
// BOT-1520: is the macOS Keychain backend usable on this host? The CLI stores
|
|
16
|
+
// durable secrets (profile agent keys, and the owner OAuth token) in the
|
|
17
|
+
// Keychain on darwin; elsewhere callers fall back to a documented minimal
|
|
18
|
+
// footprint. Keep this a pure predicate so both the profile store and the
|
|
19
|
+
// owner-token store share one definition of "keychain available".
|
|
20
|
+
export function keychainAvailable(platform = process.platform, exists = existsSync, env = process.env) {
|
|
21
|
+
// Escape hatch (headless CI where the login keychain can't unlock, or a
|
|
22
|
+
// deterministic test): force the documented non-Keychain fallback.
|
|
23
|
+
if (env?.BOTBUDDY_NO_KEYCHAIN === "1") return false;
|
|
24
|
+
return platform === "darwin" && exists("/usr/bin/security");
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function ensureProfileCredentialBackend({ platform = process.platform, exists = existsSync } = {}) {
|
|
16
|
-
if (platform
|
|
28
|
+
if (!keychainAvailable(platform, exists)) {
|
|
17
29
|
throw new Error("profile setup requires the macOS Keychain credential backend");
|
|
18
30
|
}
|
|
19
31
|
}
|
|
@@ -58,9 +70,12 @@ function writePasswordPrompt(command, args, password, spawnProcess) {
|
|
|
58
70
|
});
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
// BOT-1520: generic Keychain secret primitives keyed by an explicit service
|
|
74
|
+
// name, so both the profile agent-key store and the owner OAuth-token store
|
|
75
|
+
// share the single implementation of the `security -w` double-read prompt and
|
|
76
|
+
// the actionable error mapping — no second copy to drift.
|
|
77
|
+
export async function writeKeychainSecret(service, token, { spawnProcess = spawn } = {}) {
|
|
78
|
+
if (!service) throw new ProfileCredentialStoreError("keychain write requires a service name");
|
|
64
79
|
try {
|
|
65
80
|
await writePasswordPrompt(
|
|
66
81
|
"security",
|
|
@@ -78,21 +93,43 @@ export async function writeKeychain(profile, token, { spawnProcess = spawn } = {
|
|
|
78
93
|
{ cause: error },
|
|
79
94
|
);
|
|
80
95
|
}
|
|
81
|
-
throw new ProfileCredentialStoreError(`could not write the
|
|
96
|
+
throw new ProfileCredentialStoreError(`could not write the credential to the macOS Keychain: ${detail}`, { cause: error });
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
async function
|
|
86
|
-
const service = keychainService(profile);
|
|
100
|
+
export async function readKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
|
|
87
101
|
if (!service) return null;
|
|
88
102
|
try {
|
|
89
|
-
const { stdout } = await
|
|
103
|
+
const { stdout } = await execFileImpl("security", ["find-generic-password", "-s", service, "-a", userInfo().username, "-w"]);
|
|
90
104
|
return stdout.trim() || null;
|
|
91
105
|
} catch {
|
|
92
106
|
return null;
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
109
|
|
|
110
|
+
export async function deleteKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
|
|
111
|
+
if (!service) return false;
|
|
112
|
+
try {
|
|
113
|
+
await execFileImpl("security", ["delete-generic-password", "-s", service, "-a", userInfo().username]);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function writeKeychain(profile, token, { spawnProcess = spawn } = {}) {
|
|
121
|
+
const service = keychainService(profile);
|
|
122
|
+
if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
|
|
123
|
+
return writeKeychainSecret(service, token, { spawnProcess });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readKeychain(profile) {
|
|
127
|
+
// Honor the keychain escape hatch (BOTBUDDY_NO_KEYCHAIN / non-darwin) so the
|
|
128
|
+
// per-profile environment key is the sole credential source there.
|
|
129
|
+
if (!keychainAvailable()) return null;
|
|
130
|
+
return readKeychainSecret(keychainService(profile));
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
function profileCredentialStorePath(home = homedir()) {
|
|
97
134
|
return join(home, ".botbuddy", "agent-profiles.json");
|
|
98
135
|
}
|
package/src/api.mjs
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
import { getConfig, SERVER_URL } from "./config.mjs";
|
|
2
|
+
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
2
3
|
import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// BOT-1520: auth headers come from the Keychain, never a plaintext config.json
|
|
6
|
+
// secret. The owner OAuth token (from `botbuddy login`) is preferred; the
|
|
7
|
+
// tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
|
|
8
|
+
async function authHeader() {
|
|
9
|
+
const owner = await resolveOwnerToken({ getConfig });
|
|
10
|
+
if (owner) {
|
|
11
|
+
if (owner.expiresAt && Date.now() >= owner.expiresAt) {
|
|
12
|
+
const agentKey = await resolveAgentKey();
|
|
13
|
+
if (agentKey) return { "x-agent-api-key": agentKey };
|
|
8
14
|
die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
|
|
9
15
|
}
|
|
10
|
-
if (
|
|
16
|
+
if (owner.expiresAt && owner.expiresAt - Date.now() < 5 * 60 * 1000) {
|
|
11
17
|
console.error(`${yellow("⚠")} Token expires in <5 minutes. Run ${cyan("botbuddy login")} soon.`);
|
|
12
18
|
}
|
|
13
|
-
return { Authorization: `Bearer ${
|
|
19
|
+
return { Authorization: `Bearer ${owner.token}` };
|
|
14
20
|
}
|
|
15
|
-
|
|
21
|
+
const agentKey = await resolveAgentKey();
|
|
22
|
+
if (agentKey) return { "x-agent-api-key": agentKey };
|
|
16
23
|
die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export async function callTool(toolName, args = {}) {
|
|
20
|
-
const headers = { "Content-Type": "application/json", ...authHeader() };
|
|
27
|
+
const headers = { "Content-Type": "application/json", ...(await authHeader()) };
|
|
21
28
|
const body = {
|
|
22
29
|
jsonrpc: "2.0",
|
|
23
30
|
id: 1,
|
|
@@ -47,15 +54,19 @@ export async function callTool(toolName, args = {}) {
|
|
|
47
54
|
// * { ok:false, auth:true } — not authenticated / token expired
|
|
48
55
|
// * { ok:false, status } — HTTP/JSON-RPC/transport error (status may be null)
|
|
49
56
|
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal } = {}) {
|
|
50
|
-
const
|
|
57
|
+
const owner = await resolveOwnerToken({ getConfig });
|
|
51
58
|
let auth;
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
auth = { Authorization: `Bearer ${cfg.access_token}` };
|
|
55
|
-
} else if (cfg.api_key) {
|
|
56
|
-
auth = { "x-agent-api-key": cfg.api_key };
|
|
59
|
+
if (owner && !(owner.expiresAt && Date.now() >= owner.expiresAt)) {
|
|
60
|
+
auth = { Authorization: `Bearer ${owner.token}` };
|
|
57
61
|
} else {
|
|
58
|
-
|
|
62
|
+
const agentKey = await resolveAgentKey();
|
|
63
|
+
if (agentKey) {
|
|
64
|
+
auth = { "x-agent-api-key": agentKey };
|
|
65
|
+
} else if (owner) {
|
|
66
|
+
return { ok: false, auth: true, error: "token_expired" };
|
|
67
|
+
} else {
|
|
68
|
+
return { ok: false, auth: true, error: "not_authenticated" };
|
|
69
|
+
}
|
|
59
70
|
}
|
|
60
71
|
const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
|
|
61
72
|
let res;
|
|
@@ -80,7 +91,7 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
|
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
export async function listResources() {
|
|
83
|
-
const headers = { "Content-Type": "application/json", ...authHeader() };
|
|
94
|
+
const headers = { "Content-Type": "application/json", ...(await authHeader()) };
|
|
84
95
|
const body = { jsonrpc: "2.0", id: 1, method: "resources/list", params: {} };
|
|
85
96
|
const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
|
|
86
97
|
const data = await res.json();
|
|
@@ -88,7 +99,7 @@ export async function listResources() {
|
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
export async function readResource(uri) {
|
|
91
|
-
const headers = { "Content-Type": "application/json", ...authHeader() };
|
|
102
|
+
const headers = { "Content-Type": "application/json", ...(await authHeader()) };
|
|
92
103
|
const body = { jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri } };
|
|
93
104
|
const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
|
|
94
105
|
const data = await res.json();
|
package/src/auth.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SERVER_URL, saveConfig as realSaveConfig, getConfig as realGetConfig } from "./config.mjs";
|
|
2
|
+
import { persistOwnerToken } from "./cli-credentials.mjs";
|
|
2
3
|
import { green, dim, cyan, yellow, bold, red } from "./utils.mjs";
|
|
3
4
|
import {
|
|
4
5
|
createLoopbackReceiver,
|
|
@@ -63,6 +64,7 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
63
64
|
errorLog = (s) => console.error(s),
|
|
64
65
|
timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
|
|
65
66
|
now = () => Date.now(),
|
|
67
|
+
persist = persistOwnerToken,
|
|
66
68
|
} = deps;
|
|
67
69
|
const noBrowser = Boolean(options.noBrowser);
|
|
68
70
|
|
|
@@ -154,14 +156,19 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
154
156
|
? now() + tokenData.expires_in * 1000
|
|
155
157
|
: now() + 24 * 60 * 60 * 1000; // default 24h if server omits expires_in
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
// BOT-1520: the durable secret goes to the Keychain (darwin); config.json
|
|
160
|
+
// keeps only non-secret metadata. On a non-darwin host without a Keychain,
|
|
161
|
+
// persistOwnerToken falls back to the 0600 config.json and warns.
|
|
162
|
+
const { storedInKeychain } = await persist(
|
|
163
|
+
{ token: tokenData.access_token, expiresAt, clientId },
|
|
164
|
+
{ getConfig, saveConfig, warn: errorLog },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
log(
|
|
168
|
+
`\n${green("✓")} Logged in successfully! Token stored in ${
|
|
169
|
+
storedInKeychain ? "the macOS Keychain" : dim("~/.botbuddy/config.json")
|
|
170
|
+
}.`,
|
|
171
|
+
);
|
|
165
172
|
return { clientId, redirectUri };
|
|
166
173
|
} finally {
|
|
167
174
|
// AC-11/AC-13: always release the socket so re-running login starts clean.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// BOT-1520: single source of truth for reading and writing the CLI's durable
|
|
2
|
+
// secrets, so no secret ever lands in plaintext ~/.botbuddy/config.json on a
|
|
3
|
+
// host with a Keychain.
|
|
4
|
+
//
|
|
5
|
+
// There are two secrets the CLI handles:
|
|
6
|
+
//
|
|
7
|
+
// 1. The owner OAuth access token minted by `botbuddy login`. On darwin it is
|
|
8
|
+
// stored in the login Keychain under a dedicated service; config.json keeps
|
|
9
|
+
// only its non-secret metadata (client_id, token_expires_at).
|
|
10
|
+
// 2. The tenant-bound agent key minted by `botbuddy profile setup`, already
|
|
11
|
+
// stored in the profile Keychain store (agent-credential-store.mjs). The
|
|
12
|
+
// retired `botbuddy register` used to write this as a no-expiry `api_key`
|
|
13
|
+
// into config.json; it no longer exists.
|
|
14
|
+
//
|
|
15
|
+
// On a non-darwin host (no Keychain today) `login` falls back to a documented
|
|
16
|
+
// minimal footprint: the token stays in the 0600 config.json and a warning is
|
|
17
|
+
// printed. Agent/CI usage on any host works from the profile Keychain or the
|
|
18
|
+
// per-profile environment key, never from a config.json secret.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
keychainAvailable,
|
|
22
|
+
writeKeychainSecret,
|
|
23
|
+
readKeychainSecret,
|
|
24
|
+
deleteKeychainSecret,
|
|
25
|
+
} from "./agent-credential-store.mjs";
|
|
26
|
+
import { resolveAgentProfile } from "./wait-profile.mjs";
|
|
27
|
+
|
|
28
|
+
// Dedicated Keychain service for the owner OAuth token. Distinct from the
|
|
29
|
+
// per-profile agent-key services (BOTBUDDY_BB_AGENT_KEY / BOTBUDDY_SG_AGENT_KEY)
|
|
30
|
+
// so the human owner session and the machine agent identity never collide.
|
|
31
|
+
export const OWNER_TOKEN_SERVICE = "BOTBUDDY_OWNER_TOKEN";
|
|
32
|
+
|
|
33
|
+
// Config keys that must never carry secret material at rest.
|
|
34
|
+
const SECRET_CONFIG_KEYS = ["api_key", "access_token"];
|
|
35
|
+
|
|
36
|
+
// Strip secret material from a config object. `api_key` is always removed (the
|
|
37
|
+
// `register` path that wrote it is retired). `access_token` is removed only when
|
|
38
|
+
// a Keychain is available to hold it instead; on a non-darwin host it remains
|
|
39
|
+
// the documented fallback store, so it is preserved there.
|
|
40
|
+
export function sanitizeConfigSecrets(config, { platform = process.platform, exists } = {}) {
|
|
41
|
+
const out = { ...(config ?? {}) };
|
|
42
|
+
let changed = false;
|
|
43
|
+
const keychain = keychainAvailable(platform, exists);
|
|
44
|
+
for (const key of SECRET_CONFIG_KEYS) {
|
|
45
|
+
if (!(key in out)) continue;
|
|
46
|
+
if (key === "access_token" && !keychain) continue; // documented non-darwin fallback
|
|
47
|
+
delete out[key];
|
|
48
|
+
changed = true;
|
|
49
|
+
}
|
|
50
|
+
return { config: out, changed };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Persist the owner OAuth token. Non-secret metadata always goes to config.json;
|
|
54
|
+
// the token itself goes to the Keychain on darwin, or (with a warning) stays in
|
|
55
|
+
// the 0600 config.json where no Keychain exists.
|
|
56
|
+
export async function persistOwnerToken(
|
|
57
|
+
{ token, expiresAt, clientId },
|
|
58
|
+
{
|
|
59
|
+
platform = process.platform,
|
|
60
|
+
exists,
|
|
61
|
+
getConfig,
|
|
62
|
+
saveConfig,
|
|
63
|
+
keychainWrite = (value) => writeKeychainSecret(OWNER_TOKEN_SERVICE, value),
|
|
64
|
+
warn = (message) => console.error(message),
|
|
65
|
+
} = {},
|
|
66
|
+
) {
|
|
67
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
68
|
+
throw new Error("persistOwnerToken requires a non-empty token");
|
|
69
|
+
}
|
|
70
|
+
// Start from a sanitized copy so a stale on-disk secret is never carried
|
|
71
|
+
// forward, then layer on the fresh non-secret metadata.
|
|
72
|
+
const keychain = keychainAvailable(platform, exists);
|
|
73
|
+
const { config: base } = sanitizeConfigSecrets(getConfig(), { platform, exists });
|
|
74
|
+
const next = { ...base };
|
|
75
|
+
if (clientId !== undefined) next.client_id = clientId;
|
|
76
|
+
if (expiresAt !== undefined) next.token_expires_at = expiresAt;
|
|
77
|
+
|
|
78
|
+
if (keychain) {
|
|
79
|
+
await keychainWrite(token);
|
|
80
|
+
delete next.access_token;
|
|
81
|
+
} else {
|
|
82
|
+
warn(
|
|
83
|
+
"⚠ macOS Keychain unavailable on this host — storing the OAuth token in ~/.botbuddy/config.json (0600). This is the documented non-darwin fallback.",
|
|
84
|
+
);
|
|
85
|
+
next.access_token = token;
|
|
86
|
+
}
|
|
87
|
+
saveConfig(next);
|
|
88
|
+
return { storedInKeychain: keychain };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve the owner OAuth token for outbound auth. On darwin it comes from the
|
|
92
|
+
// Keychain only — a stale config.json access_token is ignored, never silently
|
|
93
|
+
// trusted (BOT-1520 AC5). On a non-darwin host the config.json fallback is the
|
|
94
|
+
// legitimate store. Expiry metadata always comes from config.json.
|
|
95
|
+
export async function resolveOwnerToken({
|
|
96
|
+
platform = process.platform,
|
|
97
|
+
exists,
|
|
98
|
+
getConfig,
|
|
99
|
+
keychainRead = () => readKeychainSecret(OWNER_TOKEN_SERVICE),
|
|
100
|
+
} = {}) {
|
|
101
|
+
const cfg = getConfig() ?? {};
|
|
102
|
+
const expiresAt = typeof cfg.token_expires_at === "number" ? cfg.token_expires_at : null;
|
|
103
|
+
let token = null;
|
|
104
|
+
if (keychainAvailable(platform, exists)) {
|
|
105
|
+
token = await keychainRead();
|
|
106
|
+
} else if (typeof cfg.access_token === "string" && cfg.access_token) {
|
|
107
|
+
token = cfg.access_token;
|
|
108
|
+
}
|
|
109
|
+
if (!token) return null;
|
|
110
|
+
return { token, expiresAt };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Remove the owner OAuth token from every store (`botbuddy logout`).
|
|
114
|
+
export async function clearOwnerToken({
|
|
115
|
+
platform = process.platform,
|
|
116
|
+
exists,
|
|
117
|
+
keychainDelete = () => deleteKeychainSecret(OWNER_TOKEN_SERVICE),
|
|
118
|
+
} = {}) {
|
|
119
|
+
if (keychainAvailable(platform, exists)) {
|
|
120
|
+
await keychainDelete();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Resolve the tenant-bound agent key from the profile Keychain store (or the
|
|
125
|
+
// per-profile environment key), without throwing when no profile is configured.
|
|
126
|
+
// This is the sole source of the agent credential now that `register` is gone.
|
|
127
|
+
export async function resolveAgentKey(options = {}) {
|
|
128
|
+
try {
|
|
129
|
+
const { token } = await resolveAgentProfile(options);
|
|
130
|
+
return token || null;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.code === "profile_required" || error?.code === "unknown_profile") return null;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/codex-bridge.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { createHash, randomBytes } from "crypto";
|
|
16
16
|
import { getConfig, SERVER_URL } from "./config.mjs";
|
|
17
|
+
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
17
18
|
import { green, red, cyan, dim, bold, yellow, die } from "./utils.mjs";
|
|
18
19
|
import { VERSION } from "./version.mjs";
|
|
19
20
|
|
|
@@ -64,10 +65,17 @@ function isAddressInUseError(text) {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export async function runBridge(args) {
|
|
67
|
-
const cfg = getConfig();
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const cfg = getConfig(); // non-secret metadata only (agent_name)
|
|
69
|
+
// BOT-1520: authenticate from the Keychain — the profile agent key
|
|
70
|
+
// (`botbuddy profile setup`) or the owner OAuth token (`botbuddy login`).
|
|
71
|
+
// Resolve ONCE here for the life of the bridge and cache it, so the relay
|
|
72
|
+
// helpers don't shell out to `security` on every request.
|
|
73
|
+
const agentKey = await resolveAgentKey();
|
|
74
|
+
const owner = agentKey ? null : await resolveOwnerToken({ getConfig });
|
|
75
|
+
if (!agentKey && !owner) {
|
|
76
|
+
die(`Not authenticated. Run: ${cyan("botbuddy start")} or ${cyan("botbuddy login")} (agents: ${cyan("botbuddy profile setup <profile>")}).`);
|
|
70
77
|
}
|
|
78
|
+
bridgeAuth = { agentKey, owner };
|
|
71
79
|
|
|
72
80
|
// Parse args
|
|
73
81
|
let wsPort = 4500;
|
|
@@ -519,16 +527,27 @@ export async function runBridge(args) {
|
|
|
519
527
|
|
|
520
528
|
// ─── HTTP helpers ───
|
|
521
529
|
|
|
522
|
-
|
|
523
|
-
|
|
530
|
+
// BOT-1520: resolved once by runBridge and reused, so the relay helpers never
|
|
531
|
+
// shell out to `security` per request. Null until the bridge starts (or in a
|
|
532
|
+
// direct unit-test call), in which case authHeaders resolves live.
|
|
533
|
+
let bridgeAuth = null;
|
|
534
|
+
|
|
535
|
+
// BOT-1520: the bridge is agent-context, so it prefers the tenant-bound agent
|
|
536
|
+
// key from the profile Keychain store and falls back to the owner OAuth token.
|
|
537
|
+
export async function authHeaders() {
|
|
524
538
|
const headers = { "Content-Type": "application/json" };
|
|
525
|
-
|
|
526
|
-
|
|
539
|
+
const agentKey = bridgeAuth ? bridgeAuth.agentKey : await resolveAgentKey();
|
|
540
|
+
if (agentKey) {
|
|
541
|
+
headers["x-agent-api-key"] = agentKey;
|
|
542
|
+
} else {
|
|
543
|
+
const owner = bridgeAuth ? bridgeAuth.owner : await resolveOwnerToken({ getConfig });
|
|
544
|
+
if (owner) headers["Authorization"] = `Bearer ${owner.token}`;
|
|
545
|
+
}
|
|
527
546
|
return headers;
|
|
528
547
|
}
|
|
529
548
|
|
|
530
549
|
async function relayPost(path, body) {
|
|
531
|
-
const headers = authHeaders();
|
|
550
|
+
const headers = await authHeaders();
|
|
532
551
|
// Add HMAC signing if we have a session secret and session_id
|
|
533
552
|
if (sessionSecret && body?.session_id) {
|
|
534
553
|
const hmacHeaders = await signRequest(body.session_id);
|
|
@@ -545,7 +564,7 @@ async function relayPost(path, body) {
|
|
|
545
564
|
async function relayGet(path) {
|
|
546
565
|
const res = await fetch(`${RELAY_URL}${path}`, {
|
|
547
566
|
method: "GET",
|
|
548
|
-
headers: authHeaders(),
|
|
567
|
+
headers: await authHeaders(),
|
|
549
568
|
});
|
|
550
569
|
return res.json();
|
|
551
570
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { callTool, callToolJson, readResource } from "./api.mjs";
|
|
2
2
|
import { doLogin } from "./auth.mjs";
|
|
3
3
|
import { runBridge } from "./codex-bridge.mjs";
|
|
4
|
-
import { loadConfig, getConfig, clearConfig,
|
|
4
|
+
import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
|
|
5
|
+
import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
|
|
5
6
|
import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
|
|
6
7
|
import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
|
|
7
8
|
import { cmdStack } from "./stack.mjs";
|
|
@@ -24,7 +25,6 @@ export async function run(argv) {
|
|
|
24
25
|
case "logout": return cmdLogout();
|
|
25
26
|
case "status": return cmdStatus();
|
|
26
27
|
// Agent-only commands (used by MCP agents, not humans)
|
|
27
|
-
case "register": return cmdRegister(args);
|
|
28
28
|
case "heartbeat": return cmdHeartbeat(args);
|
|
29
29
|
case "lock": return cmdLock(args);
|
|
30
30
|
case "locks": return cmdLocks(args);
|
|
@@ -164,8 +164,10 @@ ${bold("USAGE")}
|
|
|
164
164
|
${bold("HOW IT WORKS")}
|
|
165
165
|
Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
|
|
166
166
|
your default browser at the BotBuddy authorization page, and waits for you to
|
|
167
|
-
sign in as an allowed user. On success the token is
|
|
168
|
-
${dim("~/.botbuddy/config.json")}
|
|
167
|
+
sign in as an allowed user. On success the token is stored in the macOS
|
|
168
|
+
Keychain (only non-secret metadata is kept in ${dim("~/.botbuddy/config.json")})
|
|
169
|
+
and the browser tab can be closed. On a host without a Keychain the token
|
|
170
|
+
falls back to the 0600 config file and the tool tells you so.
|
|
169
171
|
|
|
170
172
|
${bold("OPTIONS")}
|
|
171
173
|
--no-browser Don't launch a browser; print the authorization URL to open
|
|
@@ -192,14 +194,17 @@ ${bold("RECOVERY")}
|
|
|
192
194
|
|
|
193
195
|
async function cmdStart(args) {
|
|
194
196
|
console.log(`${bold("botbuddy")} ${dim(`v${VERSION}`)}\n`);
|
|
195
|
-
const cfg = getConfig();
|
|
196
197
|
|
|
197
|
-
// Auto-login if not authenticated
|
|
198
|
+
// Auto-login if not authenticated. BOT-1520: credentials live in the Keychain
|
|
199
|
+
// (owner token from `login`, agent key from `profile setup`), so probe those
|
|
200
|
+
// rather than a plaintext config secret.
|
|
198
201
|
try {
|
|
199
|
-
|
|
202
|
+
const owner = await resolveOwnerToken({ getConfig });
|
|
203
|
+
const agentKey = owner ? null : await resolveAgentKey();
|
|
204
|
+
if (!owner && !agentKey) {
|
|
200
205
|
console.log(dim("→ No credentials found. Starting login...\n"));
|
|
201
206
|
await doLogin();
|
|
202
|
-
} else if (
|
|
207
|
+
} else if (owner && owner.expiresAt && owner.expiresAt <= Date.now() && !(await resolveAgentKey())) {
|
|
203
208
|
console.log(dim("→ Token expired. Re-authenticating...\n"));
|
|
204
209
|
await doLogin();
|
|
205
210
|
}
|
|
@@ -211,14 +216,15 @@ async function cmdStart(args) {
|
|
|
211
216
|
return runBridge(args);
|
|
212
217
|
}
|
|
213
218
|
|
|
214
|
-
function cmdStatus() {
|
|
219
|
+
async function cmdStatus() {
|
|
215
220
|
const cfg = getConfig();
|
|
216
|
-
|
|
217
|
-
|
|
221
|
+
const owner = await resolveOwnerToken({ getConfig });
|
|
222
|
+
if (owner) {
|
|
223
|
+
console.log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
|
|
218
224
|
if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
|
|
219
225
|
if (cfg.client_id) console.log(` Client: ${dim(cfg.client_id)}`);
|
|
220
|
-
if (
|
|
221
|
-
const remaining =
|
|
226
|
+
if (owner.expiresAt) {
|
|
227
|
+
const remaining = owner.expiresAt - Date.now();
|
|
222
228
|
if (remaining <= 0) {
|
|
223
229
|
console.log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
|
|
224
230
|
} else {
|
|
@@ -228,17 +234,21 @@ function cmdStatus() {
|
|
|
228
234
|
}
|
|
229
235
|
}
|
|
230
236
|
console.log(` Config: ${dim(getConfigPath())}`);
|
|
231
|
-
|
|
232
|
-
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const agentKey = await resolveAgentKey();
|
|
240
|
+
if (agentKey) {
|
|
241
|
+
console.log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
|
|
233
242
|
if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
|
|
234
|
-
|
|
235
|
-
console.log(`${red("✗")} Not authenticated`);
|
|
236
|
-
console.log(` Run: ${cyan("botbuddy start")}`);
|
|
243
|
+
return;
|
|
237
244
|
}
|
|
245
|
+
console.log(`${red("✗")} Not authenticated`);
|
|
246
|
+
console.log(` Run: ${cyan("botbuddy start")}`);
|
|
238
247
|
}
|
|
239
248
|
|
|
240
|
-
function cmdLogout() {
|
|
249
|
+
async function cmdLogout() {
|
|
241
250
|
clearConfig();
|
|
251
|
+
await clearOwnerToken();
|
|
242
252
|
console.log(`${green("✓")} Logged out. Credentials removed.`);
|
|
243
253
|
}
|
|
244
254
|
|
|
@@ -280,22 +290,6 @@ async function cmdAgentAuth(args) {
|
|
|
280
290
|
console.log(`${green("✓")} ${result.profile} (${result.tenant}) machine credential ${result.removed ? "removed" : "was not present"}.`);
|
|
281
291
|
}
|
|
282
292
|
|
|
283
|
-
async function cmdRegister(args) {
|
|
284
|
-
const name = args[0];
|
|
285
|
-
if (!name) die("Usage: botbuddy register <name> [type]");
|
|
286
|
-
const type = args[1] || "custom";
|
|
287
|
-
const data = await callTool("register_agent", { name, type });
|
|
288
|
-
const text = data?.result?.content?.[0]?.text;
|
|
289
|
-
if (text) {
|
|
290
|
-
try {
|
|
291
|
-
const parsed = JSON.parse(text);
|
|
292
|
-
if (parsed.api_key) {
|
|
293
|
-
saveConfig({ ...getConfig(), api_key: parsed.api_key, agent_id: parsed.agent_id, agent_name: name });
|
|
294
|
-
}
|
|
295
|
-
} catch {}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
293
|
function cmdHeartbeat(args) {
|
|
300
294
|
return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
|
|
301
295
|
}
|
package/src/config.mjs
CHANGED
|
@@ -1,6 +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 { sanitizeConfigSecrets } from "./cli-credentials.mjs";
|
|
4
5
|
|
|
5
6
|
const CONFIG_DIR = join(homedir(), ".botbuddy");
|
|
6
7
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
@@ -13,13 +14,28 @@ export const SERVER_URL = process.env.BOTBUDDY_SERVER_URL
|
|
|
13
14
|
|
|
14
15
|
let config = {};
|
|
15
16
|
|
|
16
|
-
export function loadConfig() {
|
|
17
|
+
export function loadConfig({ platform = process.platform, warn = (m) => console.error(m) } = {}) {
|
|
18
|
+
let raw = {};
|
|
17
19
|
try {
|
|
18
20
|
if (existsSync(CONFIG_FILE)) {
|
|
19
|
-
|
|
21
|
+
raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
20
22
|
}
|
|
21
23
|
} catch {
|
|
22
|
-
|
|
24
|
+
raw = {};
|
|
25
|
+
}
|
|
26
|
+
// BOT-1520 migration: never silently trust secrets left in a plaintext
|
|
27
|
+
// config.json by an older CLI. `api_key` (written by the retired `register`)
|
|
28
|
+
// is always dropped; `access_token` is dropped where a Keychain can hold it.
|
|
29
|
+
// Clear them at rest by rewriting the file, and tell the operator once.
|
|
30
|
+
const { config: sanitized, changed } = sanitizeConfigSecrets(raw, { platform });
|
|
31
|
+
// Adopt the sanitized config in memory unconditionally — a failed best-effort
|
|
32
|
+
// rewrite (read-only FS, permissions) must not drop the non-secret metadata.
|
|
33
|
+
config = sanitized;
|
|
34
|
+
if (changed) {
|
|
35
|
+
try { saveConfig(sanitized); } catch {} // best-effort: clear the secret at rest
|
|
36
|
+
warn(
|
|
37
|
+
"⚠ 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.",
|
|
38
|
+
);
|
|
23
39
|
}
|
|
24
40
|
return config;
|
|
25
41
|
}
|
package/src/stack.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { basename, dirname, join, relative, isAbsolute } from "path";
|
|
|
29
29
|
import { randomUUID } from "crypto";
|
|
30
30
|
import { callToolJson } from "./api.mjs";
|
|
31
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
|
+
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
32
33
|
import { runDockerWorkflow } from "./docker-hygiene.mjs";
|
|
33
34
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
34
35
|
|
|
@@ -350,13 +351,20 @@ export function parseSupabaseStatus(text) {
|
|
|
350
351
|
|
|
351
352
|
// ── runtime (network / process) ──────────────────────────────────────────────
|
|
352
353
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
354
|
+
// BOT-1520: source both auth headers from the Keychain — the owner OAuth token
|
|
355
|
+
// (`botbuddy login`) is preferred, the tenant-bound agent key
|
|
356
|
+
// (`botbuddy profile setup`) is the fallback. No plaintext config.json secret.
|
|
357
|
+
export async function stackAuthHeader() {
|
|
358
|
+
const agentKey = await resolveAgentKey();
|
|
359
|
+
const owner = await resolveOwnerToken({ getConfig });
|
|
360
|
+
if (owner) {
|
|
361
|
+
if (owner.expiresAt && Date.now() >= owner.expiresAt) {
|
|
362
|
+
if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return { Authorization: `Bearer ${owner.token}`, "x-agent-api-key": agentKey || "" };
|
|
358
366
|
}
|
|
359
|
-
if (
|
|
367
|
+
if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
|
|
360
368
|
return null;
|
|
361
369
|
}
|
|
362
370
|
|
|
@@ -676,7 +684,7 @@ export async function cmdUp(opts, {
|
|
|
676
684
|
process.stderr.write(`${yellow("⚠")} stack: OrbStack preflight warns of interface pressure; cleanup is recommended before another stack.\n`);
|
|
677
685
|
}
|
|
678
686
|
}
|
|
679
|
-
const auth = authProvider();
|
|
687
|
+
const auth = await authProvider();
|
|
680
688
|
if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
681
689
|
|
|
682
690
|
const req = await callTool("request_stack_lease", {
|
|
@@ -966,7 +974,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
966
974
|
touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
|
|
967
975
|
release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
|
|
968
976
|
};
|
|
969
|
-
const auth = adapters.auth ?? stackAuthHeader();
|
|
977
|
+
const auth = adapters.auth ?? await stackAuthHeader();
|
|
970
978
|
const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
|
|
971
979
|
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
|
|
972
980
|
const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
|
package/src/wait-core.mjs
CHANGED
|
@@ -611,6 +611,63 @@ export function foldFeedLag(degraded, lagging) {
|
|
|
611
611
|
return degraded;
|
|
612
612
|
}
|
|
613
613
|
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// BOT-1539 — arm-time outstanding-review notice.
|
|
616
|
+
//
|
|
617
|
+
// `pr-review` / `pr-state` are edge-triggered: they wake only on a review event
|
|
618
|
+
// delivered AFTER arm. When the relay reports (via `pr_review_snapshot` on the
|
|
619
|
+
// register receipt) that a PR ALREADY has an outstanding review, warn the
|
|
620
|
+
// operator on stderr so a silent park isn't mistaken for "no review yet". The
|
|
621
|
+
// existing review will NOT re-wake this wait.
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
// Compact, human relative age (e.g. "27m ago", "3h ago", "2d ago"). Returns
|
|
625
|
+
// null when the timestamp is absent or unparseable, so callers can omit the
|
|
626
|
+
// phrase entirely.
|
|
627
|
+
function relativeAge(iso, now) {
|
|
628
|
+
if (!iso) return null;
|
|
629
|
+
const then = Date.parse(iso);
|
|
630
|
+
if (!Number.isFinite(then)) return null;
|
|
631
|
+
const secs = Math.max(0, Math.round((now - then) / 1000));
|
|
632
|
+
if (secs < 60) return `${secs}s ago`;
|
|
633
|
+
const mins = Math.round(secs / 60);
|
|
634
|
+
if (mins < 60) return `${mins}m ago`;
|
|
635
|
+
const hours = Math.round(mins / 60);
|
|
636
|
+
if (hours < 48) return `${hours}h ago`;
|
|
637
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Build one stderr warning line per OUTSTANDING pr-review snapshot entry.
|
|
642
|
+
* Clean (not-outstanding) entries and empty/absent input yield no lines.
|
|
643
|
+
*
|
|
644
|
+
* @param {Array<{repo:string,pr:number,unresolved_threads:number,review_decision:string,pr_updated_at:string|null,existing_review_outstanding:boolean}>|null|undefined} snapshot
|
|
645
|
+
* @param {number} [now] epoch ms, for deterministic age formatting
|
|
646
|
+
* @returns {string[]}
|
|
647
|
+
*/
|
|
648
|
+
export function formatPrReviewSnapshotWarnings(snapshot, now = Date.now()) {
|
|
649
|
+
if (!Array.isArray(snapshot)) return [];
|
|
650
|
+
const lines = [];
|
|
651
|
+
for (const e of snapshot) {
|
|
652
|
+
if (!e || e.existing_review_outstanding !== true) continue;
|
|
653
|
+
const threads = typeof e.unresolved_threads === "number" ? e.unresolved_threads : 0;
|
|
654
|
+
const parts = [`pr-review armed: ${e.repo}#${e.pr} already has a review outstanding`];
|
|
655
|
+
if (threads > 0) {
|
|
656
|
+
parts.push(`${threads} unresolved review thread${threads === 1 ? "" : "s"}`);
|
|
657
|
+
}
|
|
658
|
+
if (e.review_decision && e.review_decision !== "unknown") {
|
|
659
|
+
parts.push(`review_decision=${e.review_decision}`);
|
|
660
|
+
}
|
|
661
|
+
const age = relativeAge(e.pr_updated_at, now);
|
|
662
|
+
if (age) parts.push(`last update ${age}`);
|
|
663
|
+
lines.push(
|
|
664
|
+
`${parts.join(" · ")}. This wait fires only on the NEXT review event; ` +
|
|
665
|
+
`the existing review will not re-wake it.`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
return lines;
|
|
669
|
+
}
|
|
670
|
+
|
|
614
671
|
// ---------------------------------------------------------------------------
|
|
615
672
|
// Matching — does a spine signal frame satisfy a condition?
|
|
616
673
|
// ---------------------------------------------------------------------------
|
package/src/wait.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Usage: botbuddy wait [--any] <condition>... [options]
|
|
14
14
|
// Run botbuddy wait --help for the condition grammar.
|
|
15
15
|
|
|
16
|
-
import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt } from "./wait-core.mjs";
|
|
16
|
+
import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings } from "./wait-core.mjs";
|
|
17
17
|
import { resolveAgentProfile, withPrincipalReceipt } from "./wait-profile.mjs";
|
|
18
18
|
import { VERSION } from "./version.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -52,7 +52,12 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
52
52
|
flip sets payload.mergeable_changed (no new condition).
|
|
53
53
|
pr-review:repo=<owner/repo>[,pr=<n>]
|
|
54
54
|
a new review comment, or a thread resolved/re-opened
|
|
55
|
-
(payload carries the unresolved-thread count)
|
|
55
|
+
(payload carries the unresolved-thread count).
|
|
56
|
+
Edge-triggered: it wakes only on review activity AFTER
|
|
57
|
+
arm. If the PR ALREADY has an outstanding review at arm,
|
|
58
|
+
bb-wait prints a one-line stderr notice (unresolved count,
|
|
59
|
+
review_decision, age) so a silent park isn't mistaken for
|
|
60
|
+
"no review yet" — the existing review will not re-wake it.
|
|
56
61
|
test-run:id=<run_id> a test run reaching a terminal status (owner-scoped)
|
|
57
62
|
lease:id=<lease_id> a batch stack lease (BOT-1218) leaving the queue /
|
|
58
63
|
becoming active — the zero-poll wake for a parked
|
|
@@ -273,6 +278,24 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
273
278
|
err.minimumProtocol = body.minimum_wait_protocol ?? null;
|
|
274
279
|
throw err;
|
|
275
280
|
}
|
|
281
|
+
if (res.status === 409 || res.status === 404) {
|
|
282
|
+
// BOT-1523: `unblocked:<KEY>` hydration at registration could not arm the wait
|
|
283
|
+
// truthfully. These are NOT the untracked-live-only fallback: arming a live-only
|
|
284
|
+
// unblocked wait would skip the server's initial evaluation and could resume a
|
|
285
|
+
// coding session on a false already_unblocked. Surface the typed receipt and a
|
|
286
|
+
// concrete exit code (5=backend/retryable, 4=invalid/not-found).
|
|
287
|
+
const body = await res.json().catch(() => ({}));
|
|
288
|
+
if (body.error === "unblocked_projection_unresolved" || body.error === "unblocked_ticket_not_found") {
|
|
289
|
+
const err = new Error(body.detail || body.error);
|
|
290
|
+
err.hydrationRegister = true;
|
|
291
|
+
err.errorCode = body.error;
|
|
292
|
+
err.ticket = body.ticket ?? null;
|
|
293
|
+
err.retryable = body.retryable ?? null;
|
|
294
|
+
err.hydration = body.hydration ?? null;
|
|
295
|
+
err.notFound = body.error === "unblocked_ticket_not_found";
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
276
299
|
if (!res.ok) {
|
|
277
300
|
const body = await res.json().catch(() => ({}));
|
|
278
301
|
const err = new Error(body.detail || body.message || body.error || `register responded ${res.status}`);
|
|
@@ -316,6 +339,10 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
316
339
|
// echoes session_agent_id only when it overrode the profile agent).
|
|
317
340
|
sessionId: body.session_id ?? null,
|
|
318
341
|
sessionAgentId: body.session_agent_id ?? null,
|
|
342
|
+
// BOT-1539: arm-time snapshot of already-outstanding code review for any
|
|
343
|
+
// pr-review/pr-state target. Report-only — surfaced on stderr so a silent
|
|
344
|
+
// edge-triggered park isn't mistaken for "no review yet".
|
|
345
|
+
prReviewSnapshot: Array.isArray(body.pr_review_snapshot) ? body.pr_review_snapshot : null,
|
|
319
346
|
};
|
|
320
347
|
}
|
|
321
348
|
|
|
@@ -600,6 +627,12 @@ export async function runWait(argv) {
|
|
|
600
627
|
registeredAgentId = reg.agentId;
|
|
601
628
|
registeredSessionAgentId = reg.sessionAgentId;
|
|
602
629
|
registeredSessionId = reg.sessionId;
|
|
630
|
+
// BOT-1539: if any pr-review/pr-state target already has an outstanding
|
|
631
|
+
// review, tell the operator now (stderr, one line per PR). Purely
|
|
632
|
+
// informational — it does not change arming or exit semantics.
|
|
633
|
+
for (const line of formatPrReviewSnapshotWarnings(reg.prReviewSnapshot)) {
|
|
634
|
+
process.stderr.write(`bb-wait: ${line}\n`);
|
|
635
|
+
}
|
|
603
636
|
// BOT-1184: adopt the server's canonical host for each lock condition so the
|
|
604
637
|
// local matcher builds the same subject_key the availability/claim-grant
|
|
605
638
|
// signals carry (armed under an alias like 'jono-mac', the signal uses the
|
|
@@ -678,6 +711,30 @@ export async function runWait(argv) {
|
|
|
678
711
|
});
|
|
679
712
|
process.exit(EXIT.INVALID);
|
|
680
713
|
}
|
|
714
|
+
if (err && err.hydrationRegister) {
|
|
715
|
+
// BOT-1523: `unblocked:<KEY>` could not be hydrated from Linear at
|
|
716
|
+
// registration. Emit the typed receipt and a concrete exit: 4 (INVALID)
|
|
717
|
+
// for a not-found key, 5 (BACKEND) for a retryable backend/config reason.
|
|
718
|
+
const reason = err.hydration?.reason ?? (err.notFound ? "issue_not_found" : "unknown");
|
|
719
|
+
const tenant = opts.agentProfile?.tenant ?? "your-tenant";
|
|
720
|
+
const fix = err.notFound
|
|
721
|
+
? "no Linear issue with that key exists in this workspace"
|
|
722
|
+
: reason === "no_linear_api_key"
|
|
723
|
+
? `add LINEAR_API_KEY for tenant ${tenant} at /settings/integrations#linear`
|
|
724
|
+
: reason === "org_mismatch"
|
|
725
|
+
? "the Linear issue belongs to a different Linear workspace than this tenant"
|
|
726
|
+
: "retry once Linear is reachable";
|
|
727
|
+
process.stderr.write(`bb-wait: unblocked:${err.ticket ?? "?"} cannot register — ${reason}; ${fix}\n`);
|
|
728
|
+
emitReceipt({
|
|
729
|
+
schema_version: 1,
|
|
730
|
+
outcome: "error",
|
|
731
|
+
error: err.errorCode,
|
|
732
|
+
ticket: err.ticket,
|
|
733
|
+
retryable: err.retryable,
|
|
734
|
+
hydration: err.hydration,
|
|
735
|
+
});
|
|
736
|
+
process.exit(err.notFound ? EXIT.INVALID : EXIT.BACKEND);
|
|
737
|
+
}
|
|
681
738
|
if (err && err.invalidCondition) {
|
|
682
739
|
// A rejected condition set is a configuration error, not a wait — fail closed
|
|
683
740
|
// rather than arming an untracked wait that skips the server's initial
|