@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.
@@ -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
  /**
@@ -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 getEntityUid(token, scopeOpts(secrets.opts()));
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 && !requestedCompany && !parentScope.personal && peekHqApiKey() === undefined) {
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 getEntityUid(cred.token, secretScope, { allowOfflineScopeFallback: true });
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
@@ -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
- if (slug && COMPANY_SLUG_RE.test(slug) && !listed.includes(slug))
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 fetchHqFleetReleaseLinks(release) {
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 links = await (deps.hqReleaseLinks ?? fetchHqFleetReleaseLinks)(release);
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
- const files = links
775
- ? await downloadFleetLocalReleaseFromHq(release, fetcher, links, cachedRuntime)
776
- : await downloadFleetLocalRelease(release, fetcher, (deps.token ?? resolveFleetReleaseToken)(), cachedRuntime);
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);
@@ -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 companySlug = config.companySlug ?? (kind === "company" && companies.length === 1 ? companies[0] : undefined);
535
- const companyScopeKey = kind === "company" || companySlug !== undefined
536
- ? JSON.stringify({ kind, companies: [...companies].sort(), companySlug: companySlug ?? null })
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
- const liveCompanySlug = live.companySlug ?? (effectiveBotKind(live) === "company" && liveCompanies.length === 1 ? liveCompanies[0] : undefined);
1112
- if (liveCompanySlug !== companySlug || effectiveBotKind(live) !== kind ||
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, liveCompanySlug, undefined, now);
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
  };