@indigoai-us/hq-cli 5.345.176 → 5.345.178
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 +34 -0
- package/dist/command-catalog.generated.d.ts +8 -0
- package/dist/command-catalog.generated.js +10 -0
- package/dist/commands/bot.d.ts +5 -0
- package/dist/commands/bot.js +186 -21
- package/dist/commands/cloud.d.ts +49 -0
- package/dist/commands/cloud.js +155 -15
- package/dist/commands/dm-attach.js +15 -3
- package/dist/commands/library-add.d.ts +9 -0
- package/dist/commands/library-add.js +19 -2
- package/dist/commands/run.js +9 -1
- package/dist/lib/bot/config.d.ts +2 -0
- package/dist/lib/bot/fleet-default-flag.d.ts +31 -0
- package/dist/lib/bot/fleet-default-flag.js +127 -0
- package/dist/lib/bot/fleet-local.d.ts +29 -0
- package/dist/lib/bot/fleet-local.js +85 -10
- package/dist/lib/bot/index.d.ts +1 -0
- package/dist/lib/bot/index.js +1 -0
- package/dist/lib/bot/local-config.js +1 -1
- package/dist/lib/daemon/catalog.d.ts +2 -0
- package/dist/lib/daemon/catalog.js +7 -2
- package/dist/lib/daemon/config.d.ts +2 -1
- package/dist/lib/daemon/config.js +4 -3
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +26 -6
- package/dist/lib/sync-core/release.d.ts +1 -1
- package/dist/lib/sync-core/release.js +2 -2
- package/dist/run/hq-plugin.d.ts +3 -0
- package/dist/run/hq-plugin.js +18 -0
- package/package.json +7 -7
package/dist/commands/cloud.js
CHANGED
|
@@ -22,7 +22,7 @@ import * as path from "path";
|
|
|
22
22
|
import { share, getStateDir, listJournals, createIgnoreFilter, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, readSyncProgress, syncDoctor, } from "@indigoai-us/hq-cloud";
|
|
23
23
|
import { syncLeg } from "../utils/sync-operation-lock.js";
|
|
24
24
|
import { SYNC_WATCHDOG_EXIT_CODE, SYNC_WATCHDOG_REPORT_GRACE_MS, SyncWatchdogTimeoutError, createSnapshotProgressProbe, resolveSyncWatchdogLimits, withSyncWatchdog, } from "../utils/sync-watchdog.js";
|
|
25
|
-
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, loadCachedTokens, } from "../utils/cognito-session.js";
|
|
25
|
+
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, IdentitySwapRefusal, cognitoCachePrincipalUid, isDefinitivePersonAuthRejection, isIdentitySwapRefusal, loadCachedTokens, } from "../utils/cognito-session.js";
|
|
26
26
|
import { resolveUploadAuthor } from "../utils/upload-author.js";
|
|
27
27
|
import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
|
|
28
28
|
import { beginSyncHealthReport, classifySyncFailure, } from "../utils/client-health.js";
|
|
@@ -66,12 +66,130 @@ async function ensureSyncCognitoToken() {
|
|
|
66
66
|
interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
+
/** Shown when the session expires during a sync and cannot be renewed. */
|
|
70
|
+
export const SYNC_SESSION_EXPIRED_MESSAGE = "Your HQ session expired during sync and could not be refreshed. Run `hq login`, then rerun the sync.";
|
|
71
|
+
/** Shown when the refresh failed for a network or server reason. */
|
|
72
|
+
export const SYNC_REFRESH_UNAVAILABLE_MESSAGE = "Could not refresh your HQ session during sync because the sign-in service did not respond. Check your connection, then rerun the sync.";
|
|
73
|
+
/** Shown when the start token carries no principal to bind the run to. */
|
|
74
|
+
export const SYNC_NO_IDENTITY_MESSAGE = "The HQ session token does not identify an account, so the sync did not start. Run `hq login`, then rerun the sync.";
|
|
75
|
+
/** Shown when the token would change to a different account mid-run. */
|
|
76
|
+
export const SYNC_IDENTITY_CHANGED_MESSAGE = "The signed-in HQ account changed while this sync was running, so the sync stopped instead of continuing as a different account. Rerun the sync as the account you want.";
|
|
77
|
+
/**
|
|
78
|
+
* The session token expired during a sync run and the non-interactive
|
|
79
|
+
* refresh failed. `kind` separates a rejected session (sign in again) from a
|
|
80
|
+
* network or server failure (retry later).
|
|
81
|
+
*/
|
|
82
|
+
export class SyncSessionExpiredError extends Error {
|
|
83
|
+
kind;
|
|
84
|
+
reason;
|
|
85
|
+
constructor(kind, reason) {
|
|
86
|
+
const base = kind === "reauth" ? SYNC_SESSION_EXPIRED_MESSAGE : SYNC_REFRESH_UNAVAILABLE_MESSAGE;
|
|
87
|
+
super(reason ? `${base} (${reason})` : base);
|
|
88
|
+
this.kind = kind;
|
|
89
|
+
this.reason = reason;
|
|
90
|
+
this.name = "SyncSessionExpiredError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Per-config record of the last token-function failure. hq-cloud's one-shot
|
|
95
|
+
* 401 retry swallows getter errors and rethrows the original 401, so
|
|
96
|
+
* {@link formatSyncFailure} reads this (given the run's config) to report the
|
|
97
|
+
* real cause. Keyed by config so two runs in one process never share a record.
|
|
98
|
+
*/
|
|
99
|
+
const syncAuthFailures = new WeakMap();
|
|
100
|
+
/** The last token failure recorded by a {@link buildSyncVaultConfig} config. */
|
|
101
|
+
export function syncAuthFailureFor(config) {
|
|
102
|
+
return config ? (syncAuthFailures.get(config)?.() ?? null) : null;
|
|
103
|
+
}
|
|
104
|
+
function classifyRefreshFailure(err) {
|
|
105
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
106
|
+
const reauth = isDefinitivePersonAuthRejection(err) ||
|
|
107
|
+
/No valid HQ session|Run `hq login`/i.test(reason);
|
|
108
|
+
return new SyncSessionExpiredError(reauth ? "reauth" : "unavailable", reason);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Vault config for a sync run whose token stays valid for the whole run.
|
|
112
|
+
*
|
|
113
|
+
* `initialToken` is resolved (and, on a TTY, logged in) before the run starts.
|
|
114
|
+
* The config carries a getter instead of that string, so every vault request
|
|
115
|
+
* re-reads the cached session and refreshes it when it is close to expiry.
|
|
116
|
+
* When a request still gets a 401, hq-cloud's VaultClient calls the getter
|
|
117
|
+
* once more and retries once if the token changed (no loop). A multi-company
|
|
118
|
+
* `hq sync pull --all` can outlast the one-hour Cognito access token; a
|
|
119
|
+
* captured string made every later target fail with 401 (feedback_3d3fab0b).
|
|
120
|
+
*
|
|
121
|
+
* The getter is bound to the principal of `initialToken`. If a later token
|
|
122
|
+
* belongs to a different principal (a mid-run `hq login` as another account,
|
|
123
|
+
* or the machine-identity fallback after a rejected person refresh), it throws
|
|
124
|
+
* an {@link IdentitySwapRefusal} and the run stops. It never continues as a
|
|
125
|
+
* different principal.
|
|
126
|
+
*
|
|
127
|
+
* Mid-run refreshes never open a browser.
|
|
128
|
+
*/
|
|
129
|
+
export function buildSyncVaultConfig(initialToken, deps = {}) {
|
|
130
|
+
const ensureToken = deps.ensureToken ?? (() => ensureCognitoToken({ interactive: false }));
|
|
131
|
+
const runPrincipal = cognitoCachePrincipalUid(initialToken);
|
|
132
|
+
// A start token with no sub/entityUid cannot be bound to a principal, and a
|
|
133
|
+
// null binding would accept any later identity-less token. Refuse up front.
|
|
134
|
+
if (runPrincipal === null) {
|
|
135
|
+
throw new IdentitySwapRefusal(SYNC_NO_IDENTITY_MESSAGE);
|
|
136
|
+
}
|
|
137
|
+
let refused = null;
|
|
138
|
+
let lastSyncAuthFailure = null;
|
|
139
|
+
const authToken = async () => {
|
|
140
|
+
// Once refused, stay refused for the rest of the run.
|
|
141
|
+
if (refused)
|
|
142
|
+
throw refused;
|
|
143
|
+
let token;
|
|
144
|
+
try {
|
|
145
|
+
token = await ensureToken();
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const failure = isIdentitySwapRefusal(err) ? err : classifyRefreshFailure(err);
|
|
149
|
+
if (isIdentitySwapRefusal(failure))
|
|
150
|
+
refused = failure;
|
|
151
|
+
lastSyncAuthFailure = failure;
|
|
152
|
+
throw failure;
|
|
153
|
+
}
|
|
154
|
+
if (cognitoCachePrincipalUid(token) !== runPrincipal) {
|
|
155
|
+
refused = new IdentitySwapRefusal(SYNC_IDENTITY_CHANGED_MESSAGE);
|
|
156
|
+
lastSyncAuthFailure = refused;
|
|
157
|
+
throw refused;
|
|
158
|
+
}
|
|
159
|
+
lastSyncAuthFailure = null;
|
|
160
|
+
return token;
|
|
161
|
+
};
|
|
162
|
+
const config = { ...buildVaultConfig(initialToken), authToken };
|
|
163
|
+
syncAuthFailures.set(config, () => lastSyncAuthFailure);
|
|
164
|
+
return config;
|
|
165
|
+
}
|
|
166
|
+
function isVaultAuthRejection(err) {
|
|
167
|
+
// Duck-typed (VaultAuthError sets statusCode 401) so this stays compatible
|
|
168
|
+
// across independently released hq-cloud versions.
|
|
169
|
+
if (typeof err !== "object" || err === null)
|
|
170
|
+
return false;
|
|
171
|
+
const e = err;
|
|
172
|
+
return e.name === "VaultAuthError" || e.statusCode === 401;
|
|
173
|
+
}
|
|
69
174
|
/**
|
|
70
175
|
* Preserve the vault's typed write-scope refusal at the CLI boundary. This is
|
|
71
176
|
* deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
|
|
72
177
|
* CLI are released independently.
|
|
73
178
|
*/
|
|
74
179
|
export function formatSyncFailure(err, ctx = {}) {
|
|
180
|
+
if (err instanceof SyncSessionExpiredError || isIdentitySwapRefusal(err)) {
|
|
181
|
+
return err.message;
|
|
182
|
+
}
|
|
183
|
+
if (isVaultAuthRejection(err)) {
|
|
184
|
+
// hq-cloud's 401 retry swallowed the token-function error; report it.
|
|
185
|
+
const recorded = syncAuthFailureFor(ctx.vaultConfig);
|
|
186
|
+
if (recorded)
|
|
187
|
+
return recorded.message;
|
|
188
|
+
// A 401 that survived the one refresh-and-retry: the session is no longer
|
|
189
|
+
// accepted, so the fix is a fresh sign-in.
|
|
190
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
191
|
+
return `${base}. ${SYNC_SESSION_EXPIRED_MESSAGE}`;
|
|
192
|
+
}
|
|
75
193
|
const unreachable = formatUnreachablePushPaths(err, ctx);
|
|
76
194
|
if (unreachable)
|
|
77
195
|
return unreachable;
|
|
@@ -694,7 +812,7 @@ export async function pullAll(options, deps) {
|
|
|
694
812
|
}
|
|
695
813
|
}
|
|
696
814
|
catch (err) {
|
|
697
|
-
const message = formatSyncFailure(err);
|
|
815
|
+
const message = (deps.formatFailure ?? formatSyncFailure)(err);
|
|
698
816
|
result.errors.push({ company: entry.slug, message });
|
|
699
817
|
result.perCompany.push({ slug: entry.slug, error: message });
|
|
700
818
|
}
|
|
@@ -823,7 +941,7 @@ export async function pushAll(options, deps) {
|
|
|
823
941
|
});
|
|
824
942
|
}
|
|
825
943
|
catch (err) {
|
|
826
|
-
const message = formatSyncFailure(err);
|
|
944
|
+
const message = (deps.formatFailure ?? formatSyncFailure)(err);
|
|
827
945
|
result.errors.push({ company: entry.slug, message });
|
|
828
946
|
result.perCompany.push({ slug: entry.slug, error: message });
|
|
829
947
|
}
|
|
@@ -1126,6 +1244,8 @@ export function registerCloudCommands(program) {
|
|
|
1126
1244
|
// Assigned right before the sync engine runs, so option-validation
|
|
1127
1245
|
// failures never count as a sync attempt (US-003).
|
|
1128
1246
|
let syncHealth;
|
|
1247
|
+
/** The run's vault config, so the catch can report its recorded token failure. */
|
|
1248
|
+
let runVaultConfig;
|
|
1129
1249
|
let explicitPersonalPaths;
|
|
1130
1250
|
// Company folder the push resolves relative paths against; names the
|
|
1131
1251
|
// .hqinclude line and the blocked path in failure output.
|
|
@@ -1172,11 +1292,13 @@ export function registerCloudCommands(program) {
|
|
|
1172
1292
|
// Upload creds are pre-vended in entityContext, but prefix filtering
|
|
1173
1293
|
// still needs vault-service membership/grant resolution.
|
|
1174
1294
|
const accessToken = await ensureSyncCognitoToken();
|
|
1175
|
-
scopeVaultConfig =
|
|
1295
|
+
scopeVaultConfig = buildSyncVaultConfig(accessToken);
|
|
1296
|
+
runVaultConfig = scopeVaultConfig;
|
|
1176
1297
|
}
|
|
1177
1298
|
else {
|
|
1178
1299
|
const accessToken = await ensureSyncCognitoToken();
|
|
1179
|
-
vaultConfig =
|
|
1300
|
+
vaultConfig = buildSyncVaultConfig(accessToken);
|
|
1301
|
+
runVaultConfig = vaultConfig;
|
|
1180
1302
|
scopeVaultConfig = vaultConfig;
|
|
1181
1303
|
}
|
|
1182
1304
|
// Resolve the target. For `--personal`, look up the caller's
|
|
@@ -1426,6 +1548,7 @@ export function registerCloudCommands(program) {
|
|
|
1426
1548
|
const message = formatSyncFailure(err, {
|
|
1427
1549
|
hqRoot: options.hqRoot,
|
|
1428
1550
|
companySlug: pushCompanySlug,
|
|
1551
|
+
vaultConfig: runVaultConfig,
|
|
1429
1552
|
});
|
|
1430
1553
|
if (jsonMode) {
|
|
1431
1554
|
// In JSON mode, the parent process is parsing stderr for ndjson —
|
|
@@ -1493,12 +1616,15 @@ export function registerCloudCommands(program) {
|
|
|
1493
1616
|
return;
|
|
1494
1617
|
}
|
|
1495
1618
|
let syncHealth;
|
|
1619
|
+
/** The run's vault config, so the catch can report its recorded token failure. */
|
|
1620
|
+
let runVaultConfig;
|
|
1496
1621
|
try {
|
|
1497
1622
|
console.log(chalk.bold("\nHQ Sync — Pull"));
|
|
1498
1623
|
console.log(` HQ root: ${options.hqRoot}`);
|
|
1499
1624
|
console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
|
|
1500
1625
|
const accessToken = await ensureSyncCognitoToken();
|
|
1501
|
-
const vaultConfig =
|
|
1626
|
+
const vaultConfig = buildSyncVaultConfig(accessToken);
|
|
1627
|
+
runVaultConfig = vaultConfig;
|
|
1502
1628
|
// US-011 (2026-05-21 fix): resolve the caller's sync-config for
|
|
1503
1629
|
// the targeted membership BEFORE the pull, so we can (a) emit the
|
|
1504
1630
|
// narrow-hint banner after success if still on all-mode and
|
|
@@ -1581,7 +1707,7 @@ export function registerCloudCommands(program) {
|
|
|
1581
1707
|
catch (err) {
|
|
1582
1708
|
if (syncHealth)
|
|
1583
1709
|
await syncHealth.failed();
|
|
1584
|
-
const message = formatSyncFailure(err);
|
|
1710
|
+
const message = formatSyncFailure(err, { vaultConfig: runVaultConfig });
|
|
1585
1711
|
console.error(chalk.red("\n✗ Pull failed:"), message);
|
|
1586
1712
|
process.exit(1);
|
|
1587
1713
|
}
|
|
@@ -1843,9 +1969,12 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1843
1969
|
logVaultSkips(skipPersonal, skipCompanies);
|
|
1844
1970
|
console.log("");
|
|
1845
1971
|
let result;
|
|
1972
|
+
/** The run's vault config, so the catch can report its recorded token failure. */
|
|
1973
|
+
let runVaultConfig;
|
|
1846
1974
|
try {
|
|
1847
1975
|
const accessToken = await ensureSyncCognitoToken();
|
|
1848
|
-
const vaultConfig =
|
|
1976
|
+
const vaultConfig = buildSyncVaultConfig(accessToken);
|
|
1977
|
+
runVaultConfig = vaultConfig;
|
|
1849
1978
|
const realClient = new VaultClient(vaultConfig);
|
|
1850
1979
|
const adapter = {
|
|
1851
1980
|
listMyMemberships: () => realClient.listMyMemberships(),
|
|
@@ -1871,6 +2000,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1871
2000
|
...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
|
|
1872
2001
|
}, {
|
|
1873
2002
|
vaultClient: adapter,
|
|
2003
|
+
formatFailure: (err) => formatSyncFailure(err, { vaultConfig }),
|
|
1874
2004
|
// DEV-1768: resolve each company's REAL pull scope (mode + prefixSet)
|
|
1875
2005
|
// via the shared resolver, so the actual pull is scoped — the CLI no
|
|
1876
2006
|
// longer seeds an all-mode PullRecord that wedges the menubar runner.
|
|
@@ -1898,7 +2028,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1898
2028
|
});
|
|
1899
2029
|
}
|
|
1900
2030
|
catch (err) {
|
|
1901
|
-
console.error(chalk.red("\n✗ Pull-all failed:"),
|
|
2031
|
+
console.error(chalk.red("\n✗ Pull-all failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
|
|
1902
2032
|
// Unwind (never process.exit) so withSyncHealthReport records the
|
|
1903
2033
|
// terminal sync_failure before exiting on our behalf.
|
|
1904
2034
|
throw new SyncExitError(1);
|
|
@@ -1937,9 +2067,12 @@ export async function runPullPersonal(hqRoot, onConflict) {
|
|
|
1937
2067
|
console.log(chalk.bold("\nHQ Sync — Pull (personal)"));
|
|
1938
2068
|
console.log(` HQ root: ${hqRoot}`);
|
|
1939
2069
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
2070
|
+
/** The run's vault config, so the catch can report its recorded token failure. */
|
|
2071
|
+
let runVaultConfig;
|
|
1940
2072
|
try {
|
|
1941
2073
|
const accessToken = await ensureSyncCognitoToken();
|
|
1942
|
-
const vaultConfig =
|
|
2074
|
+
const vaultConfig = buildSyncVaultConfig(accessToken);
|
|
2075
|
+
runVaultConfig = vaultConfig;
|
|
1943
2076
|
const client = new VaultClient(vaultConfig);
|
|
1944
2077
|
const personalUid = await resolveCanonicalPersonUid({
|
|
1945
2078
|
listMyMemberships: () => client.listMyMemberships(),
|
|
@@ -1967,7 +2100,7 @@ export async function runPullPersonal(hqRoot, onConflict) {
|
|
|
1967
2100
|
catch (err) {
|
|
1968
2101
|
if (err instanceof SyncExitError)
|
|
1969
2102
|
throw err;
|
|
1970
|
-
console.error(chalk.red("\n✗ Pull (personal) failed:"),
|
|
2103
|
+
console.error(chalk.red("\n✗ Pull (personal) failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
|
|
1971
2104
|
throw new SyncExitError(1);
|
|
1972
2105
|
}
|
|
1973
2106
|
}
|
|
@@ -1983,9 +2116,12 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompani
|
|
|
1983
2116
|
logVaultSkips(skipPersonal, skipCompanies);
|
|
1984
2117
|
console.log("");
|
|
1985
2118
|
let result;
|
|
2119
|
+
/** The run's vault config, so the catch can report its recorded token failure. */
|
|
2120
|
+
let runVaultConfig;
|
|
1986
2121
|
try {
|
|
1987
2122
|
const accessToken = await ensureSyncCognitoToken();
|
|
1988
|
-
const vaultConfig =
|
|
2123
|
+
const vaultConfig = buildSyncVaultConfig(accessToken);
|
|
2124
|
+
runVaultConfig = vaultConfig;
|
|
1989
2125
|
const realClient = new VaultClient(vaultConfig);
|
|
1990
2126
|
const author = resolveUploadAuthor();
|
|
1991
2127
|
const adapter = {
|
|
@@ -2008,6 +2144,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompani
|
|
|
2008
2144
|
...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
|
|
2009
2145
|
}, {
|
|
2010
2146
|
vaultClient: adapter,
|
|
2147
|
+
formatFailure: (err) => formatSyncFailure(err, { vaultConfig }),
|
|
2011
2148
|
resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
|
|
2012
2149
|
beforeShare: async ({ slug, companyUid, companyRoot }) => warnAboutInvalidOntologyEntities({
|
|
2013
2150
|
hqRoot,
|
|
@@ -2052,7 +2189,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompani
|
|
|
2052
2189
|
});
|
|
2053
2190
|
}
|
|
2054
2191
|
catch (err) {
|
|
2055
|
-
console.error(chalk.red("\n✗ Push-all failed:"),
|
|
2192
|
+
console.error(chalk.red("\n✗ Push-all failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
|
|
2056
2193
|
// Unwind (never process.exit) so withSyncHealthReport records the
|
|
2057
2194
|
// terminal sync_failure before exiting on our behalf.
|
|
2058
2195
|
throw new SyncExitError(1);
|
|
@@ -2149,9 +2286,12 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
2149
2286
|
console.log(` HQ root: ${hqRoot}`);
|
|
2150
2287
|
console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
|
|
2151
2288
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
2289
|
+
/** The run's vault config, so the catch can report its recorded token failure. */
|
|
2290
|
+
let runVaultConfig;
|
|
2152
2291
|
try {
|
|
2153
2292
|
const accessToken = await ensureSyncCognitoToken();
|
|
2154
|
-
const vaultConfig =
|
|
2293
|
+
const vaultConfig = buildSyncVaultConfig(accessToken);
|
|
2294
|
+
runVaultConfig = vaultConfig;
|
|
2155
2295
|
const author = resolveUploadAuthor();
|
|
2156
2296
|
// Resolve the target. For --personal, look up the canonical person and
|
|
2157
2297
|
// route paths/journal through personalMode. Otherwise use the company arg
|
|
@@ -2403,7 +2543,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
2403
2543
|
catch (err) {
|
|
2404
2544
|
if (err instanceof SyncExitError)
|
|
2405
2545
|
throw err;
|
|
2406
|
-
console.error(chalk.red("\n✗ Sync now failed:"),
|
|
2546
|
+
console.error(chalk.red("\n✗ Sync now failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
|
|
2407
2547
|
throw new SyncExitError(1);
|
|
2408
2548
|
}
|
|
2409
2549
|
}
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Network is injected (`presign`, `put`) so the sequence is unit-testable.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import { readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { Readable } from "node:stream";
|
|
22
23
|
import path from "node:path";
|
|
23
24
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
24
25
|
import { contentTypeFor, streamPut } from "./library-add.js";
|
|
@@ -89,7 +90,17 @@ export function validateAttachmentPaths(paths, stat = statSync) {
|
|
|
89
90
|
}
|
|
90
91
|
export const defaultAttachmentUploadDeps = {
|
|
91
92
|
presign: (token, body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
|
|
92
|
-
|
|
93
|
+
// Read the bytes once and declare their real length. Streaming with the
|
|
94
|
+
// size from validateAttachmentPaths' earlier stat sent a Content-Length the
|
|
95
|
+
// body could fall short of (file rewritten or truncated in between); S3 then
|
|
96
|
+
// waits ~20s and answers 400 RequestTimeout. Attachments are capped at 25 MB.
|
|
97
|
+
put: (file, url, headers) => {
|
|
98
|
+
const bytes = readFileSync(file.path);
|
|
99
|
+
if (bytes.length > MAX_DM_ATTACHMENT_BYTES) {
|
|
100
|
+
return Promise.reject(new Error(`Attachment too large: ${file.path} grew past the 25 MB limit after it was checked.`));
|
|
101
|
+
}
|
|
102
|
+
return streamPut(url, headers, Readable.from([bytes]), bytes.length);
|
|
103
|
+
},
|
|
93
104
|
};
|
|
94
105
|
/**
|
|
95
106
|
* Presign-put every file into `scopeUid`, then return the `attachments[]`
|
|
@@ -138,7 +149,8 @@ export async function uploadDmAttachments(args) {
|
|
|
138
149
|
}
|
|
139
150
|
const put = await deps.put(p.file, row.url, headers);
|
|
140
151
|
if (put.status < 200 || put.status >= 300) {
|
|
141
|
-
|
|
152
|
+
const reason = [put.errorCode, put.errorMessage].filter(Boolean).join(": ");
|
|
153
|
+
throw new Error(`Upload failed for ${p.file.path} (HTTP ${put.status}${reason ? ` ${reason}` : ""}). The message was not sent.`);
|
|
142
154
|
}
|
|
143
155
|
out.push({
|
|
144
156
|
vaultPath: p.key,
|
|
@@ -19,7 +19,16 @@ export interface OpenedSource {
|
|
|
19
19
|
export interface PutResponse {
|
|
20
20
|
status: number;
|
|
21
21
|
headers: Record<string, string | undefined>;
|
|
22
|
+
/** S3 `<Code>` from a non-2xx response body (e.g. `RequestTimeout`), when present. */
|
|
23
|
+
errorCode?: string;
|
|
24
|
+
/** S3 `<Message>` from a non-2xx response body, when present. */
|
|
25
|
+
errorMessage?: string;
|
|
22
26
|
}
|
|
27
|
+
/** Pull `<Code>` and `<Message>` out of an S3 XML error body. Pure. */
|
|
28
|
+
export declare function parseS3Error(body: string): {
|
|
29
|
+
errorCode?: string;
|
|
30
|
+
errorMessage?: string;
|
|
31
|
+
};
|
|
23
32
|
export interface LibraryAddDeps {
|
|
24
33
|
hqRoot: string;
|
|
25
34
|
now: () => Date;
|
|
@@ -33,6 +33,14 @@ import { CONTENT_SHA256_METADATA_KEY, PLACEHOLDER_SUFFIX, companyOffloadKey, con
|
|
|
33
33
|
import { formatPlanGateError, formatUpgradeRefusal, planGateErrorFromResponse, } from "../utils/plan-gate-error.js";
|
|
34
34
|
/** Metadata key hq-pro binds to `checksumSha256` (sync's content hash). */
|
|
35
35
|
const CONTENT_SHA256_META_KEY = CONTENT_SHA256_METADATA_KEY;
|
|
36
|
+
/** Cap on how much of a non-2xx PUT response body is kept for the error reason. */
|
|
37
|
+
const PUT_ERROR_BODY_LIMIT = 8 * 1024;
|
|
38
|
+
/** Pull `<Code>` and `<Message>` out of an S3 XML error body. Pure. */
|
|
39
|
+
export function parseS3Error(body) {
|
|
40
|
+
const code = /<Code>([^<]{1,200})<\/Code>/.exec(body)?.[1]?.trim();
|
|
41
|
+
const message = /<Message>([^<]{1,500})<\/Message>/.exec(body)?.[1]?.trim();
|
|
42
|
+
return { ...(code ? { errorCode: code } : {}), ...(message ? { errorMessage: message } : {}) };
|
|
43
|
+
}
|
|
36
44
|
const MEDIA_TYPES = {
|
|
37
45
|
".png": "image/png",
|
|
38
46
|
".jpg": "image/jpeg",
|
|
@@ -515,12 +523,21 @@ export function streamPut(url, headers, body, size) {
|
|
|
515
523
|
return new Promise((resolve, reject) => {
|
|
516
524
|
const target = new URL(url);
|
|
517
525
|
const request = (target.protocol === "http:" ? http : https).request(target, { method: "PUT", headers: { ...headers, "content-length": String(size) } }, (res) => {
|
|
518
|
-
res.
|
|
526
|
+
const status = res.statusCode ?? 0;
|
|
527
|
+
// Keep the start of a failure body so the caller can name S3's reason
|
|
528
|
+
// (RequestTimeout, EntityTooLarge, ...) instead of a bare status.
|
|
529
|
+
let errorBody = "";
|
|
530
|
+
res.setEncoding("utf8");
|
|
531
|
+
res.on("data", (chunk) => {
|
|
532
|
+
if ((status < 200 || status >= 300) && errorBody.length < PUT_ERROR_BODY_LIMIT) {
|
|
533
|
+
errorBody += chunk.slice(0, PUT_ERROR_BODY_LIMIT - errorBody.length);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
519
536
|
res.on("end", () => {
|
|
520
537
|
const out = {};
|
|
521
538
|
for (const [k, v] of Object.entries(res.headers))
|
|
522
539
|
out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
523
|
-
resolve({ status:
|
|
540
|
+
resolve({ status, headers: out, ...(errorBody ? parseS3Error(errorBody) : {}) });
|
|
524
541
|
});
|
|
525
542
|
res.on("error", reject);
|
|
526
543
|
});
|
package/dist/commands/run.js
CHANGED
|
@@ -10,7 +10,7 @@ import { computeSha256 } from '../utils/integrity.js';
|
|
|
10
10
|
import { isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from '../utils/secret-load-availability.js';
|
|
11
11
|
import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
|
|
12
12
|
import { discoverSchemas } from '../run/discover-schemas.js';
|
|
13
|
-
import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
|
|
13
|
+
import { installHqPlugin, itemIsOptional, prewarmHqSecrets } from '../run/hq-plugin.js';
|
|
14
14
|
import { assertEnvGraphLoaded, resolveEnvValuesOrThrow, EnvGraphLoadError, EnvResolutionError, } from '../run/env-graph-guard.js';
|
|
15
15
|
import { hadFatalRejection } from '../unhandled-rejection-boundary.js';
|
|
16
16
|
import { withRunSecretReadTelemetry } from './run-feature-telemetry.js';
|
|
@@ -223,6 +223,14 @@ export function registerRunCommand(program) {
|
|
|
223
223
|
const schemaItems = Object.entries(graph.configSchema);
|
|
224
224
|
const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
|
|
225
225
|
.filter((e) => Boolean(e?.isWarning) === wantWarnings)
|
|
226
|
+
.filter((e) => {
|
|
227
|
+
if (wantWarnings)
|
|
228
|
+
return true;
|
|
229
|
+
if (!itemIsOptional(item))
|
|
230
|
+
return true;
|
|
231
|
+
const msg = e?.message ?? '';
|
|
232
|
+
return !/No read permission|does not exist in company|not returned by vault/.test(msg);
|
|
233
|
+
})
|
|
226
234
|
.map((e) => ` ${k}: ${e?.message ?? String(e)}`));
|
|
227
235
|
const warningMsgs = itemMessages(true);
|
|
228
236
|
if (warningMsgs.length > 0) {
|
package/dist/lib/bot/config.d.ts
CHANGED
|
@@ -90,6 +90,8 @@ export interface BotConfig {
|
|
|
90
90
|
ownerUid: string;
|
|
91
91
|
runtime: BotRuntimeId;
|
|
92
92
|
engine?: BotEngineMetadata;
|
|
93
|
+
fleetAuthPending?: boolean;
|
|
94
|
+
fleetServiceInstallPending?: boolean;
|
|
93
95
|
/**
|
|
94
96
|
* "personal" (acts as its owner everywhere) or "company" (acts as itself,
|
|
95
97
|
* member of `companies`). Missing on bots created before kinds existed:
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
/** Personal bots are created on the HQ Fleet Runtime for companies with this flag on. */
|
|
3
|
+
export declare const LOCAL_BOTS_FLEET_DEFAULT_FLAG = "agents.local-bots-hq-fleet-default";
|
|
4
|
+
export declare const LOCAL_BOTS_FLEET_DEFAULT_ENV = "HQ_BOT_FLEET_DEFAULT";
|
|
5
|
+
type DefaultFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
|
|
6
|
+
export interface OwnerMembership {
|
|
7
|
+
companyUid: string;
|
|
8
|
+
companySlug?: string;
|
|
9
|
+
status: string;
|
|
10
|
+
}
|
|
11
|
+
export interface FleetDefaultFlagDependencies {
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
createClient?: (options: FlagClientOptions) => DefaultFlagClient;
|
|
14
|
+
getToken?: FlagClientOptions["getToken"];
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
/** The signed-in owner's memberships; never prompts for sign-in. */
|
|
17
|
+
memberships?: () => Promise<OwnerMembership[]>;
|
|
18
|
+
}
|
|
19
|
+
/** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
|
|
20
|
+
export declare function fleetOwnerCompanies(dependencies?: Pick<FleetDefaultFlagDependencies, "memberships">): Promise<string[]>;
|
|
21
|
+
/**
|
|
22
|
+
* The owner's companies that turned the fleet-default flag on: active
|
|
23
|
+
* memberships whose company folder is on this computer. Any lookup failure
|
|
24
|
+
* leaves that company out. `HQ_BOT_FLEET_DEFAULT=1` skips the flag and keeps
|
|
25
|
+
* every such membership.
|
|
26
|
+
*/
|
|
27
|
+
export declare function fleetDefaultCompanies(hqRoot: string, dependencies?: FleetDefaultFlagDependencies): Promise<string[]>;
|
|
28
|
+
/** `HQ_BOT_FLEET_DEFAULT=1|0` overrides the registry; otherwise on when any flagged company was found. */
|
|
29
|
+
export declare function fleetRuntimeIsDefault(flaggedCompanies: readonly string[], env?: NodeJS.ProcessEnv): boolean;
|
|
30
|
+
export {};
|
|
31
|
+
//# sourceMappingURL=fleet-default-flag.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
4
|
+
import { createFlagClient, } from "@indigoai-us/hq-flags-client";
|
|
5
|
+
import { ensureCognitoToken } from "../../utils/cognito-session.js";
|
|
6
|
+
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
7
|
+
import { resolveFlagGate } from "../flag-registry.js";
|
|
8
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
9
|
+
/** Personal bots are created on the HQ Fleet Runtime for companies with this flag on. */
|
|
10
|
+
export const LOCAL_BOTS_FLEET_DEFAULT_FLAG = "agents.local-bots-hq-fleet-default";
|
|
11
|
+
export const LOCAL_BOTS_FLEET_DEFAULT_ENV = "HQ_BOT_FLEET_DEFAULT";
|
|
12
|
+
const FLAG_TIMEOUT_MS = 3_000;
|
|
13
|
+
const MEMBERSHIP_TIMEOUT_MS = 10_000;
|
|
14
|
+
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
15
|
+
const COMPANY_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
16
|
+
async function signedInMemberships() {
|
|
17
|
+
const token = await ensureCognitoToken({ tokenSource: "person", interactive: false });
|
|
18
|
+
const res = await vaultApiFetch({ token, path: "/membership/me", signal: AbortSignal.timeout(MEMBERSHIP_TIMEOUT_MS) });
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error(`membership lookup returned ${res.status}`);
|
|
21
|
+
const data = (await res.json());
|
|
22
|
+
if (!Array.isArray(data.memberships))
|
|
23
|
+
throw new Error("membership lookup returned no list");
|
|
24
|
+
return data.memberships;
|
|
25
|
+
}
|
|
26
|
+
/** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
|
|
27
|
+
export async function fleetOwnerCompanies(dependencies = {}) {
|
|
28
|
+
const memberships = await (dependencies.memberships ?? signedInMemberships)();
|
|
29
|
+
const companies = new Set();
|
|
30
|
+
for (const membership of memberships) {
|
|
31
|
+
const slug = membership.companySlug?.trim() ?? "";
|
|
32
|
+
if (membership.status !== "active" || !COMPANY_UID_RE.test(membership.companyUid))
|
|
33
|
+
continue;
|
|
34
|
+
if (!COMPANY_SLUG_RE.test(slug) || slug === "personal")
|
|
35
|
+
continue;
|
|
36
|
+
companies.add(slug);
|
|
37
|
+
}
|
|
38
|
+
return [...companies].sort((a, b) => a.localeCompare(b));
|
|
39
|
+
}
|
|
40
|
+
function hasLocalFolder(hqRoot, slug) {
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.lstatSync(path.join(hqRoot, "companies", slug));
|
|
43
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The owner's companies that turned the fleet-default flag on: active
|
|
51
|
+
* memberships whose company folder is on this computer. Any lookup failure
|
|
52
|
+
* leaves that company out. `HQ_BOT_FLEET_DEFAULT=1` skips the flag and keeps
|
|
53
|
+
* every such membership.
|
|
54
|
+
*/
|
|
55
|
+
export async function fleetDefaultCompanies(hqRoot, dependencies = {}) {
|
|
56
|
+
const env = dependencies.env ?? process.env;
|
|
57
|
+
let memberships;
|
|
58
|
+
try {
|
|
59
|
+
memberships = await (dependencies.memberships ?? signedInMemberships)();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const candidates = new Map();
|
|
65
|
+
for (const membership of memberships) {
|
|
66
|
+
const slug = membership.companySlug?.trim() ?? "";
|
|
67
|
+
if (membership.status !== "active" || !COMPANY_UID_RE.test(membership.companyUid))
|
|
68
|
+
continue;
|
|
69
|
+
if (!COMPANY_SLUG_RE.test(slug) || slug === "personal" || !hasLocalFolder(hqRoot, slug))
|
|
70
|
+
continue;
|
|
71
|
+
candidates.set(slug, membership.companyUid);
|
|
72
|
+
}
|
|
73
|
+
const entries = [...candidates.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
74
|
+
const override = env[LOCAL_BOTS_FLEET_DEFAULT_ENV]?.trim();
|
|
75
|
+
if (override === "1" || override === "true")
|
|
76
|
+
return entries.map(([slug]) => slug);
|
|
77
|
+
const createClient = dependencies.createClient ?? createFlagClient;
|
|
78
|
+
const getToken = dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? "");
|
|
79
|
+
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
80
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
81
|
+
const flagOn = async (slug, companyUid) => {
|
|
82
|
+
let client;
|
|
83
|
+
let failed = false;
|
|
84
|
+
try {
|
|
85
|
+
client = createClient({
|
|
86
|
+
endpoint,
|
|
87
|
+
companyUid,
|
|
88
|
+
companyIdentifiers: [slug],
|
|
89
|
+
env: {},
|
|
90
|
+
getToken,
|
|
91
|
+
...(doFetch ? { fetch: doFetch } : {}),
|
|
92
|
+
refreshIntervalMs: 0,
|
|
93
|
+
requestTimeoutMs: FLAG_TIMEOUT_MS,
|
|
94
|
+
onError: () => {
|
|
95
|
+
failed = true;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
await client.ready();
|
|
99
|
+
if (failed)
|
|
100
|
+
return false;
|
|
101
|
+
return resolveFlagGate(client, LOCAL_BOTS_FLEET_DEFAULT_FLAG, { fallback: false }, () => false);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
try {
|
|
108
|
+
client?.close();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* closing a flag client never changes the answer */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const results = await Promise.all(entries.map(([slug, uid]) => flagOn(slug, uid)));
|
|
116
|
+
return entries.filter((_, index) => results[index]).map(([slug]) => slug);
|
|
117
|
+
}
|
|
118
|
+
/** `HQ_BOT_FLEET_DEFAULT=1|0` overrides the registry; otherwise on when any flagged company was found. */
|
|
119
|
+
export function fleetRuntimeIsDefault(flaggedCompanies, env = process.env) {
|
|
120
|
+
const override = env[LOCAL_BOTS_FLEET_DEFAULT_ENV]?.trim();
|
|
121
|
+
if (override === "1" || override === "true")
|
|
122
|
+
return true;
|
|
123
|
+
if (override === "0" || override === "false")
|
|
124
|
+
return false;
|
|
125
|
+
return flaggedCompanies.length > 0;
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=fleet-default-flag.js.map
|