@indigoai-us/hq-cli 5.345.150 → 5.345.152
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 +47 -0
- package/dist/command-catalog.generated.d.ts +3 -3
- package/dist/command-catalog.generated.js +3 -3
- package/dist/commands/cloud-off-gate.d.ts +138 -0
- package/dist/commands/cloud-off-gate.js +234 -0
- package/dist/commands/cloud.d.ts +7 -0
- package/dist/commands/cloud.js +59 -17
- package/dist/commands/core-handoff.js +6 -1
- package/dist/commands/files.js +23 -7
- package/dist/commands/meetings.js +7 -3
- package/dist/commands/rescue.d.ts +7 -0
- package/dist/commands/rescue.js +22 -13
- package/dist/commands/secrets.d.ts +13 -0
- package/dist/commands/secrets.js +51 -8
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +186 -186
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/daemon/catalog.d.ts +7 -0
- package/dist/lib/daemon/catalog.js +10 -1
- package/dist/lib/daemon/run.js +13 -0
- package/dist/lib/lanes/links.js +27 -11
- package/dist/lib/vault-sync-switches.d.ts +41 -0
- package/dist/lib/vault-sync-switches.js +86 -0
- package/dist/utils/hook-trust.d.ts +27 -0
- package/dist/utils/hook-trust.js +150 -8
- package/dist/utils/shipped-codex-hooks.d.ts +17 -0
- package/dist/utils/shipped-codex-hooks.js +86 -0
- package/package.json +8 -8
package/dist/commands/cloud.js
CHANGED
|
@@ -28,6 +28,7 @@ import { maybeShowCliInviteNudge } from "../lib/cli-invite-nudge.js";
|
|
|
28
28
|
import { createPlanLimitRefusalTracker, describeRefusedCount, describeRefusedFiles, mergePlanLimitRefusals, renderPlanLimitRefusalLines, trackingProgressRecorder, } from "../lib/billing/plan-limit-refusals.js";
|
|
29
29
|
import { formatPlanGateError, planGateErrorFromVaultError, } from "../utils/plan-gate-error.js";
|
|
30
30
|
import { personalTargetedPushPathsEnabled } from "./personal-targeted-push-flag.js";
|
|
31
|
+
import { CLOUD_OFF_EXIT_CODE, VaultSyncOffError, assertEntityVaultSyncAllowed, refuseSyncForOffVaults, writeVaultRefusal, } from "./cloud-off-gate.js";
|
|
31
32
|
import { resolveExplicitPersonalPushPaths } from "./personal-targeted-push-paths.js";
|
|
32
33
|
/**
|
|
33
34
|
* Terminal-failure signal for the sync fanout runners. The runners
|
|
@@ -295,6 +296,12 @@ function narrowNudgeExceedsSize(hqRoot, ref) {
|
|
|
295
296
|
const threshold = resolveNarrowHintMinBytes({ hqRoot });
|
|
296
297
|
return companyFolderExceedsThreshold(companyDir, threshold);
|
|
297
298
|
}
|
|
299
|
+
/** True when a membership's vault is switched off on this computer (US-016). */
|
|
300
|
+
function isSkippedCompany(skip, slug, companyUid) {
|
|
301
|
+
if (!skip || skip.size === 0)
|
|
302
|
+
return false;
|
|
303
|
+
return skip.has(slug.toLowerCase()) || skip.has(companyUid.toLowerCase());
|
|
304
|
+
}
|
|
298
305
|
export async function pullAll(options, deps) {
|
|
299
306
|
const memberships = await deps.vaultClient.listMyMemberships();
|
|
300
307
|
const persons = await deps.vaultClient.listPersonEntities();
|
|
@@ -311,6 +318,8 @@ export async function pullAll(options, deps) {
|
|
|
311
318
|
catch {
|
|
312
319
|
// Best-effort — keep UID as the row label rather than aborting the run.
|
|
313
320
|
}
|
|
321
|
+
if (isSkippedCompany(options.skipCompanies, slug, m.companyUid))
|
|
322
|
+
continue;
|
|
314
323
|
plan.push({
|
|
315
324
|
slug,
|
|
316
325
|
companyUid: m.companyUid,
|
|
@@ -464,6 +473,8 @@ export async function pushAll(options, deps) {
|
|
|
464
473
|
catch {
|
|
465
474
|
// Best-effort — keep UID as the row label.
|
|
466
475
|
}
|
|
476
|
+
if (isSkippedCompany(options.skipCompanies, slug, m.companyUid))
|
|
477
|
+
continue;
|
|
467
478
|
plan.push({
|
|
468
479
|
slug,
|
|
469
480
|
companyUid: m.companyUid,
|
|
@@ -804,6 +815,13 @@ export function registerCloudCommands(program) {
|
|
|
804
815
|
"a toggle, and so CLI users can opt out one-off. Ignored outside " +
|
|
805
816
|
"`--all`.")
|
|
806
817
|
.action(async (paths, options) => {
|
|
818
|
+
const skips = refuseSyncForOffVaults("push", {
|
|
819
|
+
all: options.all,
|
|
820
|
+
personal: options.personal,
|
|
821
|
+
company: options.company,
|
|
822
|
+
hqRoot: options.hqRoot,
|
|
823
|
+
credsFromStdin: options.credsFromStdin,
|
|
824
|
+
}, { json: options.json === true });
|
|
807
825
|
try {
|
|
808
826
|
configureSyncLockTimeout(options.lockTimeout);
|
|
809
827
|
assertSingleSelector(options, "push");
|
|
@@ -833,7 +851,7 @@ export function registerCloudCommands(program) {
|
|
|
833
851
|
// `--all` the flag has no effect (logged above as part of the
|
|
834
852
|
// option's help text).
|
|
835
853
|
await withSyncHealthReport(async () => {
|
|
836
|
-
const refused = await runPushAll(options.hqRoot, options.message, options.onConflict,
|
|
854
|
+
const refused = await runPushAll(options.hqRoot, options.message, options.onConflict, skips.skipPersonal, skips.skipCompanies);
|
|
837
855
|
// A plan-limit refusal is not a sync failure, but the push did
|
|
838
856
|
// not upload everything it was asked to: exit non-zero.
|
|
839
857
|
if (refused)
|
|
@@ -891,6 +909,9 @@ export function registerCloudCommands(program) {
|
|
|
891
909
|
catch (e) {
|
|
892
910
|
throw new Error(`--creds-from-stdin: failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
893
911
|
}
|
|
912
|
+
// Sync switches (US-016): with no --company the target is only
|
|
913
|
+
// known now. Check it before any login or transfer.
|
|
914
|
+
assertEntityVaultSyncAllowed("push", entityContext, options.hqRoot);
|
|
894
915
|
// Upload creds are pre-vended in entityContext, but prefix filtering
|
|
895
916
|
// still needs vault-service membership/grant resolution.
|
|
896
917
|
const accessToken = await ensureSyncCognitoToken();
|
|
@@ -1085,6 +1106,11 @@ export function registerCloudCommands(program) {
|
|
|
1085
1106
|
}
|
|
1086
1107
|
}
|
|
1087
1108
|
catch (err) {
|
|
1109
|
+
if (err instanceof VaultSyncOffError) {
|
|
1110
|
+
// Not a sync failure: the target vault is switched off.
|
|
1111
|
+
writeVaultRefusal(err.refusal, { json: jsonMode });
|
|
1112
|
+
process.exit(CLOUD_OFF_EXIT_CODE);
|
|
1113
|
+
}
|
|
1088
1114
|
if (syncHealth)
|
|
1089
1115
|
await syncHealth.failed();
|
|
1090
1116
|
const message = formatSyncFailure(err);
|
|
@@ -1128,6 +1154,12 @@ export function registerCloudCommands(program) {
|
|
|
1128
1154
|
"disk and only un-tracked from sync; clean out-of-scope files are " +
|
|
1129
1155
|
"quarantined under .hq/scope-quarantine/ (recoverable).")
|
|
1130
1156
|
.action(async (options) => {
|
|
1157
|
+
const skips = refuseSyncForOffVaults("pull", {
|
|
1158
|
+
all: options.all,
|
|
1159
|
+
personal: options.personal,
|
|
1160
|
+
company: options.company,
|
|
1161
|
+
hqRoot: options.hqRoot,
|
|
1162
|
+
});
|
|
1131
1163
|
try {
|
|
1132
1164
|
configureSyncLockTimeout(options.lockTimeout);
|
|
1133
1165
|
assertSingleSelector(options, "pull");
|
|
@@ -1140,7 +1172,7 @@ export function registerCloudCommands(program) {
|
|
|
1140
1172
|
// `options.personal === false` is Commander's auto-negation of
|
|
1141
1173
|
// `--personal`; in `--all` mode that means "drop the personal
|
|
1142
1174
|
// leg from the fanout" (see `--no-personal` option above).
|
|
1143
|
-
await withSyncHealthReport(() => runPullAll(options.hqRoot, options.onConflict, options.modeAll === true,
|
|
1175
|
+
await withSyncHealthReport(() => runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, skips.skipPersonal, options.forceScopeShrink === true, skips.skipCompanies));
|
|
1144
1176
|
return;
|
|
1145
1177
|
}
|
|
1146
1178
|
if (options.personal) {
|
|
@@ -1352,6 +1384,12 @@ export function registerCloudCommands(program) {
|
|
|
1352
1384
|
"anyway. Dirty files are KEPT on disk (only un-tracked); clean " +
|
|
1353
1385
|
"out-of-scope files are quarantined under .hq/scope-quarantine/.")
|
|
1354
1386
|
.action(async (options) => {
|
|
1387
|
+
const skips = refuseSyncForOffVaults("now", {
|
|
1388
|
+
all: options.all,
|
|
1389
|
+
personal: options.personal,
|
|
1390
|
+
company: options.company,
|
|
1391
|
+
hqRoot: options.hqRoot,
|
|
1392
|
+
});
|
|
1355
1393
|
try {
|
|
1356
1394
|
configureSyncLockTimeout(options.lockTimeout);
|
|
1357
1395
|
assertSingleSelector(options, "now");
|
|
@@ -1360,7 +1398,7 @@ export function registerCloudCommands(program) {
|
|
|
1360
1398
|
// of `--personal`; in `--all` mode that means "drop the
|
|
1361
1399
|
// personal leg from both legs of the bidirectional fanout"
|
|
1362
1400
|
// (see `--no-personal` option above).
|
|
1363
|
-
await withSyncHealthReport(() => runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true,
|
|
1401
|
+
await withSyncHealthReport(() => runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, skips.skipPersonal, options.forceScopeShrink === true, skips.skipCompanies));
|
|
1364
1402
|
return;
|
|
1365
1403
|
}
|
|
1366
1404
|
await withSyncHealthReport(() => runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true, options.forceScopeShrink === true));
|
|
@@ -1446,13 +1484,19 @@ export async function runSyncDoctorReconcileConflicts(hqRoot, yes) {
|
|
|
1446
1484
|
console.log(chalk.dim(" Re-run with --yes to apply the plan above."));
|
|
1447
1485
|
}
|
|
1448
1486
|
}
|
|
1449
|
-
|
|
1487
|
+
/** Banner lines for the vaults an `--all` run leaves out. */
|
|
1488
|
+
function logVaultSkips(skipPersonal, skipCompanies) {
|
|
1489
|
+
if (skipPersonal)
|
|
1490
|
+
console.log(` Personal: skipped (sync is off for it, or --no-personal)`);
|
|
1491
|
+
if (skipCompanies && skipCompanies.size > 0) {
|
|
1492
|
+
console.log(` Skipped: ${[...skipCompanies].sort().join(", ")} (sync is off on this computer)`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink, skipCompanies) {
|
|
1450
1496
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
1451
1497
|
console.log(` HQ root: ${hqRoot}`);
|
|
1452
1498
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1453
|
-
|
|
1454
|
-
console.log(` Personal: skipped (--no-personal)`);
|
|
1455
|
-
}
|
|
1499
|
+
logVaultSkips(skipPersonal, skipCompanies);
|
|
1456
1500
|
console.log("");
|
|
1457
1501
|
let result;
|
|
1458
1502
|
try {
|
|
@@ -1480,6 +1524,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1480
1524
|
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
1481
1525
|
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
1482
1526
|
...(forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1527
|
+
...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
|
|
1483
1528
|
}, {
|
|
1484
1529
|
vaultClient: adapter,
|
|
1485
1530
|
// DEV-1768: resolve each company's REAL pull scope (mode + prefixSet)
|
|
@@ -1587,13 +1632,11 @@ export async function runPullPersonal(hqRoot, onConflict) {
|
|
|
1587
1632
|
* limit refused across every target (null when none were), so the caller can
|
|
1588
1633
|
* refuse to report success; hard failures still throw {@link SyncExitError}.
|
|
1589
1634
|
*/
|
|
1590
|
-
async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
1635
|
+
async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompanies) {
|
|
1591
1636
|
console.log(chalk.bold("\nHQ Sync — Push (all)"));
|
|
1592
1637
|
console.log(` HQ root: ${hqRoot}`);
|
|
1593
1638
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1594
|
-
|
|
1595
|
-
console.log(` Personal: skipped (--no-personal)`);
|
|
1596
|
-
}
|
|
1639
|
+
logVaultSkips(skipPersonal, skipCompanies);
|
|
1597
1640
|
console.log("");
|
|
1598
1641
|
let result;
|
|
1599
1642
|
try {
|
|
@@ -1618,6 +1661,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
|
1618
1661
|
...(onConflict ? { onConflict } : {}),
|
|
1619
1662
|
...(message ? { message } : {}),
|
|
1620
1663
|
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
1664
|
+
...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
|
|
1621
1665
|
}, {
|
|
1622
1666
|
vaultClient: adapter,
|
|
1623
1667
|
resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
|
|
@@ -1998,24 +2042,22 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1998
2042
|
throw new SyncExitError(1);
|
|
1999
2043
|
}
|
|
2000
2044
|
}
|
|
2001
|
-
async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal, forceScopeShrink) {
|
|
2045
|
+
async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal, forceScopeShrink, skipCompanies) {
|
|
2002
2046
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
2003
2047
|
console.log(` HQ root: ${hqRoot}`);
|
|
2004
2048
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
2005
|
-
|
|
2006
|
-
console.log(` Personal: skipped (--no-personal)`);
|
|
2007
|
-
}
|
|
2049
|
+
logVaultSkips(skipPersonal, skipCompanies);
|
|
2008
2050
|
console.log("");
|
|
2009
2051
|
// Push first (matches runner), then pull. Re-uses the per-leg orchestrators
|
|
2010
2052
|
// so the per-target rendering, error isolation, and exit codes are
|
|
2011
2053
|
// identical to running `push --all` then `pull --all` back-to-back.
|
|
2012
2054
|
console.log(chalk.dim("→ push --all"));
|
|
2013
|
-
const refused = await runPushAll(hqRoot, message, onConflict, skipPersonal);
|
|
2055
|
+
const refused = await runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompanies);
|
|
2014
2056
|
console.log(chalk.dim("\n→ pull --all"));
|
|
2015
2057
|
// US-011: forward --mode-all so the strict refusal applies to the
|
|
2016
2058
|
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
2017
2059
|
// pull-side).
|
|
2018
|
-
await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink);
|
|
2060
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink, skipCompanies);
|
|
2019
2061
|
if (refused) {
|
|
2020
2062
|
// A refused push does not stop the pull, but the run must not end looking
|
|
2021
2063
|
// like a clean sync. Its per-target details were printed above.
|
|
@@ -13,6 +13,7 @@ import { defaultBackgroundDependencies, runBackgroundLauncher, } from "../lib/se
|
|
|
13
13
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
14
14
|
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
15
15
|
import { describeCoreCommand } from "./core-command-catalog.js";
|
|
16
|
+
import { CLOUD_OFF_EXIT_CODE } from "./cloud-off-gate.js";
|
|
16
17
|
import { shellDisplayPath } from "../lib/core-utils/shell-display-path.js";
|
|
17
18
|
import { fingerprintTrailerArgs, offlineFingerprint } from "../lib/fingerprint/offline.js";
|
|
18
19
|
import { compactJson, isRecord, jqTruthy, prettyJson, runHandoffOpenSteps, shellDate, } from "./core-handoff-open-steps.js";
|
|
@@ -1057,7 +1058,11 @@ export function runHandoffPostWorker(args, context) {
|
|
|
1057
1058
|
stdout: typeof pushed.stdout === "string" ? pushed.stdout : "",
|
|
1058
1059
|
stderr: typeof pushed.stderr === "string" ? pushed.stderr : "",
|
|
1059
1060
|
});
|
|
1060
|
-
log(pushed.status === 0
|
|
1061
|
+
log(pushed.status === 0
|
|
1062
|
+
? `workspace-sync: pushed companies/${company}/workspace`
|
|
1063
|
+
: pushed.status === CLOUD_OFF_EXIT_CODE
|
|
1064
|
+
? `workspace-sync: skipped (sync is off) companies/${company}/workspace`
|
|
1065
|
+
: `workspace-sync: failed companies/${company}/workspace`);
|
|
1061
1066
|
}
|
|
1062
1067
|
}
|
|
1063
1068
|
else if (threadFile && fs.existsSync(threadFile))
|
package/dist/commands/files.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as readline from "node:readline";
|
|
|
4
4
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
5
5
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
6
6
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
7
|
-
import { GROUP_ID_PATTERN, EMAIL_PATTERN, isPrivateFolderPattern, normalizeFilePrefix, toSharedFolderGlob, } from "./_patterns.js";
|
|
7
|
+
import { GROUP_ID_PATTERN, EMAIL_PATTERN, isPrivateFolderPattern, normalizeFilePrefix, OUTPOST_UID_PATTERN, toSharedFolderGlob, } from "./_patterns.js";
|
|
8
8
|
import { looksLikeCompanyUid } from "../utils/vault-api.js";
|
|
9
9
|
import { AuthError } from "../utils/auth-error.js";
|
|
10
10
|
import { formatAccessDenied, offerAccessLadder, } from "../utils/access-denied-hint.js";
|
|
@@ -159,7 +159,7 @@ export function registerFilesCommand(program) {
|
|
|
159
159
|
files
|
|
160
160
|
.command("share [paths...]")
|
|
161
161
|
.description("Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
|
|
162
|
-
.option("--with <principal>", "Email address, group id, or '@all' to share with every active company member")
|
|
162
|
+
.option("--with <principal>", "Email address, group id, company Outpost id (out_...), or '@all' to share with every active company member")
|
|
163
163
|
.option("--permission <level>", "Permission level (only with --with): read | write")
|
|
164
164
|
.option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
|
|
165
165
|
.option("--expires <duration>", "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.")
|
|
@@ -268,7 +268,7 @@ export function registerFilesCommand(program) {
|
|
|
268
268
|
files
|
|
269
269
|
.command("unshare <prefix>")
|
|
270
270
|
.description("Remove a named file access grant, or remove an empty ACL row or grantee-less entries without --with.")
|
|
271
|
-
.option("--with <principal>", "Person uid, agent uid, email address, group id, or '@all' to remove the grant")
|
|
271
|
+
.option("--with <principal>", "Person uid, agent uid, company Outpost id (out_...), email address, group id, or '@all' to remove the grant")
|
|
272
272
|
.action(async (prefix, opts) => {
|
|
273
273
|
try {
|
|
274
274
|
// Unshare MUTATES access with no preview. Anchored content paths remain
|
|
@@ -286,9 +286,14 @@ export function registerFilesCommand(program) {
|
|
|
286
286
|
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
287
287
|
const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
|
|
288
288
|
const isAgent = !isAll && !isEmail && !isGroup && AGENT_UID_PATTERN.test(principal);
|
|
289
|
-
|
|
289
|
+
// A company Outpost (out_<ULID>) rides the person grantee slot on the wire.
|
|
290
|
+
const isPerson = !isAll &&
|
|
291
|
+
!isEmail &&
|
|
292
|
+
!isGroup &&
|
|
293
|
+
!isAgent &&
|
|
294
|
+
(PERSON_UID_PATTERN.test(principal) || OUTPOST_UID_PATTERN.test(principal));
|
|
290
295
|
if (opts.with && !isAll && !isEmail && !isGroup && !isPerson && !isAgent) {
|
|
291
|
-
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid matching prs_<alphanumeric>, an agent uid matching agt_<alphanumeric>, or a group id matching grp_<alphanumeric>`));
|
|
296
|
+
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid matching prs_<alphanumeric>, an agent uid matching agt_<alphanumeric>, a company Outpost id (out_<ULID>), or a group id matching grp_<alphanumeric>`));
|
|
292
297
|
process.exit(1);
|
|
293
298
|
}
|
|
294
299
|
// The vault ACL API stores first-class agent access on its backwards-
|
|
@@ -481,6 +486,9 @@ export function registerFilesCommand(program) {
|
|
|
481
486
|
if (e.granteeType === "company-wide") {
|
|
482
487
|
return "Everyone in company (including guests)";
|
|
483
488
|
}
|
|
489
|
+
if (e.granteeType === "person" && OUTPOST_UID_PATTERN.test(e.granteeId)) {
|
|
490
|
+
return `Outpost (${e.granteeId})`;
|
|
491
|
+
}
|
|
484
492
|
return e.granteeId;
|
|
485
493
|
}
|
|
486
494
|
function printEntryTable(rows, showSource) {
|
|
@@ -773,9 +781,17 @@ async function runDirectGrant(params) {
|
|
|
773
781
|
const isAll = principal === "@all";
|
|
774
782
|
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
775
783
|
const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
|
|
776
|
-
|
|
784
|
+
// A company Outpost (out_<ULID>) gets member-equivalent file access and
|
|
785
|
+
// rides the person grantee slot on the wire: { granteeType: "person",
|
|
786
|
+
// granteeId: "out_..." }. hq-pro validates out_ grantees (active binding,
|
|
787
|
+
// same company) from the paired server change; older servers store it as an
|
|
788
|
+
// inert person grant.
|
|
789
|
+
const isPerson = !isAll &&
|
|
790
|
+
!isEmail &&
|
|
791
|
+
!isGroup &&
|
|
792
|
+
(PERSON_UID_PATTERN.test(principal) || OUTPOST_UID_PATTERN.test(principal));
|
|
777
793
|
if (!isAll && !isEmail && !isGroup && !isPerson) {
|
|
778
|
-
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid, or a group id matching grp_<alphanumeric>`));
|
|
794
|
+
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, a person uid, a company Outpost id (out_<ULID>), or a group id matching grp_<alphanumeric>`));
|
|
779
795
|
process.exit(1);
|
|
780
796
|
}
|
|
781
797
|
const granteeType = isAll
|
|
@@ -658,9 +658,13 @@ export function registerMeetingsCommand(program) {
|
|
|
658
658
|
try {
|
|
659
659
|
const token = await ensureCognitoToken();
|
|
660
660
|
const companySlug = meetings.opts().company;
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
661
|
+
// Always name a company. With no companyId the backend files the bot
|
|
662
|
+
// under Personal, which can never record, so an entitled user would
|
|
663
|
+
// get the HQ Workforce upgrade prompt. Without --company this uses the
|
|
664
|
+
// caller's only active company, or asks them to pick one.
|
|
665
|
+
const query = {
|
|
666
|
+
companyId: await getCompanyUid(token, companySlug),
|
|
667
|
+
};
|
|
664
668
|
const res = await vaultApiFetch({
|
|
665
669
|
token,
|
|
666
670
|
method: "POST",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* `HQ_NO_UPDATE_CHECK=1`; always best-effort.
|
|
21
21
|
*/
|
|
22
22
|
import { Command } from 'commander';
|
|
23
|
+
import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
|
|
23
24
|
export interface RescueTarget {
|
|
24
25
|
source: string;
|
|
25
26
|
/** undefined → let the script apply its own default ref (`main`). */
|
|
@@ -45,6 +46,12 @@ export declare function shouldAttemptSelfUpdate(opts: {
|
|
|
45
46
|
check?: boolean;
|
|
46
47
|
selfUpdate?: boolean;
|
|
47
48
|
}): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Re-trust HQ's Codex hooks after a rescue that changed files on disk.
|
|
51
|
+
* Exported for tests. Best-effort: a trust failure is reported but never
|
|
52
|
+
* changes the rescue's exit status, because the core update itself succeeded.
|
|
53
|
+
*/
|
|
54
|
+
export declare function retrustHooksAfterRescue(hqRoot: string, retrust?: typeof retrustCodexHooksAfterRescue): Promise<void>;
|
|
48
55
|
/** Latest release tag for a repo, e.g. `v12.3.0`. Throws on failure. */
|
|
49
56
|
export declare function getLatestReleaseTag(repo: string, token?: string): Promise<string>;
|
|
50
57
|
/** Resolve a tag to its commit SHA, dereferencing annotated tags. */
|
package/dist/commands/rescue.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
1
|
import * as fs from 'fs';
|
|
3
2
|
import * as path from 'path';
|
|
4
3
|
import * as yaml from 'js-yaml';
|
|
@@ -6,8 +5,8 @@ import chalk from 'chalk';
|
|
|
6
5
|
import { rescue } from '@indigoai-us/hq-cloud';
|
|
7
6
|
import { findHqRoot } from '../utils/manifest.js';
|
|
8
7
|
import { selfUpdateAndReexec } from '../utils/self-update.js';
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
|
|
9
|
+
import { PROD_SOURCE, STAGING_SOURCE, resolveGhToken } from '../utils/shipped-codex-hooks.js';
|
|
11
10
|
/**
|
|
12
11
|
* Resolve the source repo + ref from the user's flags. Pure + exported for
|
|
13
12
|
* tests. `latestTag` is the resolved latest release tag (prod only); ignored
|
|
@@ -28,20 +27,27 @@ export function resolveRescueTarget(opts, latestTag) {
|
|
|
28
27
|
export function shouldAttemptSelfUpdate(opts) {
|
|
29
28
|
return opts.selfUpdate !== false && !opts.check;
|
|
30
29
|
}
|
|
31
|
-
/**
|
|
32
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Re-trust HQ's Codex hooks after a rescue that changed files on disk.
|
|
32
|
+
* Exported for tests. Best-effort: a trust failure is reported but never
|
|
33
|
+
* changes the rescue's exit status, because the core update itself succeeded.
|
|
34
|
+
*/
|
|
35
|
+
export async function retrustHooksAfterRescue(hqRoot, retrust = retrustCodexHooksAfterRescue) {
|
|
36
|
+
let root = hqRoot;
|
|
33
37
|
try {
|
|
34
|
-
|
|
35
|
-
if (res.status === 0) {
|
|
36
|
-
const tok = res.stdout.trim();
|
|
37
|
-
if (tok)
|
|
38
|
-
return tok;
|
|
39
|
-
}
|
|
38
|
+
root = fs.realpathSync(hqRoot);
|
|
40
39
|
}
|
|
41
40
|
catch {
|
|
42
|
-
//
|
|
41
|
+
// Keep the path as given; trust reports its own failure if it is unusable.
|
|
42
|
+
}
|
|
43
|
+
const result = await retrust(root);
|
|
44
|
+
if (result.status === 'trusted') {
|
|
45
|
+
console.log(`rescue: re-trusted ${result.trusted} Codex HQ hook${result.trusted === 1 ? '' : 's'}`);
|
|
46
|
+
}
|
|
47
|
+
else if (result.status === 'failed') {
|
|
48
|
+
console.warn(chalk.yellow(`rescue: could not re-trust Codex HQ hooks: ${result.reason ?? 'unknown error'}. ` +
|
|
49
|
+
'Codex will skip changed HQ hooks until you run `hq reindex`.'));
|
|
43
50
|
}
|
|
44
|
-
return process.env.GH_TOKEN || process.env.GITHUB_TOKEN || undefined;
|
|
45
51
|
}
|
|
46
52
|
function ghHeaders(token) {
|
|
47
53
|
const headers = {
|
|
@@ -178,6 +184,9 @@ export function registerRescueCommand(program) {
|
|
|
178
184
|
cloudUpdate: opts.cloudUpdate,
|
|
179
185
|
ghToken: token,
|
|
180
186
|
});
|
|
187
|
+
if (status === 0 && !opts.check) {
|
|
188
|
+
await retrustHooksAfterRescue(hqRoot);
|
|
189
|
+
}
|
|
181
190
|
process.exit(status);
|
|
182
191
|
}
|
|
183
192
|
catch (error) {
|
|
@@ -3,6 +3,13 @@ export { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
|
3
3
|
export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
|
|
4
4
|
export type { SecretLoadResponse, SecretScriptLockMode, SecretTier, SecretUsage, SecretUsageChannel } from "../secrets-env-action.js";
|
|
5
5
|
export type { SecretScriptUsage, SecretMetadata, SecretLoadSuccessRow } from "../secrets-env-action.js";
|
|
6
|
+
/**
|
|
7
|
+
* Exit code for `hq secrets exec` run with no session id and no explicit
|
|
8
|
+
* company. 78 is EX_CONFIG from sysexits.h: the caller's invocation is missing
|
|
9
|
+
* required configuration, so retrying the same command will not help.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SECRETS_EXEC_UNATTENDED_NO_COMPANY_EXIT = 78;
|
|
12
|
+
export declare const SECRETS_EXEC_UNATTENDED_NO_COMPANY_MESSAGE: string;
|
|
6
13
|
import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
|
|
7
14
|
export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
8
15
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
@@ -17,6 +24,12 @@ interface SecretAclPrincipal {
|
|
|
17
24
|
granteeType: "email" | "group" | "company-wide" | "person";
|
|
18
25
|
granteeId: string;
|
|
19
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* hq-pro refuses any Outpost grant above read with 400
|
|
29
|
+
* OUTPOST_GRANT_READ_ONLY. The CLI refuses it first with the same message.
|
|
30
|
+
*/
|
|
31
|
+
export declare const OUTPOST_GRANT_READ_ONLY_CODE = "OUTPOST_GRANT_READ_ONLY";
|
|
32
|
+
export declare const OUTPOST_GRANT_READ_ONLY_MESSAGE = "Company Outposts can only be given read access to a secret.";
|
|
20
33
|
export declare function parseSecretAclPrincipal(principal: string): SecretAclPrincipal | null;
|
|
21
34
|
export interface SecretInjectionRecipe {
|
|
22
35
|
header: string;
|
package/dist/commands/secrets.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import * as yaml from "js-yaml";
|
|
4
4
|
import { assertApiKeyCompany, buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, requestedApiKeyCompany, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
|
|
5
5
|
import { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
6
|
+
import { sessionIdFromEnv } from "../lib/core-utils/session-id.js";
|
|
6
7
|
export { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
7
8
|
export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
|
|
8
9
|
function companyUidForSlug(root, slug) {
|
|
@@ -53,6 +54,16 @@ async function resolveApiKeyCompanyWithinLock(input) {
|
|
|
53
54
|
}
|
|
54
55
|
import chalk from "chalk";
|
|
55
56
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
57
|
+
/**
|
|
58
|
+
* Exit code for `hq secrets exec` run with no session id and no explicit
|
|
59
|
+
* company. 78 is EX_CONFIG from sysexits.h: the caller's invocation is missing
|
|
60
|
+
* required configuration, so retrying the same command will not help.
|
|
61
|
+
*/
|
|
62
|
+
export const SECRETS_EXEC_UNATTENDED_NO_COMPANY_EXIT = 78;
|
|
63
|
+
export const SECRETS_EXEC_UNATTENDED_NO_COMPANY_MESSAGE = "hq secrets exec has no session id (HQ_SESSION_ID, CLAUDE_CODE_SESSION_ID, CLAUDE_SESSION_ID, " +
|
|
64
|
+
"CODEX_SESSION_ID, CODEX_THREAD_ID and GROK_SESSION_ID are all unset), so it will not borrow a company " +
|
|
65
|
+
"from whichever interactive session last wrote workspace/sessions/.current. " +
|
|
66
|
+
"Pass --company <slug> (or --personal), or set HQ_SESSION_ID to the session this job runs under.";
|
|
56
67
|
import { removeCacheEntry, clearAllCache } from "../utils/secrets-cache.js";
|
|
57
68
|
import { isHqCliChild, releaseChildWrapperPackageUseLeaseAfterStart } from "../utils/child-wrapper-lease.js";
|
|
58
69
|
import { resolveDefaultHqRoot } from "../utils/hq-root-resolver.js";
|
|
@@ -63,7 +74,7 @@ import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
|
63
74
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN, OUTPOST_UID_PATTERN, } from "./_patterns.js";
|
|
64
75
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
65
76
|
import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
|
|
66
|
-
import { formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
|
|
77
|
+
import { formatApiKeyCapabilityDenial, peekHqApiKey, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
|
|
67
78
|
import { runSandboxJobAfterSubmission } from "../utils/secrets-sandbox-resident.js";
|
|
68
79
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
69
80
|
/**
|
|
@@ -277,6 +288,12 @@ function promptSecretInteractively() {
|
|
|
277
288
|
process.stdin.resume();
|
|
278
289
|
});
|
|
279
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* hq-pro refuses any Outpost grant above read with 400
|
|
293
|
+
* OUTPOST_GRANT_READ_ONLY. The CLI refuses it first with the same message.
|
|
294
|
+
*/
|
|
295
|
+
export const OUTPOST_GRANT_READ_ONLY_CODE = "OUTPOST_GRANT_READ_ONLY";
|
|
296
|
+
export const OUTPOST_GRANT_READ_ONLY_MESSAGE = "Company Outposts can only be given read access to a secret.";
|
|
280
297
|
export function parseSecretAclPrincipal(principal) {
|
|
281
298
|
const p = principal.trim();
|
|
282
299
|
if (p === "@all") {
|
|
@@ -1164,11 +1181,23 @@ export function registerSecretsCommand(program) {
|
|
|
1164
1181
|
process.exit(1);
|
|
1165
1182
|
}
|
|
1166
1183
|
const keys = parseSecretNameList(_opts.only);
|
|
1167
|
-
const multiCompanyLockEnabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
|
|
1168
|
-
const sessionLock = multiCompanyLockEnabled ? readSessionCompanyLock(process.env) : undefined;
|
|
1169
1184
|
const parentScope = secrets.opts();
|
|
1170
1185
|
const inheritedCompany = parentScope.company;
|
|
1171
1186
|
const requestedCompany = _opts.company ?? inheritedCompany;
|
|
1187
|
+
// A launchd or cron job has no session id. Session resolution would then
|
|
1188
|
+
// fall back to workspace/sessions/.current, which names whichever
|
|
1189
|
+
// interactive session last ran a hook, and the job would read that
|
|
1190
|
+
// session's company's same-named secret. Without a session id the caller
|
|
1191
|
+
// must name its scope. HQ_API_KEY is exempt: the key is bound to one
|
|
1192
|
+
// company and never reads the session lock below.
|
|
1193
|
+
const hasCallerSession = Boolean(sessionIdFromEnv(process.env));
|
|
1194
|
+
if (!hasCallerSession && !requestedCompany && !parentScope.personal && peekHqApiKey() === undefined) {
|
|
1195
|
+
console.error(chalk.red("Error:"), SECRETS_EXEC_UNATTENDED_NO_COMPANY_MESSAGE);
|
|
1196
|
+
process.exit(SECRETS_EXEC_UNATTENDED_NO_COMPANY_EXIT);
|
|
1197
|
+
}
|
|
1198
|
+
const multiCompanyLockEnabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
|
|
1199
|
+
// Only the caller's own session id may supply a lock; never .current.
|
|
1200
|
+
const sessionLock = multiCompanyLockEnabled && hasCallerSession ? readSessionCompanyLock(process.env) : undefined;
|
|
1172
1201
|
const hasLockedCompanies = (sessionLock?.companies.length ?? 0) > 0;
|
|
1173
1202
|
const lockRoot = hasLockedCompanies ? resolveDefaultHqRoot({ env: process.env }) : undefined;
|
|
1174
1203
|
const selectedCompany = (hasLockedCompanies && requestedCompany && looksLikeCompanyUid(requestedCompany)
|
|
@@ -1319,7 +1348,7 @@ export function registerSecretsCommand(program) {
|
|
|
1319
1348
|
.command("share <path>")
|
|
1320
1349
|
.description("Share a secret with an email address, group, company Outpost (out_…), or the entire company (@all)")
|
|
1321
1350
|
.requiredOption("--with <principal>", "Email address, group id, out_ Outpost id, or @all (entire company) to share with")
|
|
1322
|
-
.
|
|
1351
|
+
.option("--permission <level>", "Permission level: read | write | admin (required, except for a company Outpost, which can only be given read and defaults to it)")
|
|
1323
1352
|
.action(async (path, opts) => {
|
|
1324
1353
|
try {
|
|
1325
1354
|
rejectIfPersonal(secrets.opts(), "share");
|
|
@@ -1327,7 +1356,8 @@ export function registerSecretsCommand(program) {
|
|
|
1327
1356
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
1328
1357
|
process.exit(1);
|
|
1329
1358
|
}
|
|
1330
|
-
if (
|
|
1359
|
+
if (opts.permission !== undefined &&
|
|
1360
|
+
!["read", "write", "admin"].includes(opts.permission)) {
|
|
1331
1361
|
console.error(chalk.red(`Invalid permission '${opts.permission}': must be one of read, write, admin`));
|
|
1332
1362
|
process.exit(1);
|
|
1333
1363
|
}
|
|
@@ -1335,6 +1365,16 @@ export function registerSecretsCommand(program) {
|
|
|
1335
1365
|
if (!principal) {
|
|
1336
1366
|
process.exit(1);
|
|
1337
1367
|
}
|
|
1368
|
+
const isOutpost = OUTPOST_UID_PATTERN.test(principal.granteeId);
|
|
1369
|
+
if (opts.permission === undefined && !isOutpost) {
|
|
1370
|
+
console.error(chalk.red("Missing --permission: must be one of read, write, admin"));
|
|
1371
|
+
process.exit(1);
|
|
1372
|
+
}
|
|
1373
|
+
const permission = opts.permission ?? "read";
|
|
1374
|
+
if (isOutpost && permission !== "read") {
|
|
1375
|
+
console.error(chalk.red(OUTPOST_GRANT_READ_ONLY_MESSAGE));
|
|
1376
|
+
process.exit(1);
|
|
1377
|
+
}
|
|
1338
1378
|
const target = await resolveSecretsTarget("secrets:share", "secrets share", secrets.opts());
|
|
1339
1379
|
const res = await vaultApiFetch({
|
|
1340
1380
|
token: target.token,
|
|
@@ -1344,12 +1384,15 @@ export function registerSecretsCommand(program) {
|
|
|
1344
1384
|
path,
|
|
1345
1385
|
granteeType: principal.granteeType,
|
|
1346
1386
|
granteeId: principal.granteeId,
|
|
1347
|
-
permission
|
|
1387
|
+
permission,
|
|
1348
1388
|
},
|
|
1349
1389
|
});
|
|
1350
1390
|
if (!res.ok) {
|
|
1351
1391
|
const body = await res.json().catch(() => ({}));
|
|
1352
|
-
if (res.status ===
|
|
1392
|
+
if (res.status === 400 && body.code === OUTPOST_GRANT_READ_ONLY_CODE) {
|
|
1393
|
+
console.error(chalk.red(OUTPOST_GRANT_READ_ONLY_MESSAGE));
|
|
1394
|
+
}
|
|
1395
|
+
else if (res.status === 401) {
|
|
1353
1396
|
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
1354
1397
|
}
|
|
1355
1398
|
else if (res.status === 403) {
|
|
@@ -1371,7 +1414,7 @@ export function registerSecretsCommand(program) {
|
|
|
1371
1414
|
}
|
|
1372
1415
|
process.exit(1);
|
|
1373
1416
|
}
|
|
1374
|
-
console.log(chalk.green(`Shared '${path}' with ${describeSecretAclPrincipal(principal)} (${
|
|
1417
|
+
console.log(chalk.green(`Shared '${path}' with ${describeSecretAclPrincipal(principal)} (${permission})`));
|
|
1375
1418
|
}
|
|
1376
1419
|
catch (err) {
|
|
1377
1420
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|