Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions docs/macos-codesigning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# macOS Codesigning

All projects (Flow, Seq, Lin, Rise) sign binaries with a Developer ID Application certificate. This makes macOS TCC grants (Accessibility, Screen Recording, Input Monitoring) survive rebuilds and work for daemon processes.

## Why this matters

macOS ties TCC permission grants to code signatures. When a binary is ad-hoc signed (`--sign -`) or signed with an Apple Development certificate, the signature changes on every rebuild. That means:

- Accessibility grants break after every `f deploy`
- Input Monitoring grants break after every rebuild
- Screen Recording grants break after every rebuild
- Daemon processes (like `seqd`) that aren't launched from a terminal never inherit terminal TCC grants at all

Developer ID Application signing solves all of this. The identity is stable across rebuilds, so TCC grants persist.

## How it works

### Shared helper: `~/.config/flow/codesign.sh`

All projects source a single shared script that handles identity detection:

```bash
source "${HOME}/.config/flow/codesign.sh"
flow_codesign /path/to/binary
```

The helper resolves the signing identity in this order:

1. `$FLOW_CODESIGN_IDENTITY` env var (explicit override)
2. `Developer ID Application` from the keychain
3. `Apple Development` from the keychain (fallback)
4. Skip silently (no certificate available)

The identity is resolved once per shell session (first `source` call queries the keychain, subsequent calls reuse the cached value). Signing failure never breaks a build - all calls are best-effort.

### Per-project integration

**Flow** (`scripts/deploy.sh`)
- Signs `f` and `lin` binaries after `cargo build`, before copying to `~/bin`
- Since copies are made from the signed source binaries, all installed copies (`~/bin/f`, `~/bin/lin`, `~/.local/bin/f`, etc.) carry the signature

**Seq** (`cli/cpp/run.sh`)
- Signs `seq` binary after clang build
- Signs `libseqmem.dylib` if present (Swift memory engine)
- Signs the `SeqDaemon.app` bundle (required for daemon TCC grants)
- Respects `$SEQ_CODE_SIGN_IDENTITY` override (maps to `$FLOW_CODESIGN_IDENTITY` internally)

**Lin** (`mac/flow.toml`)
- `release-mac` task: sources the helper, signs `/Applications/Lin.app` with `--deep` (required for `.app` bundles)
- `r` task (quick incremental build): same logic, falls back to ad-hoc if no identity found

**Rise** (`scripts/deploy-cli.sh`, `ffi/cpp/build.sh`)
- `deploy-cli.sh`: signs `rise-bin` after copying to `~/.local/bin` and `~/bin`
- `build.sh`: signs `librise.dylib` after clang build

## Verifying signatures

Check that a binary is signed with Developer ID:

```bash
codesign -dvv /path/to/binary 2>&1 | grep Authority
```

Expected output:

```
Authority=Developer ID Application: Nikita Voloboev (6J8K2M6486)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
```

Quick verification commands for each project:

```bash
# Flow
codesign -dvv ~/bin/f 2>&1 | grep Authority

# Seq
codesign -dvv ~/code/seq/cli/cpp/out/bin/seq 2>&1 | grep Authority
codesign -dvv ~/code/seq/cli/cpp/out/bin/SeqDaemon.app 2>&1 | grep Authority

# Lin
codesign -dvv /Applications/Lin.app 2>&1 | grep Authority

# Rise
codesign -dvv ~/.local/bin/rise-bin 2>&1 | grep Authority
```

If you see `Authority=Apple Development` instead of `Developer ID Application`, the Developer ID certificate is not in the keychain. If you see no Authority line or `(code or signature modified)`, the binary was modified after signing.

## Troubleshooting

### TCC grants break after rebuild

1. Verify the binary is signed: `codesign -dvv <binary> 2>&1 | grep Authority`
2. If unsigned or ad-hoc, check that `~/.config/flow/codesign.sh` exists and is sourced by the build script
3. Check that the Developer ID certificate is in the keychain: `security find-identity -p codesigning -v | grep "Developer ID"`
4. Rebuild and verify again

### "Developer ID Application" not found

The certificate must be installed in the login keychain. If you have it in a `.p12` file:

```bash
security import developer-id.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign
```

If you only have an Apple Development certificate, the helper falls back to it. TCC grants will still work but may be less stable across Xcode updates.

### seqd loses Accessibility after rebuild

The daemon runs inside `SeqDaemon.app` specifically so it gets its own TCC entry. After rebuilding:

1. Verify: `codesign -dvv ~/code/seq/cli/cpp/out/bin/SeqDaemon.app 2>&1 | grep Authority`
2. If correct, the old TCC grant should still apply. If not, re-grant in System Settings > Privacy & Security > Accessibility
3. Test: `printf 'AX_STATUS\n' | nc -U /tmp/seqd.sock` should print `1`

### Lin loses Screen Recording after rebuild

1. Verify: `codesign -dvv /Applications/Lin.app 2>&1 | grep Authority`
2. If signed with Developer ID, the TCC grant should persist. If it doesn't, reset and re-grant:
```bash
tccutil reset ScreenCapture io.linsa
```
Then reopen Lin and grant Screen Recording again.

### Overriding the identity

Set `$FLOW_CODESIGN_IDENTITY` before building to use a specific certificate:

```bash
export FLOW_CODESIGN_IDENTITY="Developer ID Application: Someone Else (XXXXXXXXXX)"
f deploy
```

For Seq specifically, `$SEQ_CODE_SIGN_IDENTITY` also works (it takes precedence).

### Checking what certificates are available

```bash
security find-identity -p codesigning -v
```

This lists all valid codesigning identities in your keychains. The helper picks the first `Developer ID Application` match, or the first `Apple Development` match if no Developer ID is available.

## Architecture notes

- The shared helper lives in `~/.config/flow/` (Flow's config directory) because it's a cross-project concern managed by Flow
- `.app` bundles (SeqDaemon.app, Lin.app) require `--deep` flag to sign embedded frameworks and executables
- Signing happens on the source binary before copies are made, so all install locations get the signed version
- The helper is sourced with `2>/dev/null || true` so missing file never breaks builds (e.g. on Linux or CI where no keychain exists)
5 changes: 5 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ cargo build "${BUILD_ARGS[@]}" --quiet
SOURCE_F="${ROOT_DIR}/target/${TARGET_DIR}/f"
SOURCE_LIN="${ROOT_DIR}/target/${TARGET_DIR}/lin"

# Codesign source binaries so all copies inherit the signature
source "${HOME}/.config/flow/codesign.sh" 2>/dev/null || true
flow_codesign "$SOURCE_F" 2>/dev/null || true
flow_codesign "$SOURCE_LIN" 2>/dev/null || true

PRIMARY_DIR="${HOME}/bin"
ALT_DIR="${HOME}/.local/bin"
PRIMARY_F="$(command -v f 2>/dev/null || true)"
Expand Down
9 changes: 7 additions & 2 deletions src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,13 @@ fn run_agents_rules(profile: Option<&str>, repo: Option<&str>) -> Result<()> {
bail!("Missing profile file: {}", source.display());
}
let target = repo_path.join("agents.md");
fs::copy(&source, &target)
.with_context(|| format!("failed to copy {} to {}", source.display(), target.display()))?;
fs::copy(&source, &target).with_context(|| {
format!(
"failed to copy {} to {}",
source.display(),
target.display()
)
})?;

let default_path = agents_dir.join(".default");
fs::write(&default_path, &profile_name)
Expand Down
16 changes: 13 additions & 3 deletions src/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,19 @@ pub fn run(action: Option<AiAction>) -> Result<()> {
AiAction::Import => import_sessions()?,
AiAction::Copy { session } => copy_session(session, Provider::All)?,
AiAction::CopyClaude { search } => {
let query = if search.is_empty() { None } else { Some(search.join(" ")) };
let query = if search.is_empty() {
None
} else {
Some(search.join(" "))
};
copy_last_session(Provider::Claude, query)?
}
AiAction::CopyCodex { search } => {
let query = if search.is_empty() { None } else { Some(search.join(" ")) };
let query = if search.is_empty() {
None
} else {
Some(search.join(" "))
};
copy_last_session(Provider::Codex, query)?
}
AiAction::Context {
Expand Down Expand Up @@ -2370,7 +2378,9 @@ fn copy_session_by_search(provider: Provider, query: &str) -> Result<()> {
if let Some(project_path) = cwd {
println!(
"Copied session {} from {} ({} lines) to clipboard",
id_short, project_path.display(), line_count
id_short,
project_path.display(),
line_count
);
return Ok(());
}
Expand Down
7 changes: 6 additions & 1 deletion src/ai_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ pub fn quick_prompt(
let text = parsed
.choices
.first()
.and_then(|c| c.message.as_ref().map(|m| m.content.clone()).or(c.text.clone()))
.and_then(|c| {
c.message
.as_ref()
.map(|m| m.content.clone())
.or(c.text.clone())
})
.map(|t| t.trim().to_string())
.unwrap_or_default();

Expand Down
29 changes: 14 additions & 15 deletions src/ask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,8 @@ fn run_with_tasks(opts: AskOpts, tasks: Vec<DiscoveredTask>) -> Result<()> {
let valid_subcommands = valid_subcommand_set(&commands);
let prompt = build_prompt(&query_display, &tasks, &commands);

let response = ai_server::quick_prompt(
&prompt,
opts.model.as_deref(),
opts.url.as_deref(),
None,
)?;
let response =
ai_server::quick_prompt(&prompt, opts.model.as_deref(), opts.url.as_deref(), None)?;

let selection = parse_ask_response(&response, &tasks, &valid_subcommands)?;

Expand Down Expand Up @@ -115,9 +111,16 @@ fn flow_command_candidates() -> Vec<FlowCommand> {
let cmd = Cli::command();
for sub in cmd.get_subcommands() {
let name = sub.get_name().to_string();
let about = sub.get_about().map(|s| s.to_string()).filter(|s| !s.is_empty());
let about = sub
.get_about()
.map(|s| s.to_string())
.filter(|s| !s.is_empty());
let aliases = sub.get_all_aliases().map(|a| a.to_string()).collect();
commands.push(FlowCommand { name, aliases, about });
commands.push(FlowCommand {
name,
aliases,
about,
});
}

commands.push(FlowCommand {
Expand Down Expand Up @@ -247,9 +250,8 @@ fn normalize_command(raw: &str, valid_subcommands: &HashSet<String>) -> Result<S
cmd = format!("f {}", cmd);
}

let tokens = shell_words::split(&cmd).unwrap_or_else(|_| {
cmd.split_whitespace().map(|s| s.to_string()).collect()
});
let tokens = shell_words::split(&cmd)
.unwrap_or_else(|_| cmd.split_whitespace().map(|s| s.to_string()).collect());
if tokens.len() < 2 {
bail!("Command '{}' is incomplete.", cmd);
}
Expand Down Expand Up @@ -432,10 +434,7 @@ fn extract_task_name(response: &str, tasks: &[DiscoveredTask]) -> Result<String>
}
}

bail!(
"Could not parse task name from AI response: '{}'",
response
)
bail!("Could not parse task name from AI response: '{}'", response)
}

#[cfg(test)]
Expand Down
38 changes: 38 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,32 @@ pub enum Commands {
alias = "px"
)]
Proxy(ProxyCommand),
#[command(
about = "Create a GitHub PR from the current branch.",
long_about = "Creates a branch/bookmark from the latest commit, pushes it, and opens a GitHub PR. Works with both jj and pure git."
)]
Pr(PrOpts),
}

#[derive(Args, Debug, Clone)]
pub struct PrOpts {
/// Arguments:
/// - `preview`: create the PR on your fork (origin) only (no upstream).
/// - `TITLE`: optional PR title. If provided, Flow will create a commit/change for current working copy changes.
#[arg(value_name = "ARGS", num_args = 0.., trailing_var_arg = true)]
pub args: Vec<String>,
/// Base branch (default: main).
#[arg(long, default_value = "main")]
pub base: String,
/// Create as draft PR.
#[arg(long)]
pub draft: bool,
/// Custom branch name (auto-generated from commit message if omitted).
#[arg(long, short)]
pub branch: Option<String>,
/// Don't open in browser after creation.
#[arg(long)]
pub no_open: bool,
}

#[derive(Args, Debug, Clone)]
Expand Down Expand Up @@ -1366,12 +1392,24 @@ pub enum CommitQueueAction {
/// Push even if the commit is not at HEAD.
#[arg(long, short = 'f')]
force: bool,
/// Allow pushing even if the queued commit has review issues recorded.
#[arg(long)]
allow_issues: bool,
/// Allow pushing even if the review timed out or is missing.
#[arg(long)]
allow_unreviewed: bool,
},
/// Approve all queued commits on the current branch (push once).
ApproveAll {
/// Push even if the branch is behind its remote.
#[arg(long, short = 'f')]
force: bool,
/// Allow pushing even if some queued commits have review issues recorded.
#[arg(long)]
allow_issues: bool,
/// Allow pushing even if some queued commits have review timed out / missing.
#[arg(long)]
allow_unreviewed: bool,
},
/// Remove a commit from the queue without pushing.
Drop {
Expand Down
Loading