aidevops 3.32.43 → 3.32.46
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/README.md +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +1 -1
- package/package.json +1 -1
- package/packages/gui-api/src/status-adapter.ts +4 -1
- package/packages/gui-api/src/status-inventory.ts +86 -0
- package/packages/gui-desktop/scripts/install-macos-app.sh +212 -34
- package/packages/gui-shared/src/contracts.ts +4 -0
- package/packages/gui-shared/src/fixtures.ts +1 -0
- package/packages/gui-web/src/SecuritySurface.tsx +1 -0
- package/packages/gui-web/src/VaultAccessModal.tsx +6 -4
- package/packages/gui-web/src/status-client.ts +1 -0
- package/packages/gui-web/src/useVaultAccessDialog.ts +13 -5
- package/packages/gui-web/src/vault-command-bridge.ts +7 -1
- package/setup.sh +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ The result: an AI operations platform that manages projects across every busines
|
|
|
60
60
|
[](https://github.com/marcusquinn)
|
|
61
61
|
|
|
62
62
|
<!-- Release & Version Info -->
|
|
63
|
-
[](https://github.com/marcusquinn/aidevops/releases)
|
|
64
64
|
[](https://www.npmjs.com/package/aidevops)
|
|
65
65
|
[](https://github.com/marcusquinn/homebrew-tap)
|
|
66
66
|
[](https://github.com/marcusquinn/aidevops)
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.32.
|
|
1
|
+
3.32.46
|
package/aidevops.sh
CHANGED
package/package.json
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import { readLocalReposSetupSummary } from "./status-local-repos";
|
|
43
43
|
import { readManagedApps } from "./status-managed-apps";
|
|
44
44
|
import { readPulseWorkersSummary } from "./status-pulse-workers";
|
|
45
|
+
import { readSecretInventory } from "./status-inventory";
|
|
45
46
|
import { readVaultSummary } from "./status-vault";
|
|
46
47
|
|
|
47
48
|
export { readVaultSummary } from "./status-vault";
|
|
@@ -82,6 +83,7 @@ export function readStatus(
|
|
|
82
83
|
const opencodeSessions = readOpenCodeSessions(opencodeDbPath, opencodeDbPathRef, localRepos.repos);
|
|
83
84
|
const oauthPool = readOAuthPoolSummary(oauthPoolPath, oauthPoolPathRef);
|
|
84
85
|
const vault = readVaultSummary(repoRoot);
|
|
86
|
+
const secretInventory = readSecretInventory(repoRoot, vault);
|
|
85
87
|
const pulseWorkers = readPulseWorkersSummary({ observedAt: options.observedAt, oauthPoolPath, ...options.pulseWorkers });
|
|
86
88
|
const notifications = buildStatusNotifications({
|
|
87
89
|
aiApps,
|
|
@@ -153,7 +155,8 @@ export function readStatus(
|
|
|
153
155
|
notifications,
|
|
154
156
|
vault,
|
|
155
157
|
pulse_workers: pulseWorkers.summary,
|
|
156
|
-
secrets: vault.unlocked ?
|
|
158
|
+
secrets: vault.unlocked ? secretInventory.secrets : [],
|
|
159
|
+
secret_backends: vault.unlocked ? secretInventory.backends : statusFixture.secret_backends,
|
|
157
160
|
};
|
|
158
161
|
|
|
159
162
|
const envelope = createEnvelope({
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { lstatSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { GuiSecretReference, GuiVaultStatusData } from "../../gui-shared/src";
|
|
5
|
+
import { readVaultSummary } from "./status-vault";
|
|
6
|
+
|
|
7
|
+
type BackendState = "available" | "missing" | "error";
|
|
8
|
+
type SecretInventory = {
|
|
9
|
+
version: 1;
|
|
10
|
+
backends: { gopass: BackendState; credentials: BackendState };
|
|
11
|
+
secrets: GuiSecretReference[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const EMPTY_INVENTORY: Omit<SecretInventory, "version"> = {
|
|
15
|
+
backends: { gopass: "missing", credentials: "missing" },
|
|
16
|
+
secrets: [],
|
|
17
|
+
};
|
|
18
|
+
const BACKEND_STATES = new Set<unknown>(["available", "missing", "error"]);
|
|
19
|
+
|
|
20
|
+
export function readSecretInventory(repoRoot: string, vault: GuiVaultStatusData): Omit<SecretInventory, "version"> {
|
|
21
|
+
if (!isAuthoritativelyUnlocked(vault)) return EMPTY_INVENTORY;
|
|
22
|
+
const helperPath = join(repoRoot, ".agents", "scripts", "secret-helper.sh");
|
|
23
|
+
if (!isTrustedHelper(helperPath)) return EMPTY_INVENTORY;
|
|
24
|
+
const result = spawnSync("/bin/bash", [helperPath, "inventory"], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
env: inventoryEnvironment(),
|
|
27
|
+
maxBuffer: 65_536,
|
|
28
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
29
|
+
timeout: 300,
|
|
30
|
+
});
|
|
31
|
+
if (result.error !== undefined || result.signal !== null || result.status !== 0 || result.stdout.length > 65_535) return EMPTY_INVENTORY;
|
|
32
|
+
return parseUnlockedInventory(result.stdout, repoRoot);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseUnlockedInventory(output: string, repoRoot: string): Omit<SecretInventory, "version"> {
|
|
36
|
+
try {
|
|
37
|
+
const inventory: unknown = JSON.parse(output);
|
|
38
|
+
const confirmed = readVaultSummary(repoRoot);
|
|
39
|
+
return isInventory(inventory) && isAuthoritativelyUnlocked(confirmed)
|
|
40
|
+
? { backends: inventory.backends, secrets: inventory.secrets }
|
|
41
|
+
: EMPTY_INVENTORY;
|
|
42
|
+
} catch {
|
|
43
|
+
return EMPTY_INVENTORY;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isAuthoritativelyUnlocked(vault: GuiVaultStatusData): boolean {
|
|
48
|
+
return vault.status === "unlocked" && vault.unlocked && !vault.locked && vault.helper_status === "available";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isTrustedHelper(path: string): boolean {
|
|
52
|
+
try {
|
|
53
|
+
const stat = lstatSync(path);
|
|
54
|
+
return stat.isFile() && !stat.isSymbolicLink() && stat.uid === process.getuid?.() && (stat.mode & 0o022) === 0;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isInventory(value: unknown): value is SecretInventory {
|
|
61
|
+
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.secrets) || value.secrets.length > 512) return false;
|
|
62
|
+
if (!isRecord(value.backends) || Object.keys(value).length !== 3 || Object.keys(value.backends).length !== 2) return false;
|
|
63
|
+
if (!BACKEND_STATES.has(value.backends.gopass) || !BACKEND_STATES.has(value.backends.credentials)) return false;
|
|
64
|
+
return value.secrets.every((secret, index) => isSecretReference(secret, index === 0 ? "" : value.secrets[index - 1]?.name));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isSecretReference(value: unknown, previousName: string | undefined): value is GuiSecretReference {
|
|
68
|
+
if (!isRecord(value) || typeof value.name !== "string") return false;
|
|
69
|
+
return /^[A-Z][A-Z0-9_]{0,127}$/.test(value.name)
|
|
70
|
+
&& value.status === "configured"
|
|
71
|
+
&& value.name > (previousName ?? "")
|
|
72
|
+
&& Object.keys(value).every((key) => key === "name" || key === "status");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return typeof value === "object" && value !== null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function inventoryEnvironment(): NodeJS.ProcessEnv {
|
|
80
|
+
const environment: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" };
|
|
81
|
+
for (const name of ["HOME", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", "AIDEVOPS_VAULT_DIR", "AIDEVOPS_VAULT_RUNTIME_DIR", "AIDEVOPS_VAULT_PYTHON"]) {
|
|
82
|
+
const value = process.env[name];
|
|
83
|
+
if (value !== undefined) environment[name] = value;
|
|
84
|
+
}
|
|
85
|
+
return environment;
|
|
86
|
+
}
|
|
@@ -202,9 +202,27 @@ run_vault_command() {
|
|
|
202
202
|
local action="\$1"
|
|
203
203
|
local command_name=""
|
|
204
204
|
case "\$action" in
|
|
205
|
-
init|unlock|lock
|
|
205
|
+
init|unlock|lock) command_name="\$action" ;;
|
|
206
206
|
*) printf 'Unsupported Vault action\n' >&2; return 2 ;;
|
|
207
207
|
esac
|
|
208
|
+
local trusted_path=""
|
|
209
|
+
for trusted_path in \
|
|
210
|
+
"\${REPO_ROOT}/aidevops.sh" \
|
|
211
|
+
"\${REPO_ROOT}/.agents/scripts/vault-helper.sh" \
|
|
212
|
+
"\${REPO_ROOT}/.agents/scripts/vault_crypto_core.py"; do
|
|
213
|
+
if [[ ! -f "\${trusted_path}" || -L "\${trusted_path}" ]]; then
|
|
214
|
+
printf 'Untrusted Vault helper chain\n' >&2
|
|
215
|
+
return 2
|
|
216
|
+
fi
|
|
217
|
+
local owner_mode=""
|
|
218
|
+
owner_mode="\$(/usr/bin/stat -f '%u %Lp' "\${trusted_path}" 2>/dev/null)" || return 2
|
|
219
|
+
local owner="\${owner_mode%% *}"
|
|
220
|
+
local mode="\${owner_mode##* }"
|
|
221
|
+
if [[ "\${owner}" != "\$(/usr/bin/id -u)" || \$((8#\${mode} & 8#022)) -ne 0 ]]; then
|
|
222
|
+
printf 'Untrusted Vault helper chain\n' >&2
|
|
223
|
+
return 2
|
|
224
|
+
fi
|
|
225
|
+
done
|
|
208
226
|
AIDEVOPS_SESSION_ID="gui-desktop" exec /bin/bash "\${REPO_ROOT}/aidevops.sh" vault "\$command_name"
|
|
209
227
|
return 1
|
|
210
228
|
}
|
|
@@ -451,6 +469,7 @@ write_webview_source() {
|
|
|
451
469
|
|
|
452
470
|
cat > "$swift_source" <<'SWIFT'
|
|
453
471
|
import AppKit
|
|
472
|
+
import Darwin
|
|
454
473
|
import WebKit
|
|
455
474
|
|
|
456
475
|
final class DraggableTitlebarView: NSView {
|
|
@@ -477,10 +496,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
477
496
|
private let serviceQueue = DispatchQueue(label: "sh.aidevops.gui.services")
|
|
478
497
|
private var hasLoadedDashboard = false
|
|
479
498
|
private var servicesStopped = false
|
|
499
|
+
private var vaultProcess: Process?
|
|
500
|
+
private var vaultMaster: FileHandle?
|
|
501
|
+
private var vaultSlaveFD: Int32 = -1
|
|
502
|
+
private var vaultOverlay: NSView?
|
|
503
|
+
private var vaultOutput: NSTextView?
|
|
504
|
+
private var vaultInput: NSSecureTextField?
|
|
505
|
+
private var vaultSubmit: NSButton?
|
|
506
|
+
private var vaultAllowsVisibleAcknowledgement = false
|
|
480
507
|
|
|
481
508
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
482
509
|
configureMenu()
|
|
483
510
|
configureWindow()
|
|
511
|
+
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(cancelVaultForSessionChange(_:)), name: NSWorkspace.sessionDidResignActiveNotification, object: nil)
|
|
512
|
+
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(cancelVaultForSessionChange(_:)), name: NSWorkspace.screensDidSleepNotification, object: nil)
|
|
484
513
|
startServicesAndLoadApp()
|
|
485
514
|
}
|
|
486
515
|
|
|
@@ -489,6 +518,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
489
518
|
}
|
|
490
519
|
|
|
491
520
|
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
|
521
|
+
closeVaultTerminal(result: "cancelled")
|
|
492
522
|
saveMainWindowFrame()
|
|
493
523
|
stopServices()
|
|
494
524
|
return .terminateNow
|
|
@@ -551,57 +581,198 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
551
581
|
// action. The bundled launcher independently allowlists it and resolves
|
|
552
582
|
// the repository CLI without PATH lookup.
|
|
553
583
|
switch action {
|
|
554
|
-
case "init", "unlock", "lock"
|
|
584
|
+
case "init", "unlock", "lock": break
|
|
555
585
|
default: return
|
|
556
586
|
}
|
|
587
|
+
presentVaultTerminal(action: action)
|
|
588
|
+
}
|
|
557
589
|
|
|
558
|
-
|
|
559
|
-
|
|
590
|
+
private func isTrustedVaultMessage(_ message: WKScriptMessage) -> Bool {
|
|
591
|
+
let origin = message.frameInfo.securityOrigin
|
|
592
|
+
return message.frameInfo.isMainFrame && origin.protocol == "http" && origin.host == "127.0.0.1" && origin.port == Int(webPort)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private func notifyVaultCommandResult(_ result: String) {
|
|
596
|
+
guard ["presented", "running", "succeeded", "failed", "cancelled"].contains(result) else { return }
|
|
597
|
+
let script = "window.dispatchEvent(new CustomEvent('aidevops:vault-command-result', { detail: '\(result)' }))"
|
|
598
|
+
webView.evaluateJavaScript(script, completionHandler: nil)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private func presentVaultTerminal(action: String) {
|
|
602
|
+
guard vaultProcess == nil, let contentView = window.contentView else {
|
|
560
603
|
notifyVaultCommandResult("failed")
|
|
561
604
|
return
|
|
562
605
|
}
|
|
563
|
-
|
|
606
|
+
let helperPath = Bundle.main.path(forResource: "aidevops-gui-services", ofType: "sh") ?? ""
|
|
607
|
+
guard trustedVaultExecutable(path: helperPath) else {
|
|
564
608
|
NSSound.beep()
|
|
565
609
|
notifyVaultCommandResult("failed")
|
|
566
610
|
return
|
|
567
611
|
}
|
|
568
612
|
|
|
569
|
-
let
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
613
|
+
let overlay = NSVisualEffectView()
|
|
614
|
+
overlay.material = .hudWindow
|
|
615
|
+
overlay.blendingMode = .withinWindow
|
|
616
|
+
overlay.translatesAutoresizingMaskIntoConstraints = false
|
|
617
|
+
let title = NSTextField(labelWithString: "Secure Vault \(action)")
|
|
618
|
+
title.font = .boldSystemFont(ofSize: 18)
|
|
619
|
+
title.translatesAutoresizingMaskIntoConstraints = false
|
|
620
|
+
let scroll = NSScrollView()
|
|
621
|
+
scroll.hasVerticalScroller = true
|
|
622
|
+
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
623
|
+
let output = NSTextView()
|
|
624
|
+
output.isEditable = false
|
|
625
|
+
output.isSelectable = true
|
|
626
|
+
output.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
|
627
|
+
output.string = "Starting fixed Vault action…\n"
|
|
628
|
+
scroll.documentView = output
|
|
629
|
+
let input = NSSecureTextField()
|
|
630
|
+
input.placeholderString = "Secure input becomes available when requested"
|
|
631
|
+
input.isEnabled = false
|
|
632
|
+
input.translatesAutoresizingMaskIntoConstraints = false
|
|
633
|
+
let submit = NSButton(title: "Submit", target: self, action: #selector(submitVaultInput(_:)))
|
|
634
|
+
submit.isEnabled = false
|
|
635
|
+
submit.translatesAutoresizingMaskIntoConstraints = false
|
|
636
|
+
let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelVaultTerminal(_:)))
|
|
637
|
+
cancel.keyEquivalent = "\u{1b}"
|
|
638
|
+
cancel.translatesAutoresizingMaskIntoConstraints = false
|
|
639
|
+
for view in [title, scroll, input, submit, cancel] { overlay.addSubview(view) }
|
|
640
|
+
contentView.addSubview(overlay)
|
|
641
|
+
NSLayoutConstraint.activate([
|
|
642
|
+
overlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), overlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
643
|
+
overlay.topAnchor.constraint(equalTo: contentView.topAnchor), overlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
644
|
+
title.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 32), title.topAnchor.constraint(equalTo: overlay.topAnchor, constant: 48),
|
|
645
|
+
scroll.leadingAnchor.constraint(equalTo: title.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -32),
|
|
646
|
+
scroll.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 18), scroll.bottomAnchor.constraint(equalTo: input.topAnchor, constant: -16),
|
|
647
|
+
input.leadingAnchor.constraint(equalTo: title.leadingAnchor), input.trailingAnchor.constraint(equalTo: submit.leadingAnchor, constant: -10), input.bottomAnchor.constraint(equalTo: overlay.bottomAnchor, constant: -32),
|
|
648
|
+
submit.trailingAnchor.constraint(equalTo: cancel.leadingAnchor, constant: -10), submit.centerYAnchor.constraint(equalTo: input.centerYAnchor),
|
|
649
|
+
cancel.trailingAnchor.constraint(equalTo: scroll.trailingAnchor), cancel.centerYAnchor.constraint(equalTo: input.centerYAnchor)
|
|
650
|
+
])
|
|
651
|
+
vaultOverlay = overlay
|
|
652
|
+
vaultOutput = output
|
|
653
|
+
vaultInput = input
|
|
654
|
+
vaultSubmit = submit
|
|
655
|
+
window.sharingType = .none
|
|
656
|
+
titlebarCameraButton?.isEnabled = false
|
|
657
|
+
notifyVaultCommandResult("presented")
|
|
658
|
+
startVaultPTY(helperPath: helperPath, action: action)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private func trustedVaultExecutable(path: String) -> Bool {
|
|
662
|
+
guard !path.isEmpty, let attributes = try? FileManager.default.attributesOfItem(atPath: path),
|
|
663
|
+
let type = attributes[.type] as? FileAttributeType, type == .typeRegular,
|
|
664
|
+
let owner = attributes[.ownerAccountID] as? NSNumber, owner.uint32Value == getuid(),
|
|
665
|
+
let permissions = attributes[.posixPermissions] as? NSNumber, permissions.intValue & 0o022 == 0 else { return false }
|
|
666
|
+
let values = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.isSymbolicLinkKey])
|
|
667
|
+
return values?.isSymbolicLink == false
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private func startVaultPTY(helperPath: String, action: String) {
|
|
671
|
+
var master: Int32 = -1
|
|
672
|
+
var slave: Int32 = -1
|
|
673
|
+
guard openpty(&master, &slave, nil, nil, nil) == 0 else {
|
|
674
|
+
closeVaultTerminal(result: "failed")
|
|
574
675
|
return
|
|
575
676
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
677
|
+
vaultSlaveFD = slave
|
|
678
|
+
let masterHandle = FileHandle(fileDescriptor: master, closeOnDealloc: true)
|
|
679
|
+
let slaveHandle = FileHandle(fileDescriptor: slave, closeOnDealloc: false)
|
|
680
|
+
let process = Process()
|
|
681
|
+
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
682
|
+
process.arguments = [helperPath, "vault-command", action]
|
|
683
|
+
process.environment = ["HOME": FileManager.default.homeDirectoryForCurrentUser.path, "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", "AIDEVOPS_SESSION_ID": "gui-desktop-secure-pty", "TERM": "dumb"]
|
|
684
|
+
process.standardInput = slaveHandle
|
|
685
|
+
process.standardOutput = slaveHandle
|
|
686
|
+
process.standardError = slaveHandle
|
|
687
|
+
masterHandle.readabilityHandler = { [weak self] handle in
|
|
688
|
+
let data = handle.availableData
|
|
689
|
+
guard !data.isEmpty else { return }
|
|
690
|
+
DispatchQueue.main.async { self?.appendVaultOutput(data) }
|
|
691
|
+
}
|
|
692
|
+
process.terminationHandler = { [weak self] process in
|
|
693
|
+
DispatchQueue.main.async { self?.finishVaultTerminal(succeeded: process.terminationStatus == 0) }
|
|
694
|
+
}
|
|
695
|
+
vaultMaster = masterHandle
|
|
696
|
+
vaultProcess = process
|
|
697
|
+
do {
|
|
698
|
+
try process.run()
|
|
699
|
+
close(slave)
|
|
700
|
+
vaultSlaveFD = -1
|
|
701
|
+
notifyVaultCommandResult("running")
|
|
702
|
+
} catch {
|
|
703
|
+
closeVaultTerminal(result: "failed")
|
|
582
704
|
}
|
|
583
|
-
notifyVaultCommandResult("opened")
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
private func isTrustedVaultMessage(_ message: WKScriptMessage) -> Bool {
|
|
587
|
-
let origin = message.frameInfo.securityOrigin
|
|
588
|
-
return message.frameInfo.isMainFrame && origin.protocol == "http" && origin.host == "127.0.0.1" && origin.port == Int(webPort)
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
private func shellQuote(_ value: String) -> String {
|
|
592
|
-
return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
593
705
|
}
|
|
594
706
|
|
|
595
|
-
private func
|
|
596
|
-
let
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
707
|
+
private func appendVaultOutput(_ data: Data) {
|
|
708
|
+
guard let text = String(data: data, encoding: .utf8) else { return }
|
|
709
|
+
let sanitized = text.unicodeScalars.filter { scalar in scalar == "\n" || scalar == "\r" || scalar == "\t" || scalar.value >= 0x20 && scalar.value != 0x7f }.map(String.init).joined()
|
|
710
|
+
vaultOutput?.string.append(String(sanitized.suffix(16_384)))
|
|
711
|
+
vaultOutput?.scrollToEndOfDocument(nil)
|
|
712
|
+
var attributes = termios()
|
|
713
|
+
let echoDisabled = vaultMaster.map { tcgetattr($0.fileDescriptor, &attributes) == 0 && (attributes.c_lflag & tcflag_t(ECHO)) == 0 } ?? false
|
|
714
|
+
vaultAllowsVisibleAcknowledgement = !echoDisabled && sanitized.contains("I UNDERSTAND")
|
|
715
|
+
let acceptsInput = echoDisabled || vaultAllowsVisibleAcknowledgement
|
|
716
|
+
vaultInput?.isEnabled = acceptsInput
|
|
717
|
+
vaultSubmit?.isEnabled = acceptsInput
|
|
718
|
+
if acceptsInput { window.makeFirstResponder(vaultInput) }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
@objc private func submitVaultInput(_ sender: Any?) {
|
|
722
|
+
guard let input = vaultInput, input.isEnabled, let master = vaultMaster else { return }
|
|
723
|
+
var attributes = termios()
|
|
724
|
+
let echoDisabled = tcgetattr(master.fileDescriptor, &attributes) == 0 && (attributes.c_lflag & tcflag_t(ECHO)) == 0
|
|
725
|
+
if !echoDisabled && (!vaultAllowsVisibleAcknowledgement || input.stringValue != "I UNDERSTAND") {
|
|
726
|
+
NSSound.beep()
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
var bytes = Array(input.stringValue.utf8)
|
|
730
|
+
input.stringValue = ""
|
|
731
|
+
vaultAllowsVisibleAcknowledgement = false
|
|
732
|
+
input.isEnabled = false
|
|
733
|
+
vaultSubmit?.isEnabled = false
|
|
734
|
+
guard !bytes.isEmpty else { return }
|
|
735
|
+
bytes.append(0x0a)
|
|
736
|
+
let data = Data(bytes)
|
|
737
|
+
do { try master.write(contentsOf: data) } catch { closeVaultTerminal(result: "failed") }
|
|
738
|
+
bytes.withUnsafeMutableBytes { buffer in
|
|
739
|
+
buffer.initializeMemory(as: UInt8.self, repeating: 0)
|
|
740
|
+
}
|
|
600
741
|
}
|
|
601
742
|
|
|
602
|
-
private func
|
|
603
|
-
|
|
604
|
-
|
|
743
|
+
@objc private func cancelVaultTerminal(_ sender: Any?) {
|
|
744
|
+
closeVaultTerminal(result: "cancelled")
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private func finishVaultTerminal(succeeded: Bool) {
|
|
748
|
+
let result = succeeded ? "succeeded" : "failed"
|
|
749
|
+
appendVaultOutput(Data("\nVault action \(result). Close this secure surface.\n".utf8))
|
|
750
|
+
vaultInput?.isEnabled = false
|
|
751
|
+
vaultSubmit?.isEnabled = false
|
|
752
|
+
notifyVaultCommandResult(result)
|
|
753
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.closeVaultTerminal(result: nil) }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private func closeVaultTerminal(result: String?) {
|
|
757
|
+
vaultProcess?.terminationHandler = nil
|
|
758
|
+
if vaultProcess?.isRunning == true { vaultProcess?.terminate() }
|
|
759
|
+
vaultMaster?.readabilityHandler = nil
|
|
760
|
+
try? vaultMaster?.close()
|
|
761
|
+
if vaultSlaveFD >= 0 { close(vaultSlaveFD) }
|
|
762
|
+
vaultInput?.stringValue = ""
|
|
763
|
+
vaultOutput?.string = ""
|
|
764
|
+
vaultOverlay?.removeFromSuperview()
|
|
765
|
+
vaultProcess = nil
|
|
766
|
+
vaultMaster = nil
|
|
767
|
+
vaultSlaveFD = -1
|
|
768
|
+
vaultInput = nil
|
|
769
|
+
vaultOutput = nil
|
|
770
|
+
vaultSubmit = nil
|
|
771
|
+
vaultOverlay = nil
|
|
772
|
+
vaultAllowsVisibleAcknowledgement = false
|
|
773
|
+
window?.sharingType = .readOnly
|
|
774
|
+
titlebarCameraButton?.isEnabled = true
|
|
775
|
+
if let result = result { notifyVaultCommandResult(result) }
|
|
605
776
|
}
|
|
606
777
|
|
|
607
778
|
private func revealFileURL(_ url: URL) -> Bool {
|
|
@@ -874,9 +1045,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
874
1045
|
}
|
|
875
1046
|
|
|
876
1047
|
func windowWillClose(_ notification: Notification) {
|
|
1048
|
+
closeVaultTerminal(result: "cancelled")
|
|
877
1049
|
saveMainWindowFrame()
|
|
878
1050
|
}
|
|
879
1051
|
|
|
1052
|
+
@objc private func cancelVaultForSessionChange(_ notification: Notification) {
|
|
1053
|
+
closeVaultTerminal(result: "cancelled")
|
|
1054
|
+
}
|
|
1055
|
+
|
|
880
1056
|
@objc private func reloadPage(_ sender: Any?) {
|
|
881
1057
|
webView.reload()
|
|
882
1058
|
}
|
|
@@ -1131,6 +1307,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
1131
1307
|
}
|
|
1132
1308
|
|
|
1133
1309
|
@objc private func captureAppScreenshot(_ sender: Any?) {
|
|
1310
|
+
guard vaultOverlay == nil else { NSSound.beep(); return }
|
|
1134
1311
|
guard let contentView = window.contentView else {
|
|
1135
1312
|
return
|
|
1136
1313
|
}
|
|
@@ -1147,6 +1324,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
|
|
|
1147
1324
|
}
|
|
1148
1325
|
|
|
1149
1326
|
@objc private func capturePageScreenshot(_ sender: Any?) {
|
|
1327
|
+
guard vaultOverlay == nil else { NSSound.beep(); return }
|
|
1150
1328
|
let script = """
|
|
1151
1329
|
(() => {
|
|
1152
1330
|
const el = document.querySelector('.workspace-scroll');
|
|
@@ -648,6 +648,10 @@ export interface GuiStatusData {
|
|
|
648
648
|
pulse_workers: GuiPulseWorkerSummary;
|
|
649
649
|
capabilities: GuiCapabilitySummary[];
|
|
650
650
|
secrets: GuiSecretReference[];
|
|
651
|
+
secret_backends: {
|
|
652
|
+
gopass: "available" | "missing" | "error";
|
|
653
|
+
credentials: "available" | "missing" | "error";
|
|
654
|
+
};
|
|
651
655
|
placeholders: string[];
|
|
652
656
|
}
|
|
653
657
|
|
|
@@ -495,6 +495,7 @@ export const statusFixture: GuiStatusData = {
|
|
|
495
495
|
},
|
|
496
496
|
],
|
|
497
497
|
secrets: [],
|
|
498
|
+
secret_backends: { gopass: "missing", credentials: "missing" },
|
|
498
499
|
placeholders: [
|
|
499
500
|
"Settings, repos, routines, OpenCode sessions, and capabilities will be added as read-only adapters.",
|
|
500
501
|
],
|
|
@@ -47,6 +47,7 @@ export function SecuritySurface({ onVaultRequest, status }: {
|
|
|
47
47
|
<SecretMetric detail={vault.unlocked ? "ready for dependent tools" : "visible after local unlock"} icon={<FiCheckCircle />} label="Configured" value={hiddenCount ?? String(configured)} />
|
|
48
48
|
<SecretMetric detail={vault.unlocked ? "missing or not yet checked" : "visible after local unlock"} icon={<FiAlertTriangle />} label="Needs attention" value={hiddenCount ?? String(needsAttention)} />
|
|
49
49
|
<SecretMetric detail="hidden local prompt only" icon={<FiTerminal />} label="Value custody" value="Write-only" />
|
|
50
|
+
<SecretMetric detail={`credentials: ${status.secret_backends.credentials}`} icon={<FiActivity />} label="gopass backend" value={vault.unlocked ? status.secret_backends.gopass : "Hidden"} />
|
|
50
51
|
</div>
|
|
51
52
|
|
|
52
53
|
{vault.unlocked ? (
|
|
@@ -63,11 +63,11 @@ interface DialogContent {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const dialogContentFactories: Record<VaultDialogIntent, (vault: GuiVaultStatusData) => DialogContent> = {
|
|
66
|
-
lock: () => ({ action: "
|
|
67
|
-
recover: () => ({ action: "
|
|
68
|
-
setup: () => ({ action: "
|
|
66
|
+
lock: () => ({ action: "Lock Vault", detail: "Locking forgets in-memory keys and hides protected previews again.", notice: "The fixed native action does not receive browser data or secret material.", title: "Lock local Vault" }),
|
|
67
|
+
recover: () => ({ action: "Refresh status", detail: "Vault metadata appears damaged. Preserve existing encrypted data and use direct CLI recovery guidance.", notice: "Do not initialise with --force or overwrite the existing Vault.", title: "Review Vault recovery" }),
|
|
68
|
+
setup: () => ({ action: "Set up securely", detail: "Create this device's Vault in the native secure surface.", notice: "Save the new passphrase in a trusted password manager. aidevops cannot recover it.", title: "Set up Vault" }),
|
|
69
69
|
unavailable: () => ({ action: "Retry status", detail: "Vault readiness is not authoritative, so setup and passphrase actions are disabled.", notice: "Check the local helper and crypto runtime, then retry. Existing encrypted data will not be reinitialised.", title: "Vault status unavailable" }),
|
|
70
|
-
unlock: (
|
|
70
|
+
unlock: () => ({ action: "Unlock securely", detail: "Unlock the existing Vault with the passphrase you already saved.", notice: "Passphrase input remains in the native secure surface and never enters the web view.", title: "Unlock existing Vault" }),
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
const dialogIcons: Record<VaultDialogIntent, IconType> = {
|
|
@@ -109,6 +109,8 @@ const launchStatusPresentation: Partial<Record<VaultLaunchStatus, { Icon: IconTy
|
|
|
109
109
|
copied: { Icon: FiClipboard, className: "vault-valid", role: "status", text: "Command copied. Run it in your local terminal." },
|
|
110
110
|
failed: { Icon: FiAlertTriangle, className: "vault-invalid", role: "alert", text: "Open a local terminal and run the displayed command." },
|
|
111
111
|
opened: { Icon: FiCheckCircle, className: "vault-valid", role: "status", text: "Secure terminal opened. Return here after the command completes; status refreshes on focus." },
|
|
112
|
+
succeeded: { Icon: FiCheckCircle, className: "vault-valid", role: "status", text: "Vault action completed securely." },
|
|
113
|
+
cancelled: { Icon: FiAlertTriangle, className: "vault-invalid", role: "status", text: "Vault action cancelled and native buffers cleared." },
|
|
112
114
|
requesting: { Icon: FiTerminal, className: "vault-valid", role: "status", text: "Requesting the secure local terminal…" },
|
|
113
115
|
};
|
|
114
116
|
|
|
@@ -99,6 +99,7 @@ function normalizeStatusEnvelope(envelope: GuiResponseEnvelope<Partial<GuiStatus
|
|
|
99
99
|
pulse_workers: normalizePulseWorkers(data.pulse_workers),
|
|
100
100
|
capabilities: data.capabilities ?? statusFixture.capabilities,
|
|
101
101
|
secrets: visibleSecrets(vault, data.secrets),
|
|
102
|
+
secret_backends: vault.status === "unlocked" && vault.unlocked ? data.secret_backends ?? statusFixture.secret_backends : statusFixture.secret_backends,
|
|
102
103
|
placeholders: data.placeholders ?? statusFixture.placeholders,
|
|
103
104
|
},
|
|
104
105
|
};
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import type { VaultDialogIntent } from "./VaultBadges";
|
|
3
|
-
import { type NativeVaultAction, postNativeVaultCommand, vaultCommandText } from "./vault-command-bridge";
|
|
3
|
+
import { isNativeVaultResult, type NativeVaultAction, type NativeVaultResult, postNativeVaultCommand, vaultCommandText } from "./vault-command-bridge";
|
|
4
4
|
|
|
5
|
-
export type VaultLaunchStatus = "idle" | "requesting" | "opened" | "copied" | "failed";
|
|
5
|
+
export type VaultLaunchStatus = "idle" | "requesting" | "opened" | "copied" | "failed" | "succeeded" | "cancelled";
|
|
6
6
|
|
|
7
7
|
const terminalActions: Record<VaultDialogIntent, NativeVaultAction | null> = {
|
|
8
8
|
lock: "lock",
|
|
9
|
-
recover:
|
|
9
|
+
recover: null,
|
|
10
10
|
setup: "init",
|
|
11
11
|
unavailable: null,
|
|
12
12
|
unlock: "unlock",
|
|
13
13
|
};
|
|
14
|
+
const nativeResultStatuses: Record<NativeVaultResult, VaultLaunchStatus> = {
|
|
15
|
+
cancelled: "cancelled",
|
|
16
|
+
failed: "failed",
|
|
17
|
+
presented: "opened",
|
|
18
|
+
running: "opened",
|
|
19
|
+
succeeded: "succeeded",
|
|
20
|
+
};
|
|
14
21
|
|
|
15
22
|
export function terminalActionForIntent(intent: VaultDialogIntent): NativeVaultAction | null {
|
|
16
23
|
return terminalActions[intent];
|
|
@@ -62,11 +69,12 @@ export function useVaultCommandLaunch({ intent, onRefresh, onTerminalLaunch }: {
|
|
|
62
69
|
const handleNativeResult = (event: Event) => {
|
|
63
70
|
clearNativeResultTimeout();
|
|
64
71
|
const result = (event as CustomEvent<unknown>).detail;
|
|
65
|
-
setLaunchStatus(result
|
|
72
|
+
setLaunchStatus(isNativeVaultResult(result) ? nativeResultStatuses[result] : "failed");
|
|
73
|
+
if (result === "succeeded") void onRefresh();
|
|
66
74
|
};
|
|
67
75
|
window.addEventListener("aidevops:vault-command-result", handleNativeResult);
|
|
68
76
|
return () => window.removeEventListener("aidevops:vault-command-result", handleNativeResult);
|
|
69
|
-
}, [clearNativeResultTimeout, setLaunchStatus]);
|
|
77
|
+
}, [clearNativeResultTimeout, onRefresh, setLaunchStatus]);
|
|
70
78
|
|
|
71
79
|
useEffect(() => {
|
|
72
80
|
if (timeoutIntent.current !== intent) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export type NativeVaultAction = "init" | "unlock" | "lock"
|
|
1
|
+
export type NativeVaultAction = "init" | "unlock" | "lock";
|
|
2
|
+
export type NativeVaultResult = "presented" | "running" | "succeeded" | "failed" | "cancelled";
|
|
3
|
+
const NATIVE_VAULT_RESULTS = new Set<unknown>(["presented", "running", "succeeded", "failed", "cancelled"]);
|
|
4
|
+
|
|
5
|
+
export function isNativeVaultResult(value: unknown): value is NativeVaultResult {
|
|
6
|
+
return NATIVE_VAULT_RESULTS.has(value);
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
interface WebKitVaultCommandWindow extends Window {
|
|
4
10
|
webkit?: {
|
package/setup.sh
CHANGED
|
@@ -17,7 +17,7 @@ fi
|
|
|
17
17
|
# AI Assistant Server Access Framework Setup Script
|
|
18
18
|
# Helps developers set up the framework for their infrastructure
|
|
19
19
|
#
|
|
20
|
-
# Version: 3.32.
|
|
20
|
+
# Version: 3.32.46
|
|
21
21
|
#
|
|
22
22
|
# Quick Install:
|
|
23
23
|
# npm install -g aidevops && aidevops update (recommended)
|