@indigoai-us/hq-cli 5.345.197 → 5.345.198
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/dist/command-catalog.generated.d.ts +287 -6
- package/dist/command-catalog.generated.js +352 -6
- package/dist/commands/core-resolve-company-native.d.ts +2 -1
- package/dist/commands/core-resolve-company-native.js +2 -24
- package/dist/commands/deploy-domains.d.ts +14 -2
- package/dist/commands/deploy-domains.js +726 -42
- package/dist/commands/integrations-connect.js +41 -3
- package/dist/commands/secrets.js +27 -6
- package/dist/lib/bot/company-bind.d.ts +34 -0
- package/dist/lib/bot/company-bind.js +112 -0
- package/dist/lib/bot/config.js +3 -1
- package/dist/lib/bot/fleet-local.d.ts +12 -0
- package/dist/lib/bot/fleet-local.js +40 -10
- package/dist/lib/bot/run.js +35 -8
- package/dist/lib/bot/session.d.ts +19 -1
- package/dist/lib/bot/session.js +75 -28
- package/dist/lib/core-utils/manifest-slugs.d.ts +2 -0
- package/dist/lib/core-utils/manifest-slugs.js +26 -0
- package/dist/lib/signals/steps/extract.d.ts +7 -0
- package/dist/lib/signals/steps/extract.js +11 -2
- package/dist/secrets-env-action.d.ts +70 -0
- package/dist/secrets-env-action.js +220 -2
- package/package.json +9 -7
|
@@ -365,6 +365,39 @@ function withDocumentedRemoteMcp(target, domainHint) {
|
|
|
365
365
|
},
|
|
366
366
|
};
|
|
367
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Google Workspace is a built-in OAuth connector (provider `google-workspace`,
|
|
370
|
+
* its own `/v1/integrations/google-workspace/connect` flow), not a remote MCP
|
|
371
|
+
* catalog entry. The CLI has no way to start that flow, so a bare `google` fell
|
|
372
|
+
* through to an install by free-text query, which hq-pro answers with
|
|
373
|
+
* "mcpUrl is required" because no catalog entry or domain resolves. The console
|
|
374
|
+
* is the surface that runs the built-in sign-in, so route there.
|
|
375
|
+
*/
|
|
376
|
+
const BUILT_IN_CONSOLE_APPS = {
|
|
377
|
+
google: "Google Workspace",
|
|
378
|
+
"google-workspace": "Google Workspace",
|
|
379
|
+
"google workspace": "Google Workspace",
|
|
380
|
+
google_workspace: "Google Workspace",
|
|
381
|
+
};
|
|
382
|
+
function builtInConsoleApp(app) {
|
|
383
|
+
return BUILT_IN_CONSOLE_APPS[app?.trim().toLowerCase() ?? ""];
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* hq-pro rejects an install/sign-in that names neither an endpoint nor a
|
|
387
|
+
* resolvable catalog entry or domain with a bare "mcpUrl is required". That is
|
|
388
|
+
* the caller's name not matching anything, so say so and name the way forward.
|
|
389
|
+
*/
|
|
390
|
+
function explainUnresolvableApp(target, err) {
|
|
391
|
+
if (!(err instanceof IntegrationsCliError) ||
|
|
392
|
+
err.code !== "DIRECT_MCP_INVALID_INPUT" ||
|
|
393
|
+
target.ref.mcpUrl ||
|
|
394
|
+
target.ref.discoveryReceiptId ||
|
|
395
|
+
target.ref.catalogEntryId) {
|
|
396
|
+
return err;
|
|
397
|
+
}
|
|
398
|
+
return new IntegrationsCliError(`${target.label} was not found in the app catalog, so HQ has no MCP server address for it. ` +
|
|
399
|
+
"Search with `hq integrations catalog <name>`, or re-run with --mcp-url <endpoint>.", { expected: true, status: err.status, code: err.code });
|
|
400
|
+
}
|
|
368
401
|
function unsupportedCatalogSurfaceError(label, cause) {
|
|
369
402
|
return new IntegrationsCliError(`${label} cannot be connected because the app catalog has no MCP server URL. ` +
|
|
370
403
|
"Re-run with --mcp-url <endpoint> (find it with `hq integrations inspect`).", { expected: true, status: cause.status, code: cause.code });
|
|
@@ -1100,6 +1133,11 @@ function reportInstall(result, opts, expectedRevivedConnectionId) {
|
|
|
1100
1133
|
* URL: hq-pro recognizes the domain and revives the revoked row in place.
|
|
1101
1134
|
*/
|
|
1102
1135
|
async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
|
|
1136
|
+
const builtIn = opts.mcpUrl || opts.docsUrl || opts.entryId ? undefined : builtInConsoleApp(app);
|
|
1137
|
+
if (builtIn) {
|
|
1138
|
+
await consoleHandoff({ ref: {}, label: builtIn }, opts);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1103
1141
|
const resolved = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
|
|
1104
1142
|
const scope = connectScopeFromOptions(opts);
|
|
1105
1143
|
const target = scope ? { ...resolved, ref: { ...resolved.ref, scope } } : resolved;
|
|
@@ -1130,7 +1168,7 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
1130
1168
|
await finishWithAccessKey();
|
|
1131
1169
|
return;
|
|
1132
1170
|
}
|
|
1133
|
-
throw err;
|
|
1171
|
+
throw explainUnresolvableApp(target, err);
|
|
1134
1172
|
}
|
|
1135
1173
|
return;
|
|
1136
1174
|
}
|
|
@@ -1174,7 +1212,7 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
1174
1212
|
await finishWithAccessKey(bearerToken);
|
|
1175
1213
|
return;
|
|
1176
1214
|
}
|
|
1177
|
-
throw oauthErr;
|
|
1215
|
+
throw explainUnresolvableApp(target, oauthErr);
|
|
1178
1216
|
}
|
|
1179
1217
|
return;
|
|
1180
1218
|
}
|
|
@@ -1182,7 +1220,7 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
1182
1220
|
err.code === "INTEGRATION_FACTORY_UNSUPPORTED_SURFACE") {
|
|
1183
1221
|
throw unsupportedCatalogSurfaceError(target.label, err);
|
|
1184
1222
|
}
|
|
1185
|
-
throw err;
|
|
1223
|
+
throw explainUnresolvableApp(target, err);
|
|
1186
1224
|
}
|
|
1187
1225
|
}
|
|
1188
1226
|
/**
|
package/dist/commands/secrets.js
CHANGED
|
@@ -2,7 +2,7 @@ import { assertScriptUnchanged, planScriptBinding, selectChildLaunch } from "../
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as yaml from "js-yaml";
|
|
5
|
-
import { assertApiKeyCompany, buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, requestedApiKeyCompany, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
|
|
5
|
+
import { assertApiKeyCompany, assertDelegatedMachineSessionUsable, buildSecretUsage, callerIsCompanyOutpost, companyOutpostIdentityFromToken, companyOutpostReadDenialMessage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, requestedApiKeyCompany, resolveSecretsReadCompanyUid, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
|
|
6
6
|
import { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
7
7
|
import { sessionIdFromEnv } from "../lib/core-utils/session-id.js";
|
|
8
8
|
import { appendPersonalSecretsAudit, isEntityScopeRef, PERSONAL_SCOPE_READ_ONLY_MESSAGE, } from "../lib/personal-secrets-audit.js";
|
|
@@ -797,6 +797,7 @@ export function registerSecretsCommand(program) {
|
|
|
797
797
|
.option("--reveal", "Include the decrypted secret value")
|
|
798
798
|
.action(async (name, opts) => {
|
|
799
799
|
try {
|
|
800
|
+
assertDelegatedMachineSessionUsable();
|
|
800
801
|
const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets get", secretsCredentialOptions());
|
|
801
802
|
if (cred.kind === "api-key") {
|
|
802
803
|
if (!opts.reveal) {
|
|
@@ -848,7 +849,7 @@ export function registerSecretsCommand(program) {
|
|
|
848
849
|
return;
|
|
849
850
|
}
|
|
850
851
|
const token = cred.token;
|
|
851
|
-
const companyUid = await
|
|
852
|
+
const companyUid = await resolveSecretsReadCompanyUid(token, scopeOpts(secrets.opts()));
|
|
852
853
|
const res = await vaultApiFetch({
|
|
853
854
|
token,
|
|
854
855
|
path: buildSecretNamePath(companyUid, name),
|
|
@@ -862,6 +863,16 @@ export function registerSecretsCommand(program) {
|
|
|
862
863
|
console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
|
|
863
864
|
process.exit(1);
|
|
864
865
|
}
|
|
866
|
+
// A company Outpost gets a code-less 403 both for an unshared secret
|
|
867
|
+
// and for an inactive binding; say both, as the reveal path does.
|
|
868
|
+
const outpost = companyOutpostIdentityFromToken(token);
|
|
869
|
+
const outpostDenial = outpost
|
|
870
|
+
? companyOutpostReadDenialMessage(name, res.status, body, companyUid, outpost)
|
|
871
|
+
: null;
|
|
872
|
+
if (outpostDenial) {
|
|
873
|
+
console.error(chalk.red(outpostDenial));
|
|
874
|
+
process.exit(1);
|
|
875
|
+
}
|
|
865
876
|
console.error(chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`));
|
|
866
877
|
process.exit(1);
|
|
867
878
|
}
|
|
@@ -1384,9 +1395,17 @@ export function registerSecretsCommand(program) {
|
|
|
1384
1395
|
// interactive session last ran a hook, and the job would read that
|
|
1385
1396
|
// session's company's same-named secret. Without a session id the caller
|
|
1386
1397
|
// must name its scope. HQ_API_KEY is exempt: the key is bound to one
|
|
1387
|
-
// company and never reads the session lock below.
|
|
1398
|
+
// company and never reads the session lock below. A company Outpost job
|
|
1399
|
+
// is exempt too: its machine identity names its one bound company.
|
|
1400
|
+
// An unusable HQ_MACHINE_SESSION_FILE fails here, naming the file,
|
|
1401
|
+
// before the scope check below can report a generic error.
|
|
1402
|
+
assertDelegatedMachineSessionUsable();
|
|
1388
1403
|
const hasCallerSession = Boolean(sessionIdFromEnv(process.env));
|
|
1389
|
-
if (!hasCallerSession &&
|
|
1404
|
+
if (!hasCallerSession &&
|
|
1405
|
+
!requestedCompany &&
|
|
1406
|
+
!parentScope.personal &&
|
|
1407
|
+
peekHqApiKey() === undefined &&
|
|
1408
|
+
!callerIsCompanyOutpost()) {
|
|
1390
1409
|
console.error(chalk.red("Error:"), SECRETS_EXEC_UNATTENDED_NO_COMPANY_MESSAGE);
|
|
1391
1410
|
process.exit(SECRETS_EXEC_UNATTENDED_NO_COMPANY_EXIT);
|
|
1392
1411
|
}
|
|
@@ -1454,7 +1473,7 @@ export function registerSecretsCommand(program) {
|
|
|
1454
1473
|
?? (unlockedApiKeyCompanyRef
|
|
1455
1474
|
? await assertApiKeyCompany(cred.token, unlockedApiKeyCompanyRef, "secrets exec")
|
|
1456
1475
|
: "__api_key__"))
|
|
1457
|
-
: await
|
|
1476
|
+
: await resolveSecretsReadCompanyUid(cred.token, secretScope, { allowOfflineScopeFallback: true });
|
|
1458
1477
|
if (entityScopeRef && companyUid !== entityScopeRef) {
|
|
1459
1478
|
throw new Error("Another person's personal vault is outside this session's scope; refusing.");
|
|
1460
1479
|
}
|
|
@@ -1482,8 +1501,10 @@ export function registerSecretsCommand(program) {
|
|
|
1482
1501
|
const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, execUsage, { allowOfflineFallback: true, lockModes });
|
|
1483
1502
|
// API-key auth has no signed-in person identity for the CLI telemetry
|
|
1484
1503
|
// endpoint. Do not attribute that read to an unrelated cached session.
|
|
1504
|
+
// hq-pro refuses every route but the secret read to a company Outpost,
|
|
1505
|
+
// telemetry included, so an Outpost read sends no telemetry event.
|
|
1485
1506
|
let revealed;
|
|
1486
|
-
if (cred.kind === "cognito") {
|
|
1507
|
+
if (cred.kind === "cognito" && !companyOutpostIdentityFromToken(cred.token)) {
|
|
1487
1508
|
const { withCliFeatureUsed } = await import("../utils/cli-telemetry.js");
|
|
1488
1509
|
revealed = await withCliFeatureUsed("secret_read", readSecrets, companyUid);
|
|
1489
1510
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Coverage note: Claude bots get the full company hook set (their session id
|
|
12
12
|
* is in the hook payload). Codex/Grok bots only see HQ_SESSION_ID in the env.
|
|
13
13
|
*/
|
|
14
|
+
import { type BotConfig, type BotKind } from "./config.js";
|
|
14
15
|
export interface CompanyBindFs {
|
|
15
16
|
existsSync: (p: string) => boolean;
|
|
16
17
|
readFileSync: (p: string) => string;
|
|
@@ -24,4 +25,37 @@ export declare function ensureBotSessionMeta(hqRoot: string, sessionId: string,
|
|
|
24
25
|
created: boolean;
|
|
25
26
|
changed: boolean;
|
|
26
27
|
};
|
|
28
|
+
/** The company a bot's sessions are locked to, and where that company came from. */
|
|
29
|
+
export interface BotCompanyBinding {
|
|
30
|
+
slug?: string;
|
|
31
|
+
/** True when the slug was read from the HQ's company list rather than bot.json. */
|
|
32
|
+
inferred: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Every entry in <hqRoot>/companies/manifest.yaml except `personal` and
|
|
36
|
+
* `_template`; [] when unreadable. Entries are not filtered by slug shape: an
|
|
37
|
+
* uppercase id such as `cmp_01K...` is still a company, and dropping it would
|
|
38
|
+
* make a two-company HQ look like a one-company HQ.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readManifestCompanies(hqRoot: string): string[];
|
|
41
|
+
/**
|
|
42
|
+
* The company a bot's turns are locked to. An explicit `companySlug` wins. A
|
|
43
|
+
* company bot with exactly one membership binds it. A personal bot (a legacy
|
|
44
|
+
* bot.json without `kind` is personal) binds the HQ's only company when its
|
|
45
|
+
* manifest lists exactly one; with two or more it stays unbound and never
|
|
46
|
+
* guesses. The setup bot is always unbound: it creates companies. Without a
|
|
47
|
+
* lock the scope guard blocks the turn and the model asks "which company?" in
|
|
48
|
+
* every channel, scheduled jobs included.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveBotCompanyBinding(config: Pick<BotConfig, "kind" | "companies" | "workerId" | "companySlug" | "hqRoot">, readCompanies?: (hqRoot: string) => string[]): BotCompanyBinding;
|
|
51
|
+
/** The fingerprint ensureBotCompanyScope records for a bot's explicit company scope. */
|
|
52
|
+
export declare function botCompanyScopeKey(kind: BotKind, companies: readonly string[], companySlug: string | undefined): string;
|
|
53
|
+
/**
|
|
54
|
+
* The scope key a CLI before "personal" stopped counting as a membership wrote
|
|
55
|
+
* for a company bot whose bot.json lists "personal" (the restore path writes
|
|
56
|
+
* that list as is), or undefined for any other bot. Same memberships, same
|
|
57
|
+
* scope: the caller passes it as an equivalent key so the upgrade neither
|
|
58
|
+
* archives the bot's conversations nor refuses to start with pending work.
|
|
59
|
+
*/
|
|
60
|
+
export declare function legacyPersonalEntryScopeKey(config: Pick<BotConfig, "kind" | "companies" | "workerId" | "companySlug">): string | undefined;
|
|
27
61
|
//# sourceMappingURL=company-bind.d.ts.map
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
+
import { parseManifestSlugs } from "../core-utils/manifest-slugs.js";
|
|
17
|
+
import { effectiveBotCompanies, effectiveBotKind, SETUP_BOT_WORKER_ID } from "./config.js";
|
|
16
18
|
export const defaultCompanyBindFs = {
|
|
17
19
|
existsSync: (p) => fs.existsSync(p),
|
|
18
20
|
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
@@ -59,4 +61,114 @@ export function ensureBotSessionMeta(hqRoot, sessionId, companySlug, io = defaul
|
|
|
59
61
|
io.writeFileSync(file, body);
|
|
60
62
|
return { path: file, created: false, changed: true };
|
|
61
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* True when every company-level key line in the manifest is one parseManifestSlugs
|
|
66
|
+
* recognises. Company-level means two-space keys under `companies:`, or top-level
|
|
67
|
+
* keys when the manifest has no `companies:` map. Anything else there (quoted or
|
|
68
|
+
* digit-leading keys, tabs, other indentation, list items) returns false.
|
|
69
|
+
*/
|
|
70
|
+
function manifestKeysFullyParsed(text) {
|
|
71
|
+
const lines = text.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
72
|
+
const meaningful = (l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^(---|\.\.\.)\s*$/.test(l);
|
|
73
|
+
const header = lines.findIndex((l) => /^companies:[ \t]*$/.test(l));
|
|
74
|
+
if (header === -1) {
|
|
75
|
+
return lines.every((l) => !meaningful(l) || /^[A-Za-z][A-Za-z0-9_-]*:/.test(l) || /^[ ]/.test(l));
|
|
76
|
+
}
|
|
77
|
+
// With a `companies:` map, parseManifestSlugs still counts any other top-level
|
|
78
|
+
// key (`version:`, `deployments:`) as a company, so one would be bound. Only
|
|
79
|
+
// `unaffiliated_repos` and its list items may sit beside the map.
|
|
80
|
+
const topLevelOk = lines.every((l, i) => i === header || !meaningful(l) || /^[ \t]/.test(l) ||
|
|
81
|
+
/^unaffiliated_repos:/.test(l) || /^-(?:[ \t]|$)/.test(l));
|
|
82
|
+
if (!topLevelOk)
|
|
83
|
+
return false;
|
|
84
|
+
let level;
|
|
85
|
+
for (const l of lines.slice(header + 1)) {
|
|
86
|
+
if (!meaningful(l))
|
|
87
|
+
continue;
|
|
88
|
+
const indent = /^[ \t]*/.exec(l)[0];
|
|
89
|
+
if (indent === "")
|
|
90
|
+
break;
|
|
91
|
+
if (indent.includes("\t"))
|
|
92
|
+
return false;
|
|
93
|
+
level ??= indent.length;
|
|
94
|
+
if (level !== 2 || indent.length < level)
|
|
95
|
+
return false;
|
|
96
|
+
if (indent.length === 1 || indent.length === 3)
|
|
97
|
+
return false;
|
|
98
|
+
if (indent.length === level && !/^ {2}(?:[A-Za-z][A-Za-z0-9_-]*|_template):/.test(l))
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Every entry in <hqRoot>/companies/manifest.yaml except `personal` and
|
|
105
|
+
* `_template`; [] when unreadable. Entries are not filtered by slug shape: an
|
|
106
|
+
* uppercase id such as `cmp_01K...` is still a company, and dropping it would
|
|
107
|
+
* make a two-company HQ look like a one-company HQ.
|
|
108
|
+
*/
|
|
109
|
+
export function readManifestCompanies(hqRoot) {
|
|
110
|
+
try {
|
|
111
|
+
// A byte order mark (Notepad's UTF-8) would hide the `companies:` header.
|
|
112
|
+
const text = fs.readFileSync(path.join(hqRoot, "companies", "manifest.yaml"), "utf8").replace(/^\uFEFF/, "");
|
|
113
|
+
// A company-level key the parser skips (quoted, digit-leading, odd indent) would
|
|
114
|
+
// be missed by the count and could make a multi-company HQ look single-company.
|
|
115
|
+
if (!manifestKeysFullyParsed(text))
|
|
116
|
+
return [];
|
|
117
|
+
return parseManifestSlugs(text).filter((slug) => slug !== "personal" && slug !== "_template");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The company a bot's turns are locked to. An explicit `companySlug` wins. A
|
|
125
|
+
* company bot with exactly one membership binds it. A personal bot (a legacy
|
|
126
|
+
* bot.json without `kind` is personal) binds the HQ's only company when its
|
|
127
|
+
* manifest lists exactly one; with two or more it stays unbound and never
|
|
128
|
+
* guesses. The setup bot is always unbound: it creates companies. Without a
|
|
129
|
+
* lock the scope guard blocks the turn and the model asks "which company?" in
|
|
130
|
+
* every channel, scheduled jobs included.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveBotCompanyBinding(config, readCompanies = readManifestCompanies) {
|
|
133
|
+
if (config.companySlug)
|
|
134
|
+
return { slug: config.companySlug, inferred: false };
|
|
135
|
+
const kind = effectiveBotKind(config);
|
|
136
|
+
if (kind === "company") {
|
|
137
|
+
const companies = effectiveBotCompanies(config);
|
|
138
|
+
return companies.length === 1 ? { slug: companies[0], inferred: false } : { inferred: false };
|
|
139
|
+
}
|
|
140
|
+
if (config.workerId === SETUP_BOT_WORKER_ID)
|
|
141
|
+
return { inferred: false };
|
|
142
|
+
const hq = readCompanies(config.hqRoot);
|
|
143
|
+
// Bind only when the one entry is also a valid slug; the count above is unfiltered.
|
|
144
|
+
return hq.length === 1 && COMPANY_SLUG_RE.test(hq[0]) ? { slug: hq[0], inferred: true } : { inferred: false };
|
|
145
|
+
}
|
|
146
|
+
/** The fingerprint ensureBotCompanyScope records for a bot's explicit company scope. */
|
|
147
|
+
export function botCompanyScopeKey(kind, companies, companySlug) {
|
|
148
|
+
return JSON.stringify({ kind, companies: [...companies].sort(), companySlug: companySlug ?? null });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The scope key a CLI before "personal" stopped counting as a membership wrote
|
|
152
|
+
* for a company bot whose bot.json lists "personal" (the restore path writes
|
|
153
|
+
* that list as is), or undefined for any other bot. Same memberships, same
|
|
154
|
+
* scope: the caller passes it as an equivalent key so the upgrade neither
|
|
155
|
+
* archives the bot's conversations nor refuses to start with pending work.
|
|
156
|
+
*/
|
|
157
|
+
export function legacyPersonalEntryScopeKey(config) {
|
|
158
|
+
const kind = effectiveBotKind(config);
|
|
159
|
+
if (kind !== "company")
|
|
160
|
+
return undefined;
|
|
161
|
+
const raw = (config.companies ?? []).map((entry) => (typeof entry === "string" ? entry.trim() : ""));
|
|
162
|
+
if (!raw.includes("personal"))
|
|
163
|
+
return undefined;
|
|
164
|
+
const members = effectiveBotCompanies(config);
|
|
165
|
+
const listed = [];
|
|
166
|
+
for (const slug of raw) {
|
|
167
|
+
if ((slug === "personal" || members.includes(slug)) && !listed.includes(slug))
|
|
168
|
+
listed.push(slug);
|
|
169
|
+
}
|
|
170
|
+
// The old rule: an explicit companySlug, else the single listed entry.
|
|
171
|
+
const slug = config.companySlug ?? (listed.length === 1 ? listed[0] : undefined);
|
|
172
|
+
return botCompanyScopeKey(kind, listed, slug);
|
|
173
|
+
}
|
|
62
174
|
//# sourceMappingURL=company-bind.js.map
|
package/dist/lib/bot/config.js
CHANGED
|
@@ -73,7 +73,9 @@ export function effectiveBotCompanies(config) {
|
|
|
73
73
|
const listed = [];
|
|
74
74
|
for (const raw of config.companies ?? []) {
|
|
75
75
|
const slug = typeof raw === "string" ? raw.trim() : "";
|
|
76
|
-
|
|
76
|
+
// "personal" is the reserved no-company scope, never a membership: counting it
|
|
77
|
+
// made a one-company bot look multi-company and left its sessions unbound.
|
|
78
|
+
if (slug && slug !== "personal" && COMPANY_SLUG_RE.test(slug) && !listed.includes(slug))
|
|
77
79
|
listed.push(slug);
|
|
78
80
|
}
|
|
79
81
|
return listed;
|
|
@@ -60,6 +60,8 @@ export interface FleetLocalInstallDeps {
|
|
|
60
60
|
fetch?: typeof fetch;
|
|
61
61
|
token?: () => string;
|
|
62
62
|
hqReleaseLinks?: (release: FleetLocalReleasePin) => Promise<Map<string, string> | null>;
|
|
63
|
+
/** Like `hqReleaseLinks`, but also says why HQ had no links. */
|
|
64
|
+
hqReleaseLookup?: (release: FleetLocalReleasePin) => Promise<HqFleetReleaseLinkLookup>;
|
|
63
65
|
now?: () => Date;
|
|
64
66
|
release?: FleetLocalReleasePin;
|
|
65
67
|
publicKey?: string | readonly string[];
|
|
@@ -205,6 +207,16 @@ export declare function fleetLocalCliCompatibilityWarning(manifest: Buffer, rele
|
|
|
205
207
|
/** `gh auth token` fallback for the release download token. */
|
|
206
208
|
export declare const FLEET_RELEASE_TOKEN_TIMEOUT_MS = 15000;
|
|
207
209
|
export declare function resolveFleetReleaseToken(env?: NodeJS.ProcessEnv, run?: FleetLocalExec): string;
|
|
210
|
+
/** Why HQ gave no download links: no usable person sign-in, or no copy of the release. */
|
|
211
|
+
export type HqFleetReleaseLinkMissing = "signed_out" | "not_published";
|
|
212
|
+
export interface HqFleetReleaseLinkLookup {
|
|
213
|
+
links: Map<string, string> | null;
|
|
214
|
+
reason: HqFleetReleaseLinkMissing | null;
|
|
215
|
+
}
|
|
216
|
+
export declare function lookupHqFleetReleaseLinks(release: FleetLocalReleasePin, deps?: {
|
|
217
|
+
token?: () => Promise<string>;
|
|
218
|
+
fetchApi?: typeof vaultApiFetch;
|
|
219
|
+
}): Promise<HqFleetReleaseLinkLookup>;
|
|
208
220
|
export declare function fetchHqFleetReleaseLinks(release: FleetLocalReleasePin): Promise<Map<string, string> | null>;
|
|
209
221
|
export declare const FLEET_LOCAL_LATEST_TIMEOUT_MS = 5000;
|
|
210
222
|
export declare const FLEET_LOCAL_LATEST_CACHE_MS: number;
|
|
@@ -394,15 +394,15 @@ export function resolveFleetReleaseToken(env = process.env, run = execFileSync)
|
|
|
394
394
|
}
|
|
395
395
|
throw expectedError("HQ could not provide the HQ Fleet Runtime download. Sign in with `hq login` and retry.");
|
|
396
396
|
}
|
|
397
|
-
export async function
|
|
397
|
+
export async function lookupHqFleetReleaseLinks(release, deps = {}) {
|
|
398
398
|
let token;
|
|
399
399
|
try {
|
|
400
|
-
token = await ensureCognitoToken({ tokenSource: "person", interactive: false });
|
|
400
|
+
token = await (deps.token ?? (() => ensureCognitoToken({ tokenSource: "person", interactive: false })))();
|
|
401
401
|
}
|
|
402
402
|
catch {
|
|
403
|
-
return null;
|
|
403
|
+
return { links: null, reason: "signed_out" };
|
|
404
404
|
}
|
|
405
|
-
const response = await vaultApiFetch({
|
|
405
|
+
const response = await (deps.fetchApi ?? vaultApiFetch)({
|
|
406
406
|
token,
|
|
407
407
|
path: `/v1/fleet-local/releases/${encodeURIComponent(release.version)}`,
|
|
408
408
|
signal: AbortSignal.timeout(FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS),
|
|
@@ -411,7 +411,7 @@ export async function fetchHqFleetReleaseLinks(release) {
|
|
|
411
411
|
throw expectedError("HQ refused the HQ Fleet Runtime download. Sign in with `hq login` and retry.");
|
|
412
412
|
}
|
|
413
413
|
if (response.status === 404)
|
|
414
|
-
return null;
|
|
414
|
+
return { links: null, reason: "not_published" };
|
|
415
415
|
if (!response.ok) {
|
|
416
416
|
throw expectedError(`Could not get the HQ Fleet Local ${release.tag} download: HQ returned HTTP ${response.status}.`);
|
|
417
417
|
}
|
|
@@ -421,7 +421,22 @@ export async function fetchHqFleetReleaseLinks(release) {
|
|
|
421
421
|
if (typeof asset.name === "string" && typeof asset.url === "string")
|
|
422
422
|
links.set(asset.name, asset.url);
|
|
423
423
|
}
|
|
424
|
-
return links.size > 0 ? links : null;
|
|
424
|
+
return links.size > 0 ? { links, reason: null } : { links: null, reason: "not_published" };
|
|
425
|
+
}
|
|
426
|
+
export async function fetchHqFleetReleaseLinks(release) {
|
|
427
|
+
return (await lookupHqFleetReleaseLinks(release)).links;
|
|
428
|
+
}
|
|
429
|
+
/** The error for a machine that has neither HQ links nor a GitHub token, naming which one HQ lacked. */
|
|
430
|
+
function noReleaseSourceError(release, reason) {
|
|
431
|
+
if (reason === "not_published") {
|
|
432
|
+
return expectedError(`HQ has not published HQ Fleet Runtime ${release.version} for download yet. ` +
|
|
433
|
+
"Retry in a few minutes; if it keeps failing, report it with `hq bug`.");
|
|
434
|
+
}
|
|
435
|
+
if (reason === "signed_out") {
|
|
436
|
+
return expectedError("The HQ sign-in on this machine could not be used to get the HQ Fleet Runtime download. " +
|
|
437
|
+
"Run `hq login` and retry.");
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
425
440
|
}
|
|
426
441
|
export const FLEET_LOCAL_LATEST_TIMEOUT_MS = 5_000;
|
|
427
442
|
export const FLEET_LOCAL_LATEST_CACHE_MS = 10 * 60_000;
|
|
@@ -769,11 +784,26 @@ export async function installPinnedFleetLocalDistribution(deps = {}) {
|
|
|
769
784
|
}
|
|
770
785
|
else {
|
|
771
786
|
const fetcher = deps.fetch ?? fetch;
|
|
772
|
-
const
|
|
787
|
+
const lookup = deps.hqReleaseLinks
|
|
788
|
+
? { links: await deps.hqReleaseLinks(release), reason: null }
|
|
789
|
+
: await (deps.hqReleaseLookup ?? lookupHqFleetReleaseLinks)(release);
|
|
790
|
+
const links = lookup.links;
|
|
773
791
|
const cachedRuntime = cachedRuntimeArchive(root, release);
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
792
|
+
let files;
|
|
793
|
+
if (links) {
|
|
794
|
+
files = await downloadFleetLocalReleaseFromHq(release, fetcher, links, cachedRuntime);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
let githubToken;
|
|
798
|
+
try {
|
|
799
|
+
githubToken = (deps.token ?? resolveFleetReleaseToken)();
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
// A customer has no GitHub token; say why HQ had no download instead of asking them to sign in again.
|
|
803
|
+
throw noReleaseSourceError(release, lookup.reason) ?? error;
|
|
804
|
+
}
|
|
805
|
+
files = await downloadFleetLocalRelease(release, fetcher, githubToken, cachedRuntime);
|
|
806
|
+
}
|
|
777
807
|
verifyFleetLocalRelease(files, release, publicKey);
|
|
778
808
|
warnCompatibility(files.manifest);
|
|
779
809
|
installFiles(dir, files, release, publicKey);
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -58,7 +58,7 @@ import { fullJitterDelayMs } from "../mesh/live/backoff.js";
|
|
|
58
58
|
import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
59
59
|
import { BotApiError } from "./api.js";
|
|
60
60
|
import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, resolveBotMemoryDir, SETUP_BOT_WORKER_ID } from "./config.js";
|
|
61
|
-
import { ensureBotSessionMeta } from "./company-bind.js";
|
|
61
|
+
import { botCompanyScopeKey, ensureBotSessionMeta, legacyPersonalEntryScopeKey, readManifestCompanies, resolveBotCompanyBinding } from "./company-bind.js";
|
|
62
62
|
import { readBotCredsIdentity } from "./creds.js";
|
|
63
63
|
import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
|
|
64
64
|
import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
|
|
@@ -531,10 +531,24 @@ export async function runBot(deps) {
|
|
|
531
531
|
const kind = effectiveBotKind(config);
|
|
532
532
|
const companies = effectiveBotCompanies(config);
|
|
533
533
|
// A single membership is unambiguous; personal and multi-company bots stay unbound.
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
534
|
+
const binding = resolveBotCompanyBinding(config);
|
|
535
|
+
const companySlug = binding.slug;
|
|
536
|
+
// What bot.json alone says; a lock inferred from the manifest is not part of it.
|
|
537
|
+
const startExplicitSlug = resolveBotCompanyBinding(config, () => []).slug;
|
|
538
|
+
// The explicit scope: bot.json's kind, memberships and company. A lock inferred
|
|
539
|
+
// from the HQ's company list is not part of it.
|
|
540
|
+
const companyScopeKey = kind === "company" || (companySlug !== undefined && !binding.inferred)
|
|
541
|
+
? botCompanyScopeKey(kind, companies, companySlug)
|
|
537
542
|
: undefined;
|
|
543
|
+
// A personal bot with no explicit company records the company it inferred (or
|
|
544
|
+
// null when unbound) beside that key. When the HQ's company list changes, its
|
|
545
|
+
// conversations are archived instead of resumed under a lock they were not
|
|
546
|
+
// created under. The first inferred lock keeps existing history.
|
|
547
|
+
const inferredScopeSlug = companyScopeKey === undefined && kind === "personal" && config.workerId !== SETUP_BOT_WORKER_ID
|
|
548
|
+
? (binding.inferred ? binding.slug ?? null : null)
|
|
549
|
+
: undefined;
|
|
550
|
+
// The key an older CLI wrote when bot.json lists "personal": the same scope.
|
|
551
|
+
const legacyScopeKey = legacyPersonalEntryScopeKey(config);
|
|
538
552
|
const lockDeps = defaultPidLockDeps({
|
|
539
553
|
pid,
|
|
540
554
|
...(deps.isProcessAlive ? { isProcessAlive: deps.isProcessAlive } : {}),
|
|
@@ -679,7 +693,10 @@ export async function runBot(deps) {
|
|
|
679
693
|
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
680
694
|
}
|
|
681
695
|
try {
|
|
682
|
-
const scope = ensureBotCompanyScope(dir, companyScopeKey
|
|
696
|
+
const scope = ensureBotCompanyScope(dir, companyScopeKey, {
|
|
697
|
+
...(inferredScopeSlug !== undefined ? { inferredSlug: inferredScopeSlug } : {}),
|
|
698
|
+
...(legacyScopeKey !== undefined && legacyScopeKey !== companyScopeKey ? { equivalentKeys: [legacyScopeKey] } : {}),
|
|
699
|
+
});
|
|
683
700
|
if (scope.reset)
|
|
684
701
|
log("info", "company scope changed or was unverified; archived prior conversations before starting fresh");
|
|
685
702
|
}
|
|
@@ -1102,14 +1119,24 @@ export async function runBot(deps) {
|
|
|
1102
1119
|
* prepended so a one-word reply still has the question it answers.
|
|
1103
1120
|
*/
|
|
1104
1121
|
const bindingNotices = new Set();
|
|
1122
|
+
let bindingNoticeLogged = false;
|
|
1105
1123
|
const executeTurn = async (prompt, sessionScope, tReceived, stream = {}, historyEventId) => {
|
|
1106
1124
|
const fresh = readBotConfig(dir);
|
|
1107
1125
|
if (!fresh)
|
|
1108
1126
|
throw new CompanyBindingError(`I couldn't read my company settings. Please restart ${config.name} after restoring its configuration; this work is still pending.`);
|
|
1109
1127
|
const live = fresh;
|
|
1110
1128
|
const liveCompanies = effectiveBotCompanies(live);
|
|
1111
|
-
|
|
1112
|
-
|
|
1129
|
+
// Explicit settings only: a lock inferred from the HQ's company list is
|
|
1130
|
+
// fixed at start, so an owner who creates or joins a company never fails a turn.
|
|
1131
|
+
const liveCompanySlug = resolveBotCompanyBinding(live, () => []).slug;
|
|
1132
|
+
if (binding.inferred && !bindingNoticeLogged) {
|
|
1133
|
+
const hqNow = readManifestCompanies(config.hqRoot);
|
|
1134
|
+
if (!(hqNow.length === 1 && hqNow[0] === companySlug)) {
|
|
1135
|
+
bindingNoticeLogged = true;
|
|
1136
|
+
log("warn", `${config.name}: this HQ's company list changed since start; keeping the start-time lock to ${companySlug} until the next restart.`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (liveCompanySlug !== startExplicitSlug || effectiveBotKind(live) !== kind ||
|
|
1113
1140
|
JSON.stringify([...liveCompanies].sort()) !== JSON.stringify([...companies].sort())) {
|
|
1114
1141
|
throw new CompanyBindingError(`My company settings changed. Please restart ${config.name} to load them before I continue; this work is still pending.`);
|
|
1115
1142
|
}
|
|
@@ -1171,7 +1198,7 @@ export async function runBot(deps) {
|
|
|
1171
1198
|
let bind = { env };
|
|
1172
1199
|
if (companySlug) {
|
|
1173
1200
|
try {
|
|
1174
|
-
ensureBotSessionMeta(config.hqRoot, sid,
|
|
1201
|
+
ensureBotSessionMeta(config.hqRoot, sid, companySlug, undefined, now);
|
|
1175
1202
|
}
|
|
1176
1203
|
catch {
|
|
1177
1204
|
throw new CompanyBindingError(`I couldn't bind this session to ${companySlug}. Please check the bot's workspace permissions and retry; this work is still pending.`);
|
|
@@ -18,6 +18,24 @@ export interface BotSessionFile {
|
|
|
18
18
|
export declare function readBotSession(dir: string, runtime: BotRuntimeId, scope?: BotSessionScope): BotSessionFile | null;
|
|
19
19
|
export declare function writeBotSession(dir: string, session: BotSessionFile, scope?: BotSessionScope): void;
|
|
20
20
|
export declare function clearBotSession(dir: string, scope?: BotSessionScope): void;
|
|
21
|
+
export interface BotCompanyScopeOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Keys an earlier CLI wrote for this same scope. A marker holding one is
|
|
24
|
+
* rewritten to the current key in place: nothing is archived and pending
|
|
25
|
+
* work is kept. Used for a company bot whose bot.json lists "personal",
|
|
26
|
+
* which no longer counts as a membership.
|
|
27
|
+
*/
|
|
28
|
+
equivalentKeys?: readonly string[];
|
|
29
|
+
/**
|
|
30
|
+
* A personal bot with no explicit company (companyScopeKey undefined): the
|
|
31
|
+
* company its sessions are locked to because the HQ lists only that one, or
|
|
32
|
+
* null when it is unbound. A change from the recorded value archives the
|
|
33
|
+
* conversations, like an explicit scope change, so a session is never
|
|
34
|
+
* resumed under a lock it was not created under. Ignored when
|
|
35
|
+
* companyScopeKey is set.
|
|
36
|
+
*/
|
|
37
|
+
inferredSlug?: string | null;
|
|
38
|
+
}
|
|
21
39
|
/**
|
|
22
40
|
* Run once at startup, before reading or appending conversation state. A scope
|
|
23
41
|
* change must never resume a prior company's model session or replay its DM
|
|
@@ -26,7 +44,7 @@ export declare function clearBotSession(dir: string, scope?: BotSessionScope): v
|
|
|
26
44
|
* Legacy personal bots retain their existing history; company bots require
|
|
27
45
|
* positive scope evidence and archive legacy, unproven conversations once.
|
|
28
46
|
*/
|
|
29
|
-
export declare function ensureBotCompanyScope(dir: string, companyScopeKey: string | undefined): {
|
|
47
|
+
export declare function ensureBotCompanyScope(dir: string, companyScopeKey: string | undefined, options?: BotCompanyScopeOptions): {
|
|
30
48
|
reset: boolean;
|
|
31
49
|
archiveDir?: string;
|
|
32
50
|
};
|