@ouro.bot/cli 0.1.0-alpha.893 → 0.1.0-alpha.895
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.json +12 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/steward-policy.js +26 -0
- package/dist/repertoire/bitwarden-store.js +153 -7
- package/dist/repertoire/credential-access.js +27 -23
- package/dist/repertoire/guardrails.js +1 -1
- package/dist/repertoire/tools-cmux.js +121 -2
- package/dist/repertoire/vault-unlock.js +59 -1
- package/dist/senses/cmux/answer.js +191 -0
- package/dist/senses/cmux/attention.js +4 -2
- package/dist/senses/cmux/casebook.js +416 -0
- package/dist/senses/cmux/floor.js +407 -0
- package/dist/senses/cmux/principles.js +72 -0
- package/dist/senses/cmux/sense.js +52 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.895",
|
|
6
|
+
"changes": [
|
|
7
|
+
"The vault unlocks once per machine. Each agent's folder under ~/.ouro-cli/bitwarden/ now keeps one disposable bw profile per installed bw version (bw-<version>/), so machines with several bw installs never migrate or share one profile. When a local step fails on an authenticated profile (a null KdfConfig or missing master-password unlock data after a newer bw half-migrated it, or a missing session), Ouro moves the profile aside as data.json.discarded and logs in fresh once with the saved unlock secret, without asking the human. Server steps (login, sync), network errors, rate limits (429), gateway errors (502/504), connection resets, DNS and unreachable-network failures, lock contention and rejected secrets never discard a profile, and those network failures now count as transient. Ouro no longer deletes the saved Keychain/DPAPI/Secret Service/plaintext unlock entry when the vault rejects it: it keeps the entry and writes a private rejection record (mode 0600) in the agent's bw folder that every process honors, so no process retries a rejected secret against the server; `ouro vault unlock` (even with the same secret) or any successful login clears it. A store's terminal login failure is retried after 15 minutes instead of being held until restart. The locked-vault message also names a macOS password reset, which replaces the login keychain, as a cause. The Unraid template and the Sanctuary bundle runtime move to this version."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.894",
|
|
12
|
+
"changes": [
|
|
13
|
+
"The cmux sense can answer a Claude Code permission prompt 'once' for the human, and only when every code check agrees: an allowlist floor over the request's structured fields (in-repo edits, a short table of read-only commands each with its own allowed options, read-only git when the repository's git config runs no programs, and the repository's own checks as exact words; everything else escalates), no 'ask me' precedent, cmux 0.65.0 or later, and a standing owner grant (cmux.feed.once) for that repository under its count cap. Before replying it re-checks the request and records the reply under the decision log's lock, and it counts the reply as made only when cmux shows the request resolved 'once'. Two new family-only tools: cmux_reply_once records the agent's judgment (shadow only unless the code allows the reply) and cmux_correct records the human's exact precedent, refusing in autonomous turns and during external events and accepting 'once' only for a request the human allowed in cmux. Decisions and precedents stay machine-local, with digests of command words and field-by-field redaction; first-format precedents are migrated to digests, and an unreadable casebook makes the sense escalate everything rather than skip an 'ask'. A reply whose call failed, or whose request expired, goes back to the human, a request is never replied to twice, a stale decision-log lock is taken over through a per-contender tombstone that is checked to be the stale lock, an unreadable decision log or a torn line makes the count cap refuse rather than count zero, a torn last line is ended before the next append, and reply records survive log rotation for 31 days, the longest grant window allowed. Questions, plan approvals, Codex and Copilot always go to the human. No human-run command issues the grant yet, so in v1 the sense escalates every request."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.893",
|
|
6
18
|
"changes": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.895</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -38,6 +38,7 @@ exports.assertNotReplayIdentity = assertNotReplayIdentity;
|
|
|
38
38
|
exports.updateStewardPolicy = updateStewardPolicy;
|
|
39
39
|
exports.readRoutineActionReceipts = readRoutineActionReceipts;
|
|
40
40
|
exports.inspectRoutineActionGrant = inspectRoutineActionGrant;
|
|
41
|
+
exports.inspectStandingActionGrant = inspectStandingActionGrant;
|
|
41
42
|
exports.consumeRoutineActionGrant = consumeRoutineActionGrant;
|
|
42
43
|
exports.withStewardPolicyLease = withStewardPolicyLease;
|
|
43
44
|
exports.withRoutineActionAttempt = withRoutineActionAttempt;
|
|
@@ -524,6 +525,31 @@ function inspectRoutineActionGrant(agentRoot, input) {
|
|
|
524
525
|
return { allowed: false, reason: error instanceof Error ? error.message : "routine action policy is unavailable" };
|
|
525
526
|
}
|
|
526
527
|
}
|
|
528
|
+
function inspectStandingActionGrant(agentRoot, input) {
|
|
529
|
+
try {
|
|
530
|
+
const { policy, rows } = readPolicyState(agentRoot);
|
|
531
|
+
const now = input.now ?? new Date().toISOString();
|
|
532
|
+
const grant = policy.routineActionGrants[input.key];
|
|
533
|
+
if (!grant)
|
|
534
|
+
return { allowed: false, reason: "standing grant is missing" };
|
|
535
|
+
if (grant.provenance !== "stated")
|
|
536
|
+
return { allowed: false, reason: "standing grant must be owner-stated" };
|
|
537
|
+
if (!appliedEntry(rows, ["grant_routine_action"], input.key, grant))
|
|
538
|
+
return { allowed: false, reason: "standing grant has no applied owner authorization" };
|
|
539
|
+
if (grant.action !== input.action)
|
|
540
|
+
return { allowed: false, reason: "action does not match the standing grant" };
|
|
541
|
+
if (!grant.targets.includes(input.target) || grant.exclusions.includes(input.target))
|
|
542
|
+
return { allowed: false, reason: "target is not covered by the standing grant" };
|
|
543
|
+
if (grant.expiresAt && Date.parse(grant.expiresAt) <= Date.parse(now))
|
|
544
|
+
return { allowed: false, reason: "standing grant expired" };
|
|
545
|
+
if (input.usesInWindow(grant.windowMs) >= grant.maxCount)
|
|
546
|
+
return { allowed: false, reason: "standing grant count cap reached" };
|
|
547
|
+
return { allowed: true, grantVersion: grant.version, maxCount: grant.maxCount, windowMs: grant.windowMs };
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
return { allowed: false, reason: error instanceof Error ? error.message : "steward policy is unavailable" };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
527
553
|
function consumeRoutineActionGrant(agentRoot, input) {
|
|
528
554
|
if (!text(input.authorizationReceiptId))
|
|
529
555
|
throw new Error("routine action requires a current authorization receipt");
|
|
@@ -43,6 +43,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
43
43
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
44
|
exports.BitwardenCredentialStore = void 0;
|
|
45
45
|
exports.sanitizeCredentialErrorDetail = sanitizeCredentialErrorDetail;
|
|
46
|
+
exports.readBwCliVersion = readBwCliVersion;
|
|
46
47
|
const node_child_process_1 = require("node:child_process");
|
|
47
48
|
const fs = __importStar(require("node:fs"));
|
|
48
49
|
const path = __importStar(require("node:path"));
|
|
@@ -169,6 +170,7 @@ const BW_LOCK_STALE_MS = BW_LOCK_TIMEOUT_MS * 2;
|
|
|
169
170
|
const BW_DATA_FILENAME = "data.json";
|
|
170
171
|
const BW_SYNC_MARKER_FILENAME = ".ouro-last-sync";
|
|
171
172
|
const BW_SYNC_FRESH_MS = 60_000;
|
|
173
|
+
const BW_TERMINAL_LOGIN_ERROR_RETRY_MS = 15 * 60_000;
|
|
172
174
|
/** In-process async mutex keyed by appDataDir. */
|
|
173
175
|
const inProcessLocks = new Map();
|
|
174
176
|
function isPidAlive(pid) {
|
|
@@ -365,6 +367,58 @@ function isBwNotInstalled(err) {
|
|
|
365
367
|
const code = err.code;
|
|
366
368
|
return code === "ENOENT" || /\bspawn\b.*\benoent\b/.test(msg) || msg.includes("command not found");
|
|
367
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Read the installed @bitwarden/cli version from the package that owns the
|
|
372
|
+
* resolved `bw` binary. Returns null when the binary is not an npm install.
|
|
373
|
+
*/
|
|
374
|
+
function readBwCliVersion(bwBinaryPath) {
|
|
375
|
+
let dir;
|
|
376
|
+
try {
|
|
377
|
+
dir = path.dirname(fs.realpathSync(bwBinaryPath));
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
383
|
+
try {
|
|
384
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
385
|
+
if (pkg.name === "@bitwarden/cli" && typeof pkg.version === "string")
|
|
386
|
+
return pkg.version;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Keep walking up toward the package root.
|
|
390
|
+
}
|
|
391
|
+
dir = path.dirname(dir);
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
/** Another process holds the profile; the profile itself is not at fault. */
|
|
396
|
+
function isBwLockTimeout(err) {
|
|
397
|
+
return err.message.includes("bw CLI lock timeout");
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* A local bw step (unlock of an already-authenticated profile) failed for a
|
|
401
|
+
* reason that says nothing about the vault server: the profile on disk is
|
|
402
|
+
* unusable. Network, lock and missing-binary failures are not this, and
|
|
403
|
+
* neither is a rejected unlock secret.
|
|
404
|
+
*/
|
|
405
|
+
class LocalProfileUnusableError extends Error {
|
|
406
|
+
localError;
|
|
407
|
+
constructor(localError) {
|
|
408
|
+
super(localError.message);
|
|
409
|
+
this.localError = localError;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function asLocalProfileFailure(err) {
|
|
413
|
+
if (isTransientError(err) || isBwNotInstalled(err) || isBwLockTimeout(err) || isBwInvalidUnlockSecretMessage(err.message)) {
|
|
414
|
+
return err;
|
|
415
|
+
}
|
|
416
|
+
return new LocalProfileUnusableError(err);
|
|
417
|
+
}
|
|
418
|
+
/** A bw version as a safe directory name component. */
|
|
419
|
+
function bwProfileDirName(version) {
|
|
420
|
+
return `bw-${version.replace(/[^A-Za-z0-9._-]/g, "_")}`;
|
|
421
|
+
}
|
|
368
422
|
/** Check if the error is transient (network/timeout) and worth retrying. */
|
|
369
423
|
function isTransientError(err) {
|
|
370
424
|
const msg = err.message.toLowerCase();
|
|
@@ -372,7 +426,18 @@ function isTransientError(err) {
|
|
|
372
426
|
msg.includes("etimedout") ||
|
|
373
427
|
msg.includes("enotfound") ||
|
|
374
428
|
msg.includes("socket hang up") ||
|
|
429
|
+
msg.includes("econnreset") ||
|
|
430
|
+
msg.includes("eai_again") ||
|
|
431
|
+
msg.includes("enetunreach") ||
|
|
432
|
+
msg.includes("ehostunreach") ||
|
|
433
|
+
msg.includes("fetch failed") ||
|
|
434
|
+
msg.includes("429") ||
|
|
435
|
+
msg.includes("too many requests") ||
|
|
436
|
+
msg.includes("502") ||
|
|
375
437
|
msg.includes("503") ||
|
|
438
|
+
msg.includes("504") ||
|
|
439
|
+
msg.includes("bad gateway") ||
|
|
440
|
+
msg.includes("gateway timeout") ||
|
|
376
441
|
msg.includes("server unavailable") ||
|
|
377
442
|
msg.includes("timed out"));
|
|
378
443
|
}
|
|
@@ -491,17 +556,22 @@ class BitwardenCredentialStore {
|
|
|
491
556
|
serverUrl;
|
|
492
557
|
email;
|
|
493
558
|
masterPassword;
|
|
559
|
+
/** The agent's bw folder; each bw version keeps its own profile under it. */
|
|
560
|
+
agentDataDir;
|
|
561
|
+
/** The profile directory for the running bw version (BITWARDENCLI_APPDATA_DIR). */
|
|
494
562
|
appDataDir;
|
|
495
563
|
onInvalidUnlockSecret;
|
|
496
564
|
onLoginSuccess;
|
|
497
565
|
sessionToken = null;
|
|
498
566
|
terminalLoginError = null;
|
|
567
|
+
terminalLoginErrorAt = 0;
|
|
499
568
|
bwBinaryPath = "bw";
|
|
500
569
|
structuredItemCache = null;
|
|
501
570
|
constructor(serverUrl, email, masterPassword, options = {}) {
|
|
502
571
|
this.serverUrl = serverUrl;
|
|
503
572
|
this.email = email;
|
|
504
573
|
this.masterPassword = masterPassword;
|
|
574
|
+
this.agentDataDir = options.appDataDir;
|
|
505
575
|
this.appDataDir = options.appDataDir;
|
|
506
576
|
this.onInvalidUnlockSecret = options.onInvalidUnlockSecret;
|
|
507
577
|
this.onLoginSuccess = options.onLoginSuccess;
|
|
@@ -543,11 +613,21 @@ class BitwardenCredentialStore {
|
|
|
543
613
|
*/
|
|
544
614
|
async login() {
|
|
545
615
|
if (this.terminalLoginError) {
|
|
546
|
-
|
|
616
|
+
// A long-lived process retries a terminal failure after a while instead
|
|
617
|
+
// of holding it until restart; rejected secrets are paused separately.
|
|
618
|
+
if (Date.now() - this.terminalLoginErrorAt < BW_TERMINAL_LOGIN_ERROR_RETRY_MS) {
|
|
619
|
+
throw this.terminalLoginError;
|
|
620
|
+
}
|
|
621
|
+
this.terminalLoginError = null;
|
|
547
622
|
}
|
|
548
623
|
// Ensure bw CLI is installed before any bw commands
|
|
549
624
|
this.bwBinaryPath = await (0, bw_installer_1.ensureBwCli)();
|
|
550
|
-
if (this.
|
|
625
|
+
if (this.agentDataDir) {
|
|
626
|
+
// Each bw version gets its own profile directory, so machines with
|
|
627
|
+
// several bw installs (one per Node version) never migrate or fight
|
|
628
|
+
// over one another's state.
|
|
629
|
+
const bwVersion = readBwCliVersion(this.bwBinaryPath);
|
|
630
|
+
this.appDataDir = bwVersion ? path.join(this.agentDataDir, bwProfileDirName(bwVersion)) : this.agentDataDir;
|
|
551
631
|
fs.mkdirSync(this.appDataDir, { recursive: true, mode: 0o700 });
|
|
552
632
|
}
|
|
553
633
|
let lastError;
|
|
@@ -562,7 +642,7 @@ class BitwardenCredentialStore {
|
|
|
562
642
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
563
643
|
// Don't retry non-transient errors (auth failures, bw not installed)
|
|
564
644
|
if (!isTransientError(lastError)) {
|
|
565
|
-
this.
|
|
645
|
+
this.setTerminalLoginError(lastError);
|
|
566
646
|
if (isBwInvalidUnlockSecretMessage(lastError.message)) {
|
|
567
647
|
await this.notifyInvalidUnlockSecret(lastError);
|
|
568
648
|
}
|
|
@@ -581,15 +661,76 @@ class BitwardenCredentialStore {
|
|
|
581
661
|
await delay(backoffMs);
|
|
582
662
|
}
|
|
583
663
|
}
|
|
584
|
-
this.
|
|
664
|
+
this.setTerminalLoginError(lastError);
|
|
585
665
|
/* v8 ignore next -- invalid unlock errors are non-transient and exit through the non-retry path above @preserve */
|
|
586
666
|
if (isBwInvalidUnlockSecretMessage(lastError.message)) {
|
|
587
667
|
await this.notifyInvalidUnlockSecret(lastError);
|
|
588
668
|
}
|
|
589
669
|
throw lastError;
|
|
590
670
|
}
|
|
591
|
-
|
|
671
|
+
setTerminalLoginError(error) {
|
|
672
|
+
this.terminalLoginError = error;
|
|
673
|
+
this.terminalLoginErrorAt = Date.now();
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Single login attempt — called by login() retry loop.
|
|
677
|
+
*
|
|
678
|
+
* The bw profile directory is a disposable cache; the durable secret is the
|
|
679
|
+
* machine's vault unlock material. When a local step fails on an
|
|
680
|
+
* authenticated profile (a corrupt or half-migrated profile, a missing
|
|
681
|
+
* session), the profile is moved aside and rebuilt with one fresh login.
|
|
682
|
+
* Server steps (login, sync), network failures, lock contention and
|
|
683
|
+
* rejected secrets never discard the profile, so a bad secret or a flaky
|
|
684
|
+
* server costs at most one server login per attempt.
|
|
685
|
+
*/
|
|
592
686
|
async loginAttempt() {
|
|
687
|
+
try {
|
|
688
|
+
await this.loginWithLocalProfile();
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
if (!(error instanceof LocalProfileUnusableError))
|
|
692
|
+
throw error;
|
|
693
|
+
await this.discardLocalProfile(error.localError);
|
|
694
|
+
try {
|
|
695
|
+
await this.loginWithLocalProfile();
|
|
696
|
+
}
|
|
697
|
+
catch (retryError) {
|
|
698
|
+
throw retryError instanceof LocalProfileUnusableError ? retryError.localError : retryError;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Move the local bw profile aside so the next bw command starts from an
|
|
704
|
+
* empty profile. The previous data file is kept as one `.discarded` copy
|
|
705
|
+
* for diagnosis and is overwritten by the next discard.
|
|
706
|
+
*/
|
|
707
|
+
async discardLocalProfile(cause) {
|
|
708
|
+
const appDataDir = this.appDataDir;
|
|
709
|
+
this.sessionToken = null;
|
|
710
|
+
this.structuredItemCache = null;
|
|
711
|
+
await withBwLock(appDataDir, async () => {
|
|
712
|
+
const dataPath = path.join(appDataDir, BW_DATA_FILENAME);
|
|
713
|
+
try {
|
|
714
|
+
fs.renameSync(dataPath, `${dataPath}.discarded`);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// Already gone: another process discarded it first, which is the same outcome.
|
|
718
|
+
}
|
|
719
|
+
fs.rmSync(path.join(appDataDir, BW_SYNC_MARKER_FILENAME), { force: true });
|
|
720
|
+
});
|
|
721
|
+
(0, runtime_1.emitNervesEvent)({
|
|
722
|
+
level: "warn",
|
|
723
|
+
event: "repertoire.bw_local_profile_discarded",
|
|
724
|
+
component: "repertoire",
|
|
725
|
+
message: "discarded unusable local bw profile; logging in fresh with the saved unlock secret",
|
|
726
|
+
meta: {
|
|
727
|
+
email: this.email,
|
|
728
|
+
serverUrl: this.serverUrl,
|
|
729
|
+
error: sanitizeCredentialErrorDetail(cause.message),
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
async loginWithLocalProfile() {
|
|
593
734
|
let status = await this.readStatus();
|
|
594
735
|
if (this.shouldRebuildLocalProfile(status)) {
|
|
595
736
|
await this.rebuildLocalProfile("local bw profile did not match requested vault", status);
|
|
@@ -692,7 +833,12 @@ class BitwardenCredentialStore {
|
|
|
692
833
|
const err = error;
|
|
693
834
|
if (!isBwAlreadyLoggedInError(err))
|
|
694
835
|
throw err;
|
|
695
|
-
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
return await this.execBwWithPasswordEnv(["unlock", "--raw"]);
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
throw asLocalProfileFailure(error);
|
|
696
842
|
}
|
|
697
843
|
}
|
|
698
844
|
forgetLocalSyncMarker() {
|
|
@@ -754,7 +900,7 @@ class BitwardenCredentialStore {
|
|
|
754
900
|
catch (error) {
|
|
755
901
|
const err = error;
|
|
756
902
|
if (!isBwInvalidUnlockSecretMessage(err.message))
|
|
757
|
-
throw err;
|
|
903
|
+
throw asLocalProfileFailure(err);
|
|
758
904
|
await this.rebuildLocalProfile(reason, status, err);
|
|
759
905
|
/* v8 ignore next -- defensive: rebuildLocalProfile always stores a string session token on success @preserve */
|
|
760
906
|
return this.sessionToken ?? "";
|
|
@@ -46,15 +46,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
46
46
|
exports.getCredentialStore = getCredentialStore;
|
|
47
47
|
exports.probeCredentialVaultAccess = probeCredentialVaultAccess;
|
|
48
48
|
exports.resetCredentialStore = resetCredentialStore;
|
|
49
|
-
const crypto = __importStar(require("node:crypto"));
|
|
50
49
|
const fs = __importStar(require("node:fs"));
|
|
51
|
-
const os = __importStar(require("node:os"));
|
|
52
50
|
const path = __importStar(require("node:path"));
|
|
53
51
|
const runtime_1 = require("../nerves/runtime");
|
|
54
52
|
const identity = __importStar(require("../heart/identity"));
|
|
55
53
|
const bitwarden_store_1 = require("./bitwarden-store");
|
|
56
54
|
const vault_unlock_1 = require("./vault-unlock");
|
|
57
55
|
let stores = new Map();
|
|
56
|
+
function rejectedUnlockMessage(agentName, reason) {
|
|
57
|
+
return [
|
|
58
|
+
`The vault rejected the unlock secret saved on this machine for ${agentName} (${reason}).`,
|
|
59
|
+
"The saved secret was kept. If the vault's unlock secret changed, run",
|
|
60
|
+
`\`ouro vault unlock --agent ${agentName}\` once with the current secret.`,
|
|
61
|
+
].join(" ");
|
|
62
|
+
}
|
|
58
63
|
function loadVaultSectionForAgent(agentName) {
|
|
59
64
|
const configPath = path.join(identity.getAgentRoot(agentName), "agent.json");
|
|
60
65
|
try {
|
|
@@ -65,14 +70,6 @@ function loadVaultSectionForAgent(agentName) {
|
|
|
65
70
|
return { configPath };
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
|
-
function bitwardenAppDataDir(agentName, vaultConfig, homeDir = os.homedir()) {
|
|
69
|
-
const digest = crypto
|
|
70
|
-
.createHash("sha256")
|
|
71
|
-
.update(`${agentName}:${vaultConfig.serverUrl}:${vaultConfig.email}`)
|
|
72
|
-
.digest("hex")
|
|
73
|
-
.slice(0, 24);
|
|
74
|
-
return path.join(homeDir, ".ouro-cli", "bitwarden", digest);
|
|
75
|
-
}
|
|
76
73
|
function getCredentialStore(agentNameInput) {
|
|
77
74
|
const agentName = agentNameInput ?? identity.getAgentName();
|
|
78
75
|
if (agentName === "SerpentGuide") {
|
|
@@ -87,6 +84,11 @@ function getCredentialStore(agentNameInput) {
|
|
|
87
84
|
const cached = stores.get(cacheKey);
|
|
88
85
|
if (cached)
|
|
89
86
|
return cached;
|
|
87
|
+
const agentDataDir = (0, vault_unlock_1.bitwardenAppDataDir)(agentName, vaultConfig);
|
|
88
|
+
const rejection = (0, vault_unlock_1.readVaultUnlockRejection)(agentDataDir);
|
|
89
|
+
if (rejection) {
|
|
90
|
+
throw new Error(rejectedUnlockMessage(agentName, rejection.reason));
|
|
91
|
+
}
|
|
90
92
|
const unlock = (0, vault_unlock_1.readVaultUnlockSecret)({
|
|
91
93
|
agentName,
|
|
92
94
|
email: vaultConfig.email,
|
|
@@ -94,25 +96,22 @@ function getCredentialStore(agentNameInput) {
|
|
|
94
96
|
});
|
|
95
97
|
const unlockConfig = { agentName, email: vaultConfig.email, serverUrl: vaultConfig.serverUrl };
|
|
96
98
|
const unlockSource = unlock.source ?? unlockConfig;
|
|
97
|
-
let invalidUnlockCleared = false;
|
|
98
99
|
let canonicalUnlockStored = false;
|
|
99
100
|
const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlock.secret, {
|
|
100
|
-
appDataDir:
|
|
101
|
+
appDataDir: agentDataDir,
|
|
101
102
|
onInvalidUnlockSecret: (error) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
email: unlockSource.email,
|
|
108
|
-
serverUrl: unlockSource.serverUrl,
|
|
109
|
-
});
|
|
103
|
+
// The vault rejected the saved secret. Keep the saved secret: deleting
|
|
104
|
+
// the only durable copy turns any misclassified bw or server error
|
|
105
|
+
// into a human prompt. Record the rejection on disk so no process
|
|
106
|
+
// retries it against the server; `ouro vault unlock` or any
|
|
107
|
+
// successful login clears the record.
|
|
110
108
|
stores.delete(cacheKey);
|
|
109
|
+
(0, vault_unlock_1.recordVaultUnlockRejection)(agentDataDir, error.message);
|
|
111
110
|
(0, runtime_1.emitNervesEvent)({
|
|
112
111
|
level: "warn",
|
|
113
|
-
event: "repertoire.
|
|
112
|
+
event: "repertoire.credential_store_unlock_rejected",
|
|
114
113
|
component: "repertoire",
|
|
115
|
-
message: "
|
|
114
|
+
message: "vault rejected the saved local unlock secret; kept it and paused retries until a new secret is saved",
|
|
116
115
|
meta: {
|
|
117
116
|
agentName,
|
|
118
117
|
serverUrl: vaultConfig.serverUrl,
|
|
@@ -123,6 +122,7 @@ function getCredentialStore(agentNameInput) {
|
|
|
123
122
|
});
|
|
124
123
|
},
|
|
125
124
|
onLoginSuccess: () => {
|
|
125
|
+
(0, vault_unlock_1.clearVaultUnlockRejection)(agentDataDir);
|
|
126
126
|
if (canonicalUnlockStored)
|
|
127
127
|
return;
|
|
128
128
|
if (unlockSource.serverUrl === vaultConfig.serverUrl && unlockSource.email === vaultConfig.email)
|
|
@@ -170,7 +170,11 @@ async function probeCredentialVaultAccess(agentNameInput, unlockSecret, options
|
|
|
170
170
|
throw new Error((0, vault_unlock_1.credentialVaultNotConfiguredError)(agentName, configPath));
|
|
171
171
|
}
|
|
172
172
|
const vaultConfig = identity.resolveVaultConfig(agentName, vault);
|
|
173
|
-
const
|
|
173
|
+
const agentDataDir = (0, vault_unlock_1.bitwardenAppDataDir)(agentName, vaultConfig, options.homeDir);
|
|
174
|
+
const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlockSecret, {
|
|
175
|
+
appDataDir: agentDataDir,
|
|
176
|
+
onLoginSuccess: () => (0, vault_unlock_1.clearVaultUnlockRejection)(agentDataDir),
|
|
177
|
+
});
|
|
174
178
|
await store.get("__ouro_vault_probe__");
|
|
175
179
|
}
|
|
176
180
|
function resetCredentialStore() {
|
|
@@ -443,7 +443,7 @@ const COMMERCE_FAMILY_TOOLS = new Set(["commerce_checkout_preview", "commerce_ch
|
|
|
443
443
|
const COMMERCE_AUTHORITY_TOOLS = new Set(["stripe_create_card", "flight_hold", "flight_book"]);
|
|
444
444
|
// The cmux tools read the owner's terminals and speak to them through their desktop, so only family may use them.
|
|
445
445
|
// A call with no known trust level fails closed.
|
|
446
|
-
const CMUX_FAMILY_TOOLS = new Set(["cmux_overview", "cmux_read", "cmux_signal"]);
|
|
446
|
+
const CMUX_FAMILY_TOOLS = new Set(["cmux_overview", "cmux_read", "cmux_signal", "cmux_reply_once", "cmux_correct"]);
|
|
447
447
|
const MAIL_FAMILY_TOOLS = new Set(["mail_screener", "mail_decide", "mail_access_log", "mail_send", "mail_index_refresh"]);
|
|
448
448
|
const MAIL_DELEGATED_READ_TOOLS = new Set(["mail_recent", "mail_search"]);
|
|
449
449
|
function opaqueAnsiShellAtoms(command) {
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.cmuxToolDefinitions = exports.cmuxSignalToolDefinition = exports.cmuxReadToolDefinition = exports.cmuxOverviewToolDefinition = exports.CMUX_STATUS_KEY = void 0;
|
|
36
|
+
exports.cmuxToolDefinitions = exports.cmuxCorrectToolDefinition = exports.cmuxReplyOnceToolDefinition = exports.cmuxSignalToolDefinition = exports.cmuxReadToolDefinition = exports.cmuxOverviewToolDefinition = exports.CMUX_STATUS_KEY = void 0;
|
|
37
37
|
exports.cmuxToolsEnabled = cmuxToolsEnabled;
|
|
38
38
|
const fs = __importStar(require("node:fs"));
|
|
39
39
|
const path = __importStar(require("node:path"));
|
|
@@ -41,7 +41,10 @@ const cmux_config_1 = require("../heart/cmux-config");
|
|
|
41
41
|
const runtime_credentials_1 = require("../heart/runtime-credentials");
|
|
42
42
|
const runtime_1 = require("../nerves/runtime");
|
|
43
43
|
const attention_1 = require("../senses/cmux/attention");
|
|
44
|
+
const answer_1 = require("../senses/cmux/answer");
|
|
45
|
+
const casebook_1 = require("../senses/cmux/casebook");
|
|
44
46
|
const client_1 = require("../senses/cmux/client");
|
|
47
|
+
const principles_1 = require("../senses/cmux/principles");
|
|
45
48
|
const redact_1 = require("../senses/cmux/redact");
|
|
46
49
|
/**
|
|
47
50
|
* The cmux sense's tools: who needs the human (`cmux_overview`), what a terminal shows
|
|
@@ -117,6 +120,7 @@ async function overview(ctx) {
|
|
|
117
120
|
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.cmux_overview", message: "read cmux overview", meta: { workspaces: workspaces.length, waiting: pending.length } });
|
|
118
121
|
return JSON.stringify({
|
|
119
122
|
sense: { connected: state.connected, lastError: state.lastError, cmuxVersion: state.cmuxVersion },
|
|
123
|
+
principles: (0, principles_1.readCmuxPrinciples)(ctx.agentRoot),
|
|
120
124
|
waitingOnHuman: pending.map((item) => {
|
|
121
125
|
const where = refFor((0, attention_1.surfaceForSession)(state, item.workstreamId)?.surfaceId);
|
|
122
126
|
return {
|
|
@@ -274,4 +278,119 @@ exports.cmuxSignalToolDefinition = {
|
|
|
274
278
|
summaryKeys: ["workspace", "status", "notify_title"],
|
|
275
279
|
riskProfile: { mutates: "external_side_effect", risk: "high", reason: "changes what cmux shows the human (sidebar status or a desktop notification)" },
|
|
276
280
|
};
|
|
277
|
-
|
|
281
|
+
function answerContext(ctx, client) {
|
|
282
|
+
const stateDir = (0, answer_1.cmuxStateDir)(ctx.agentRoot);
|
|
283
|
+
return { agentRoot: ctx.agentRoot, stateDir, client, now: Date.now, cmuxVersion: () => (0, attention_1.readCmuxState)(path.join(stateDir, "state.json"), new Date().toISOString()).cmuxVersion };
|
|
284
|
+
}
|
|
285
|
+
exports.cmuxReplyOnceToolDefinition = {
|
|
286
|
+
tool: {
|
|
287
|
+
type: "function",
|
|
288
|
+
function: {
|
|
289
|
+
name: "cmux_reply_once",
|
|
290
|
+
description: "Record your judgment that a coding agent's pending permission request is routine and fine to allow once. Your judgment alone never sends anything: the reply goes out only when the code floor, the human's exact precedent and a standing owner grant all allow it, and then only as 'once'. Otherwise your judgment is logged in shadow for the human to review and the request stays with them. Questions, plan approvals, Codex and Copilot requests always stay with the human.",
|
|
291
|
+
parameters: {
|
|
292
|
+
type: "object",
|
|
293
|
+
properties: {
|
|
294
|
+
request_id: { type: "string", description: "The Feed request id from the escalation or cmux_overview." },
|
|
295
|
+
reasoning: { type: "string", description: "Why this is routine under the cmux principles, in one or two sentences." },
|
|
296
|
+
},
|
|
297
|
+
required: ["request_id", "reasoning"],
|
|
298
|
+
additionalProperties: false,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
handler: async (args, ctx) => {
|
|
303
|
+
const requestId = text(args.request_id).trim();
|
|
304
|
+
const reasoning = text(args.reasoning).trim();
|
|
305
|
+
if (!requestId || !reasoning)
|
|
306
|
+
return JSON.stringify({ error: "give the request_id and your reasoning" });
|
|
307
|
+
const client = clientFor(ctx);
|
|
308
|
+
if (typeof client === "string")
|
|
309
|
+
return JSON.stringify({ error: client });
|
|
310
|
+
try {
|
|
311
|
+
const item = (0, attention_1.pendingFeedItems)(await client.call("feed.list", { pending_only: true })).find((entry) => entry.requestId === requestId);
|
|
312
|
+
if (!item)
|
|
313
|
+
return JSON.stringify({ error: `request ${requestId} is no longer pending` });
|
|
314
|
+
const answers = answerContext(ctx, client);
|
|
315
|
+
const judgment = (0, answer_1.judgeFeedItem)(answers, item);
|
|
316
|
+
if (judgment.reply) {
|
|
317
|
+
const outcome = await (0, answer_1.answerOnce)(answers, item, judgment);
|
|
318
|
+
return JSON.stringify({ sent: outcome === "replied_once", outcome, authority: judgment.authority, ...(outcome === "replied_once" || outcome === "race" ? {} : { next: "The request stays with the human; tell them with cmux_signal if it is urgent." }) });
|
|
319
|
+
}
|
|
320
|
+
(0, answer_1.recordDecision)(answers, item, judgment, "shadow", `agent would allow once: ${reasoning}`);
|
|
321
|
+
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.cmux_shadow_judgment", message: "logged the agent's would-be cmux answer in shadow", meta: { tool: item.toolName } });
|
|
322
|
+
return JSON.stringify({ sent: false, outcome: "shadow", reason: judgment.reason, next: "The request stays with the human; tell them with cmux_signal if it is urgent." });
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
return failure("cmux_reply_once", error);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
summaryKeys: ["request_id"],
|
|
329
|
+
riskProfile: { mutates: "external_side_effect", risk: "high", reason: "may answer a coding agent's permission request once when the floor, a precedent and a standing grant allow it" },
|
|
330
|
+
};
|
|
331
|
+
exports.cmuxCorrectToolDefinition = {
|
|
332
|
+
tool: {
|
|
333
|
+
type: "function",
|
|
334
|
+
function: {
|
|
335
|
+
name: "cmux_correct",
|
|
336
|
+
description: "Record the human's answer or correction for a coding-agent request the cmux sense judged, as an exact precedent on this machine. 'once' means the human is fine with exactly this request being allowed once next time, and is accepted only when cmux shows the human allowed that request themselves; 'ask' means always ask them about exactly this request. Use it only for what the human actually said, in conversation with them; it is refused in autonomous turns and while handling an external event. A precedent never overrides the code floor, and live replies still need a standing owner grant.",
|
|
337
|
+
parameters: {
|
|
338
|
+
type: "object",
|
|
339
|
+
properties: {
|
|
340
|
+
request_id: { type: "string", description: "The Feed request id the human answered or corrected." },
|
|
341
|
+
verdict: { type: "string", enum: ["once", "ask"], description: "once: fine to allow once next time. ask: always ask." },
|
|
342
|
+
note: { type: "string", description: "What the human said, briefly." },
|
|
343
|
+
},
|
|
344
|
+
required: ["request_id", "verdict", "note"],
|
|
345
|
+
additionalProperties: false,
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
handler: async (args, ctx) => {
|
|
350
|
+
const requestId = text(args.request_id).trim();
|
|
351
|
+
const verdict = args.verdict;
|
|
352
|
+
if (!requestId || (verdict !== "once" && verdict !== "ask"))
|
|
353
|
+
return JSON.stringify({ error: "give the request_id and a verdict of once or ask" });
|
|
354
|
+
if (!ctx?.agentRoot)
|
|
355
|
+
return JSON.stringify({ error: "the cmux tools need an agent runtime" });
|
|
356
|
+
if (ctx.currentExternalEvent || ctx.autonomousTurnKind)
|
|
357
|
+
return JSON.stringify({ error: "cmux_correct records what the human said in conversation; it cannot run in an autonomous turn or while handling an external event" });
|
|
358
|
+
const stateDir = (0, answer_1.cmuxStateDir)(ctx.agentRoot);
|
|
359
|
+
const decisions = (0, casebook_1.readDecisions)((0, casebook_1.cmuxDecisionLogPath)(stateDir)).filter((entry) => entry.requestId === requestId);
|
|
360
|
+
const decision = [...decisions].reverse().find((entry) => entry.shape);
|
|
361
|
+
if (!decision)
|
|
362
|
+
return JSON.stringify({ error: `no judged request ${requestId} with a precedent shape; the floor escalates it unconditionally or it was never seen` });
|
|
363
|
+
const shape = decision.shape;
|
|
364
|
+
if (!(0, casebook_1.isShape)(shape))
|
|
365
|
+
return JSON.stringify({ error: `the logged shape for ${requestId} is from an older format and cannot become a precedent; record it again after the sense judges the request anew` });
|
|
366
|
+
if (verdict === "once") {
|
|
367
|
+
// A "once" precedent turns into live authority later, so it must rest on the human's own answer in cmux, not on ours.
|
|
368
|
+
if (decisions.some((entry) => entry.outcome === "reply_sent" || entry.outcome === "replied_once"))
|
|
369
|
+
return JSON.stringify({ error: `the sense itself replied to ${requestId}; only a request the human answered can become a once precedent` });
|
|
370
|
+
const client = clientFor(ctx);
|
|
371
|
+
if (typeof client === "string")
|
|
372
|
+
return JSON.stringify({ error: client });
|
|
373
|
+
try {
|
|
374
|
+
const status = await (0, answer_1.feedItemStatus)(client, requestId);
|
|
375
|
+
if (status?.status !== "resolved" || status.kind !== "permission" || !status.mode || status.mode === "deny") {
|
|
376
|
+
return JSON.stringify({ error: `cmux does not show the human allowing ${requestId} (${status ? `${status.status}${status.mode ? `, ${status.mode}` : ""}` : "not listed"}); record 'ask' instead, or wait until they answer it in cmux` });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
return failure("cmux_correct", error);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
let entry;
|
|
384
|
+
try {
|
|
385
|
+
entry = (0, casebook_1.addCase)((0, casebook_1.cmuxCasebookPath)(stateDir), { verdict, shape, requestId, note: text(args.note), at: new Date().toISOString() });
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
return failure("cmux_correct", error);
|
|
389
|
+
}
|
|
390
|
+
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.cmux_correct", message: "recorded the human's cmux precedent", meta: { verdict } });
|
|
391
|
+
return JSON.stringify({ case: entry.id, verdict, tool: entry.shape.tool, repoRoot: entry.shape.repoRoot });
|
|
392
|
+
},
|
|
393
|
+
summaryKeys: ["request_id", "verdict"],
|
|
394
|
+
riskProfile: { mutates: "durable_state_write", risk: "high", reason: "records a precedent that later decides whether a coding agent's request is answered" },
|
|
395
|
+
};
|
|
396
|
+
exports.cmuxToolDefinitions = [exports.cmuxOverviewToolDefinition, exports.cmuxReadToolDefinition, exports.cmuxSignalToolDefinition, exports.cmuxReplyOnceToolDefinition, exports.cmuxCorrectToolDefinition];
|