@botbuddy/cli 1.8.6 → 1.8.8
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/botbuddy-release-repair.json +1 -1
- 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/pw/daemon.mjs +53 -4
- package/src/pw/run.mjs +15 -0
- package/src/stack.mjs +16 -8
- package/src/wait.mjs +42 -0
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.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schema_version":1,"source_version":"1.8.
|
|
1
|
+
{"schema_version":1,"source_version":"1.8.7","source_identity":"e6b611000678281287808866482334e5a40e265ef3c0328c4e85f53a5bf23cd0"}
|
|
@@ -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/pw/daemon.mjs
CHANGED
|
@@ -1,13 +1,62 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import net from "node:net";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { normaliseLane } from "./args.mjs";
|
|
7
8
|
export const daemonDir = (env = process.env) => env.BB_PW_DAEMON_DIR || join(homedir(), ".botbuddy", "pw-daemon");
|
|
8
9
|
export function resolveCliBin(env = process.env) { if (env.BB_PW_CLI_BIN) return { cmd: "node", args: [env.BB_PW_CLI_BIN] }; const require = createRequire(import.meta.url); const pkg = require.resolve("@playwright/cli/package.json"), meta = JSON.parse(readFileSync(pkg, "utf8")), bin = typeof meta.bin === "string" ? meta.bin : meta.bin["playwright-cli"] || Object.values(meta.bin)[0]; return { cmd: "node", args: [join(dirname(pkg), bin)] }; }
|
|
9
10
|
export function spawnExec(plan, env = process.env) { const { cmd, args } = resolveCliBin(env); return new Promise((resolve) => { const child = spawn(cmd, [...args, ...plan.execArgv], { stdio: "inherit", env: { ...env, PWTEST_DAEMON_SESSION_DIR: daemonDir(env) } }); child.on("exit", (code) => resolve(code ?? 1)); child.on("error", () => resolve(127)); }); }
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
// BOT-1522: playwright-cli keeps one sub-dir per invocation context under the
|
|
12
|
+
// daemon dir, and only ever cleans up its OWN sub-dir's `<session>.session`. A
|
|
13
|
+
// lane opened from another worktree whose daemon has since exited leaves a file
|
|
14
|
+
// pointing at a socket that no longer exists — and it can sort before today's
|
|
15
|
+
// live one. Selection is therefore by liveness, never by readdir order, and the
|
|
16
|
+
// caller gets a remediation naming the lane and the command, never a raw syscall.
|
|
17
|
+
export const laneOf = (session) => normaliseLane(session ?? "");
|
|
18
|
+
// Any connect-phase failure means the daemon is not there to answer; name the
|
|
19
|
+
// shape in prose so the syscall code never reaches the operator (AC-3/AC-6).
|
|
20
|
+
const CONNECT_FAILURE = { ENOENT: "its socket is gone", ECONNREFUSED: "its socket refused the connection", ENOTSOCK: "its socket refused the connection", ECONNRESET: "the connection dropped", EPIPE: "the connection dropped" };
|
|
21
|
+
const connectFailure = (error) => (error?.code ? CONNECT_FAILURE[error.code] ?? "the connection failed" : null);
|
|
22
|
+
export function notRunningMessage(session, ignored = [], detail = null) {
|
|
23
|
+
const why = ignored.length ? `ignored stale session file(s): ${ignored.map((entry) => `${entry.file} [${entry.reason}]`).join(", ")}` : detail ?? "no session file found";
|
|
24
|
+
return `bb-pw: ${session} daemon is not running (${why}). Run \`bb-pw ${laneOf(session) || "<lane>"} open <url>\` first, then retry.`;
|
|
25
|
+
}
|
|
26
|
+
// Every `<root>/<sub>/<session>.session`, newest first. A file whose socket path
|
|
27
|
+
// is ABSENT on disk is provably dead (the daemon's tmp dir is gone) and is
|
|
28
|
+
// pruned; one whose socket EXISTS is kept — the daemon may be starting — and
|
|
29
|
+
// liveness is decided by connecting (resolveSession), not by the file. Corrupt
|
|
30
|
+
// JSON is left alone: it is not provably dead. Only this session's file is read,
|
|
31
|
+
// so another lane's live daemon in the same sub-dir is never touched.
|
|
32
|
+
export function scanSessions(session, env = process.env, { prune = true } = {}) {
|
|
33
|
+
const root = daemonDir(env), candidates = [], ignored = [];
|
|
34
|
+
if (!existsSync(root)) return { candidates, ignored };
|
|
35
|
+
for (const item of readdirSync(root)) {
|
|
36
|
+
const file = join(root, item, `${session}.session`);
|
|
37
|
+
let data; try { data = JSON.parse(readFileSync(file, "utf8")); } catch { continue; }
|
|
38
|
+
if (!data?.socketPath) { ignored.push({ file, reason: "no socket path" }); continue; }
|
|
39
|
+
if (!existsSync(data.socketPath)) { ignored.push({ file, reason: "socket missing" }); if (prune) { try { unlinkSync(file); } catch {} } continue; }
|
|
40
|
+
let mtimeMs = 0; try { mtimeMs = statSync(file).mtimeMs; } catch {}
|
|
41
|
+
candidates.push({ file, data, mtimeMs });
|
|
42
|
+
}
|
|
43
|
+
// Newest first; a deterministic path tiebreak so an mtime tie never falls back to readdir order.
|
|
44
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || b.file.localeCompare(a.file));
|
|
45
|
+
return { candidates, ignored };
|
|
46
|
+
}
|
|
47
|
+
// Synchronous, back-compatible, READ-ONLY reader (reap.mjs): the newest session
|
|
48
|
+
// whose socket path exists on disk. Pruning belongs to the socket path (socketRun).
|
|
49
|
+
export function readSession(session, env = process.env) { return scanSessions(session, env, { prune: false }).candidates[0]?.data ?? null; }
|
|
50
|
+
export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000, session = null } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => { const why = session ? connectFailure(error) : null; finish({ ok: false, error: why ? notRunningMessage(session, [], why) : error.message }); }); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
|
|
12
51
|
export function socketAlive(socketPath, { connect = net.createConnection, timeoutMs = 1000 } = {}) { return new Promise((resolve) => { if (!socketPath) return resolve(false); let done = false, socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => finish(true)); socket.on("error", () => finish(false)); setTimeout(() => finish(false), timeoutMs).unref(); }); }
|
|
13
|
-
|
|
52
|
+
// Authoritative, async: the newest candidate whose socket actually ANSWERS.
|
|
53
|
+
export async function resolveSession(session, env = process.env, { alive = socketAlive } = {}) {
|
|
54
|
+
const { candidates, ignored } = scanSessions(session, env);
|
|
55
|
+
for (const candidate of candidates) { if (await alive(candidate.data.socketPath)) return { session: candidate.data, ignored }; ignored.push({ file: candidate.file, reason: "did not answer" }); }
|
|
56
|
+
return { session: null, ignored };
|
|
57
|
+
}
|
|
58
|
+
export async function socketRun(plan, env = process.env, { alive, send = sendToDaemon } = {}) {
|
|
59
|
+
const { session, ignored } = await resolveSession(plan.session, env, { alive });
|
|
60
|
+
if (!session) return { ok: false, error: notRunningMessage(plan.session, ignored) };
|
|
61
|
+
return send(session.socketPath, plan.socketArgs, { session: plan.session });
|
|
62
|
+
}
|
package/src/pw/run.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { canonicalizeHostString } from "./host.mjs";
|
|
|
7
7
|
import { resolveAgentProfile } from "../wait-profile.mjs";
|
|
8
8
|
import { readProfileIdentity } from "../agent-credential-store.mjs";
|
|
9
9
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
10
|
+
import { VERSION } from "../version.mjs";
|
|
10
11
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
11
12
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
12
13
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
@@ -68,8 +69,22 @@ async function gate({ env, host, lane, deps }) {
|
|
|
68
69
|
return { allowed: false, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
// BOT-1522: a stale global install (1.6.1 on the reporting host) replayed the
|
|
73
|
+
// pre-BOT-1488 refusal and was indistinguishable from a code regression. Every
|
|
74
|
+
// non-zero exit now names the bundled version, and --version exists at all, so
|
|
75
|
+
// an agent can tell "old binary" from "real bug" without reading the source.
|
|
76
|
+
const versionLine = () => `bb-pw (@botbuddy/cli v${VERSION})`;
|
|
71
77
|
export async function runPw(argv, deps = {}) {
|
|
78
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
79
|
+
const code = await runPwInner(argv, deps);
|
|
80
|
+
// Neutral: a refusal may be correct policy, not staleness — the line only lets
|
|
81
|
+
// the reader compare this binary against cli/package.json in their checkout.
|
|
82
|
+
if (code !== 0) stderr.write(`${versionLine()} — if your checkout's cli/package.json is newer, this global is stale: npm i -g @botbuddy/cli@latest, or run node cli/bin/bb-pw.mjs from the repo.\n`);
|
|
83
|
+
return code;
|
|
84
|
+
}
|
|
85
|
+
async function runPwInner(argv, deps = {}) {
|
|
72
86
|
const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
|
|
87
|
+
if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
|
|
73
88
|
while (args[0] === "--profile" || args[0] === "--session-id") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--profile" ? { ...deps, profile: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
|
|
74
89
|
let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
|
|
75
90
|
const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
|
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.mjs
CHANGED
|
@@ -273,6 +273,24 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
273
273
|
err.minimumProtocol = body.minimum_wait_protocol ?? null;
|
|
274
274
|
throw err;
|
|
275
275
|
}
|
|
276
|
+
if (res.status === 409 || res.status === 404) {
|
|
277
|
+
// BOT-1523: `unblocked:<KEY>` hydration at registration could not arm the wait
|
|
278
|
+
// truthfully. These are NOT the untracked-live-only fallback: arming a live-only
|
|
279
|
+
// unblocked wait would skip the server's initial evaluation and could resume a
|
|
280
|
+
// coding session on a false already_unblocked. Surface the typed receipt and a
|
|
281
|
+
// concrete exit code (5=backend/retryable, 4=invalid/not-found).
|
|
282
|
+
const body = await res.json().catch(() => ({}));
|
|
283
|
+
if (body.error === "unblocked_projection_unresolved" || body.error === "unblocked_ticket_not_found") {
|
|
284
|
+
const err = new Error(body.detail || body.error);
|
|
285
|
+
err.hydrationRegister = true;
|
|
286
|
+
err.errorCode = body.error;
|
|
287
|
+
err.ticket = body.ticket ?? null;
|
|
288
|
+
err.retryable = body.retryable ?? null;
|
|
289
|
+
err.hydration = body.hydration ?? null;
|
|
290
|
+
err.notFound = body.error === "unblocked_ticket_not_found";
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
276
294
|
if (!res.ok) {
|
|
277
295
|
const body = await res.json().catch(() => ({}));
|
|
278
296
|
const err = new Error(body.detail || body.message || body.error || `register responded ${res.status}`);
|
|
@@ -678,6 +696,30 @@ export async function runWait(argv) {
|
|
|
678
696
|
});
|
|
679
697
|
process.exit(EXIT.INVALID);
|
|
680
698
|
}
|
|
699
|
+
if (err && err.hydrationRegister) {
|
|
700
|
+
// BOT-1523: `unblocked:<KEY>` could not be hydrated from Linear at
|
|
701
|
+
// registration. Emit the typed receipt and a concrete exit: 4 (INVALID)
|
|
702
|
+
// for a not-found key, 5 (BACKEND) for a retryable backend/config reason.
|
|
703
|
+
const reason = err.hydration?.reason ?? (err.notFound ? "issue_not_found" : "unknown");
|
|
704
|
+
const tenant = opts.agentProfile?.tenant ?? "your-tenant";
|
|
705
|
+
const fix = err.notFound
|
|
706
|
+
? "no Linear issue with that key exists in this workspace"
|
|
707
|
+
: reason === "no_linear_api_key"
|
|
708
|
+
? `add LINEAR_API_KEY for tenant ${tenant} at /settings/integrations#linear`
|
|
709
|
+
: reason === "org_mismatch"
|
|
710
|
+
? "the Linear issue belongs to a different Linear workspace than this tenant"
|
|
711
|
+
: "retry once Linear is reachable";
|
|
712
|
+
process.stderr.write(`bb-wait: unblocked:${err.ticket ?? "?"} cannot register — ${reason}; ${fix}\n`);
|
|
713
|
+
emitReceipt({
|
|
714
|
+
schema_version: 1,
|
|
715
|
+
outcome: "error",
|
|
716
|
+
error: err.errorCode,
|
|
717
|
+
ticket: err.ticket,
|
|
718
|
+
retryable: err.retryable,
|
|
719
|
+
hydration: err.hydration,
|
|
720
|
+
});
|
|
721
|
+
process.exit(err.notFound ? EXIT.INVALID : EXIT.BACKEND);
|
|
722
|
+
}
|
|
681
723
|
if (err && err.invalidCondition) {
|
|
682
724
|
// A rejected condition set is a configuration error, not a wait — fail closed
|
|
683
725
|
// rather than arming an untracked wait that skips the server's initial
|