refactor(desktop): extract spawn helpers and reshape permission card props

Post-merge adaptation to origin/main. Lift the git-credential-nostr
wiring and BUZZ_ACP_PERMISSION_POLICY injection out of the now-oversized
spawn_agent_child into named helpers (configure_git_credential_helper,
inject_spawn_permission_policy), and collapse PermissionRequestCardBlock
to take the TimelineMessage plus the isKnownAgentPubkey predicate so the
null-gate lives inside the block instead of at every MessageRow callsite.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-25 13:08:43 -04:00
parent 9d42119562
commit 25ddb2f5d4
6 changed files with 124 additions and 93 deletions

View File

@@ -116,6 +116,42 @@ pub(crate) fn build_buzz_agent_provider_defaults(cmd: &mut std::process::Command
}
}
/// Wire the git-credential-nostr helper onto `cmd` for Buzz relay git (NIP-98).
///
/// Sets ephemeral `GIT_CONFIG_*` vars scoped to the relay HTTP URL and mirrors
/// the agent key into `NOSTR_PRIVATE_KEY`. When the helper binary is absent,
/// the agent simply lacks automatic Buzz git auth — a warning, not a failure.
pub(super) fn configure_git_credential_helper(
cmd: &mut std::process::Command,
relay_url: &str,
private_key_nsec: &str,
agent_name: &str,
) {
let Some(cred_helper) = super::resolve_command("git-credential-nostr") else {
eprintln!(
"buzz-desktop: git-credential-nostr not found — agent {agent_name} will not have automatic Buzz git auth",
);
return;
};
let relay_http_url = crate::relay::relay_http_base_url(relay_url);
cmd.env("NOSTR_PRIVATE_KEY", private_key_nsec);
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.env("GIT_CONFIG_COUNT", "2");
cmd.env(
"GIT_CONFIG_KEY_0",
format!("credential.{relay_http_url}/git.helper"),
);
cmd.env(
"GIT_CONFIG_VALUE_0",
cred_helper.to_string_lossy().replace('\\', "/"),
);
cmd.env(
"GIT_CONFIG_KEY_1",
format!("credential.{relay_http_url}/git.useHttpPath"),
);
cmd.env("GIT_CONFIG_VALUE_1", "true");
}
/// Parse newline-delimited `KEY=VALUE` lines from a baked env blob.
/// Blank lines are skipped. Each non-blank line must contain `=`; the key
/// is everything before the first `=`, the value is everything after (values

View File

@@ -120,6 +120,20 @@ pub fn apply_permission_policy_update(
Ok(())
}
/// Resolve the effective policy and inject `BUZZ_ACP_PERMISSION_POLICY` so the
/// running process and the UI-visible setting stay in sync. Returns the policy
/// so the caller can stamp it onto the spawn-config snapshot.
pub fn inject_spawn_permission_policy(
command: &mut std::process::Command,
record: &ManagedAgentRecord,
definitions: &[AgentDefinition],
global: &super::global_config::GlobalAgentConfig,
) -> PermissionPolicy {
let (policy, _) = resolve_effective_permission_policy(record, definitions, global);
command.env("BUZZ_ACP_PERMISSION_POLICY", policy.as_str());
policy
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -783,42 +783,24 @@ pub fn spawn_agent_child(
command.env_remove(key);
}
// Inject BUZZ_ACP_PERMISSION_POLICY — resolved here so the running process
// and the UI-visible setting are always in sync.
let (effective_permission_policy, _) =
resolve_effective_permission_policy(record, &personas, &global);
command.env(
"BUZZ_ACP_PERMISSION_POLICY",
effective_permission_policy.as_str(),
// Inject BUZZ_ACP_PERMISSION_POLICY, keeping the running process and the
// UI-visible setting in sync; the returned policy is stamped below.
let effective_permission_policy = super::permission_policy::inject_spawn_permission_policy(
&mut command,
record,
&personas,
&global,
);
command.env("BUZZ_ACP_RELAY_OBSERVER", "true");
// Git credential helper: NIP-98 auth for Buzz relay git via git-credential-nostr.
// Ephemeral GIT_CONFIG_COUNT env vars scoped to relay HTTP URL; NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY.
if let Some(cred_helper) = resolve_command("git-credential-nostr") {
let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url);
command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec);
command.env("GIT_TERMINAL_PROMPT", "0");
command.env("GIT_CONFIG_COUNT", "2");
command.env(
"GIT_CONFIG_KEY_0",
format!("credential.{relay_http_url}/git.helper"),
);
let helper = cred_helper.to_string_lossy().replace('\\', "/");
command.env("GIT_CONFIG_VALUE_0", helper);
command.env(
"GIT_CONFIG_KEY_1",
format!("credential.{relay_http_url}/git.useHttpPath"),
);
command.env("GIT_CONFIG_VALUE_1", "true");
} else {
eprintln!(
"buzz-desktop: git-credential-nostr not found — agent {} will not have automatic Buzz git auth",
record.name,
);
}
super::agent_env::configure_git_credential_helper(
&mut command,
&effective_relay_url,
&record.private_key_nsec,
&record.name,
);
// User env (descriptor.env): fully-layered floor→runtime→definition→global→persona→agent,
// reserved-key filtered. Written last so user-explicit values win over Buzz-set env.

View File

@@ -33,7 +33,6 @@ import {
KIND_STREAM_MESSAGE_DIFF,
} from "@/shared/constants/kinds";
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
import { getPermissionRequestAgentPubkey } from "@/features/messages/ui/permissionRequestAuthPubkey";
import { PermissionRequestCardBlock } from "@/features/messages/ui/PermissionRequestCardBlock";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -688,20 +687,11 @@ export const MessageRow = React.memo(
<>
<SentFromThreadLine channelId={channelId} tags={message.tags} />
{renderBody()}
{channelId && message.isAgent ? (
<PermissionRequestCardBlock
agentPubkey={getPermissionRequestAgentPubkey(
message,
isKnownAgentPubkey,
)}
channelId={channelId}
content={message.body}
editSignerPubkey={message.editSignerPubkey}
interactive
ownerPubkey={message.ownerPubkey}
signerPubkey={message.signerPubkey}
/>
) : null}
<PermissionRequestCardBlock
message={message}
isKnownAgentPubkey={isKnownAgentPubkey}
channelId={channelId}
/>
{continuationMetadataNode}
<MessageReactions
messageId={message.id}

View File

@@ -149,6 +149,26 @@ async function makeQueryClient(viewerPubkey) {
// ── Render helper ─────────────────────────────────────────────────────────────
// Build a minimal TimelineMessage carrying the sentinel. The block reads
// kind/isAgent/body/signerPubkey/editSignerPubkey/ownerPubkey; other fields
// are irrelevant to the render gates under test.
function makeMessage({ content, signerPubkey, editSignerPubkey, ownerPubkey }) {
return {
kind: 9,
isAgent: true,
body: content,
signerPubkey,
editSignerPubkey,
ownerPubkey,
};
}
// The block resolves `agentPubkey` from the signer via this predicate, so a
// forged signer (signer ≠ agent) fails the gate exactly as production does.
function makeIsKnownAgentPubkey(agentPubkey) {
return (pubkey) => pubkey === agentPubkey;
}
async function renderBlock({
content,
signerPubkey = AGENT_PUBKEY,
@@ -173,12 +193,13 @@ async function renderBlock({
QueryClientProvider,
{ client: qc },
createElement(PermissionRequestCardBlock, {
content,
interactive: true,
agentPubkey,
signerPubkey,
editSignerPubkey,
ownerPubkey,
message: makeMessage({
content,
signerPubkey,
editSignerPubkey,
ownerPubkey,
}),
isKnownAgentPubkey: makeIsKnownAgentPubkey(agentPubkey),
channelId: CHANNEL_ID,
}),
),
@@ -315,11 +336,12 @@ test("test_expiry_disables_buttons_after_clock_tick", async () => {
QueryClientProvider,
{ client: qc },
createElement(PermissionRequestCardBlock, {
content: makePendingContent(EXPIRY_SECS),
interactive: true,
agentPubkey: AGENT_PUBKEY,
signerPubkey: AGENT_PUBKEY,
ownerPubkey: OWNER_PUBKEY,
message: makeMessage({
content: makePendingContent(EXPIRY_SECS),
signerPubkey: AGENT_PUBKEY,
ownerPubkey: OWNER_PUBKEY,
}),
isKnownAgentPubkey: makeIsKnownAgentPubkey(AGENT_PUBKEY),
channelId: CHANNEL_ID,
}),
),

View File

@@ -8,6 +8,8 @@
*/
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { getPermissionRequestAgentPubkey } from "@/features/messages/ui/permissionRequestAuthPubkey";
import { useIdentityQuery } from "@/shared/api/hooks";
import { computePermissionRequest } from "@/shared/lib/computePermissionRequest";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -15,51 +17,40 @@ import { AttachmentGroup } from "@/shared/ui/attachment";
import { PermissionRequestCard } from "@/shared/ui/permission-request-card";
export type PermissionRequestCardBlockProps = {
/** Message body content — may contain the sentinel or may not. */
content: string;
/** Whether this is an interactive render surface. Non-interactive → no card. */
interactive: boolean;
/**
* Normalized hex pubkey of the known agent that signed the kind-9 event.
* Undefined → card disabled (no agent signer on this message).
*/
agentPubkey: string | undefined;
/** Raw signer pubkey from the signed event envelope. */
signerPubkey: string | undefined;
/**
* Signer pubkey of the most recent authorized kind-40003 edit, if any.
* Used to enforce edit authenticity: only agent-signed edits resolve the card.
*/
editSignerPubkey?: string;
/** Verified owner pubkey for the agent (from the agent's profile). */
ownerPubkey?: string | null;
/** Channel ID for routing the permission decision click. */
channelId: string;
/** The message that may carry a permission-request sentinel. */
message: TimelineMessage;
/** Predicate identifying a known agent signer (enables the card). */
isKnownAgentPubkey: (pubkey: string) => boolean;
/** Channel ID for routing the decision click; falsy → no card. */
channelId: string | null | undefined;
};
export const PermissionRequestCardBlock = React.memo(
function PermissionRequestCardBlock({
content,
interactive,
agentPubkey,
signerPubkey,
editSignerPubkey,
ownerPubkey,
message,
isKnownAgentPubkey,
channelId,
}: PermissionRequestCardBlockProps) {
const identityQuery = useIdentityQuery();
const viewerPubkey = identityQuery.data?.pubkey;
if (!channelId || !message.isAgent) return null;
const agentPubkey = getPermissionRequestAgentPubkey(
message,
isKnownAgentPubkey,
);
const request = computePermissionRequest(
content,
interactive,
message.body,
true,
agentPubkey,
signerPubkey,
editSignerPubkey,
message.signerPubkey,
message.editSignerPubkey,
);
if (request === null || !agentPubkey) return null;
const ownerPubkey = message.ownerPubkey;
const isOwner =
!!viewerPubkey &&
!!ownerPubkey &&
@@ -80,11 +71,7 @@ export const PermissionRequestCardBlock = React.memo(
);
},
(prev, next) =>
prev.content === next.content &&
prev.interactive === next.interactive &&
prev.agentPubkey === next.agentPubkey &&
prev.signerPubkey === next.signerPubkey &&
prev.editSignerPubkey === next.editSignerPubkey &&
prev.ownerPubkey === next.ownerPubkey &&
prev.message === next.message &&
prev.isKnownAgentPubkey === next.isKnownAgentPubkey &&
prev.channelId === next.channelId,
);