Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/workflows/ci-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,29 @@ jobs:

- uses: ./.github/actions/setup-android

# FCM client config, when the run can see secrets. Writing it here is what
# puts the google-services plugin — resource generation and its manifest
# merge, which ONLY a Firebase-enabled build exercises — under PR-time
# verification instead of discovering it in the nightly deploy.
#
# Tolerant, unlike deploy-android's copy: a pull_request from a fork gets
# no secrets, and build.gradle.kts skips the plugin when the file is
# absent, so those builds still pass — just without the Firebase half
# compiled in. Announce which of the two builds this was; a silent
# difference between CI and the release build is the whole reason push
# shipped broken.
- name: Set up FCM client config
env:
GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}
run: |
set -euo pipefail
if [ -z "${GOOGLE_SERVICES_JSON_BASE64:-}" ]; then
echo "No GOOGLE_SERVICES_JSON_BASE64 (expected on a fork PR) — building WITHOUT the FCM plugin path."
exit 0
fi
printf '%s' "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/android/app/google-services.json
echo "Wrote app/android/app/google-services.json — building WITH the FCM plugin path."

- name: Flutter analyze
working-directory: app
run: flutter analyze
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/deploy-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,31 @@ jobs:
storeFile=../upload-keystore.jks
EOF

# FCM client config for the release AAB. Without this file build.gradle.kts
# skips the google-services plugin, the app ships with no google_app_id
# resource, FirebaseMessaging.getToken() throws on every launch, and the
# phone never sends push:register — push is dead in the field and NOTHING
# in the build log says so. That is how it shipped dark; hard-fail instead
# of inheriting the silence. Only this repo releases (RELEASE_REPO gates
# the job), so an absent secret here is a misconfiguration, never a fork.
#
# No content check: the google-services plugin already refuses a malformed
# file or one with no client for the applicationId, and it reads that id
# from Gradle, so a check here would be a second copy that can drift.
# What nothing can check from this repo is that the file's project_id
# matches the relay's FCM_PROJECT_ID — that env lives in antgrid-ops. A
# mismatch mints tokens fine and fails every send as UNREGISTERED.
- name: Set up FCM client config
env:
GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}
run: |
set -euo pipefail
if [ -z "${GOOGLE_SERVICES_JSON_BASE64:-}" ]; then
echo "::error::GOOGLE_SERVICES_JSON_BASE64 is not set — this AAB would ship to the Play Store with push permanently disabled."
exit 1
fi
printf '%s' "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/android/app/google-services.json

# versionName is stamped, not read from pubspec — see the `version` job in
# build-desktop.yml for the format. VERSION_MAJOR must match the env of the
# same name there; nothing enforces it across workflow files, so bump both
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Shared packages in `packages/`: **`antgrid_relay_client`** (pure Dart relay/cryp
- **Windows build needs VS 2022+/18 with the C++ workload AND a JDK.** VS 2019's CMake 3.20 fails; VS 18 2026 with its bundled CMake 4.3.1 is measured green. The tree asks only 3.14 (`app/windows/CMakeLists.txt`), so the real floor is whatever the Windows plugins demand. The JDK is not optional: `sentry_flutter` pulls `jni`, which declares Windows support, so every configure runs `find_package(JNI)` and the link dies `LNK1104 ... jvm.lib` without one. Any full JDK works (`winget install Microsoft.OpenJDK.21` sets `JAVA_HOME` itself).
- **A failed Windows CMake configure poisons `app/build/` permanently.** The install-prefix block in `app/windows/CMakeLists.txt` sits *after* `include(flutter/generated_plugins.cmake)`, and `CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT` is true only on the FIRST configure — so one plugin error (a missing JDK will do it) leaves the prefix at `C:/Program Files/antgrid` and no later successful configure ever corrects it. Symptom is a clean compile that dies in INSTALL with "cannot create directory … Maybe need administrative privileges", which reads like a permissions problem and is not. Fix the plugin error, then delete `app/build/windows` — re-running the build alone cannot recover.
- **Windows ships only as a Store MSIX, and a packaged binary is launchable from OUTSIDE the package only if `AppxManifest.xml` declares it as an `<Application>`.** An undeclared one fails `CreateProcess` with `ERROR_ACCESS_DENIED` — surfaced by libuv as `EPERM: uv_spawn` — even though its DACL grants execute; only the app itself, which holds package identity, can spawn a sibling. Every agent's hook config names `antgrid-bridge.exe` by absolute path (`resolveHookCommand` bakes `process.execPath`), so losing that declaration kills every hook for every agent, silently and in the field. `scripts/patch-msix-manifest.ps1` adds it between `msix:build` and `msix:pack` — the `msix` package emits exactly one `<Application>` and its `execution_alias` only ever aliases the main exe — and `scripts/verify-msix-executables.ps1` gates the packed artifact. Two non-obvious parts of that declaration: an `<Application>` is **single-instance by default**, so the bridge needs `desktop4:SupportsMultipleInstances="true"` or concurrent hooks collapse into one process; and `Subsystem="console"` exists only on `uap5:AppExecutionAlias`, so the older `uap3` + `desktop:ExecutionAlias` spelling cannot carry it. `bridge/scripts/smoke-hook-binary.ts` cannot cover any of this: it runs the loose binary, which is the case that always works. **The helper must stay visible in the app list** — one `AppListEntry="none"` anywhere makes the whole package a headless app, which Store ingestion refuses without Microsoft's per-product `HeadlessAppBypass` waiver (request: storeops@microsoft.com). It fails at submission commit, minutes after a full upload, so pack and `verify-msix-executables.ps1` both pass first.
- **A Store update while the bridge host is alive permanently bricks the package.** Every child of the app inherits its Desktop AppX **silo** (measured on 26200 — children do NOT break away), so `antgrid-bridge.exe` and its whole PTY tree are silo members. The update force-kills the app, which fires neither `didRequestAppExit` nor `HostTeardownObserver`; `owner-watchdog.ts` notices ~2s later and then drains *gracefully* for up to 5s more, so members are still live while the Store destages. A silo destroyed with members leaks the package's Helium hives (`%LOCALAPPDATA%\Packages\<family>\SystemAppData\Helium\{User,UserClasses}.dat`) mounted with no owner; the next launch cannot convert a fresh job into that family's silo, fails `ERROR_SHARING_VIOLATION` (0x80070020 → AppXDeploymentServer events 215 + 208), and the shell reports "Another program is currently using this file". **Only sign-out or reboot clears it** — `Reset-AppxPackage`, `Add-AppxPackage -Register`, and service restarts all fail, and Windows' own `RepairAppRegistrationOption` retry is what fails on every launch. The hard backstop is `app/lib/launcher/windows_job_object.dart`: the app assigns each spawned host to a job with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` and never closes the handle, so the kernel sweeps the tree as it reaps the app — on force-kill too, which is the only path that matters. A nested job inside a silo IS permitted and DOES sweep grandchildren (measured). Keep the assignment BEFORE the stdin bootstrap write in `spawnHostProcess`: the host blocks on `readBootstrapPayload()` as its first act, which is the only thing making the window race-free — and it only holds while the spawned pid IS the bridge, not the `cmd.exe` of a `.cmd` `ANTGRID_AGENT_BIN`. POSIX has no equivalent that survives a SIGKILLed parent — the watchdog stays the sole backstop there.
- **A Store update while the bridge host is alive permanently bricks the package.** Every child of the app inherits its Desktop AppX **silo** (measured on 26200 — children do NOT break away), so `antgrid-bridge.exe` and its whole PTY tree are silo members. The update force-kills the app, which fires neither `didRequestAppExit` nor `HostTeardownObserver`; `owner-watchdog.ts` notices ~2s later and then drains *gracefully* for up to 5s more, so members are still live while the Store destages. A silo destroyed with members leaks the package's Helium hives (`%LOCALAPPDATA%\Packages\<family>\SystemAppData\Helium\{User,UserClasses}.dat`) mounted with no owner; the next launch cannot convert a fresh job into that family's silo, fails `ERROR_SHARING_VIOLATION` (0x80070020 → AppXDeploymentServer events 215 + 208), and the shell reports "Another program is currently using this file". **Only sign-out or reboot clears it** — `Reset-AppxPackage`, `Add-AppxPackage -Register`, and service restarts all fail, and Windows' own `RepairAppRegistrationOption` retry is what fails on every launch. The hard backstop is `app/lib/launcher/windows_job_object.dart`: the app assigns each spawned host to a job with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` and never closes the handle, so the kernel sweeps the tree as it reaps the app — on force-kill too, which is the only path that matters. A nested job inside a silo IS permitted and DOES sweep grandchildren (measured). Keep the assignment BEFORE the stdin bootstrap write in `spawnHostProcess`: the host blocks on `readBootstrapPayload()` as its first act, which is the only thing making the window race-free — and it only holds while the spawned pid IS the bridge, not the `cmd.exe` of a `.cmd` `ANTGRID_AGENT_BIN`. POSIX has no equivalent that survives a SIGKILLed parent — the watchdog stays the sole backstop there — and no tree kill either: the host is spawned `ProcessStartMode.normal`, so it leads no process group and `kill(-pid)` is a guaranteed ESRCH. `terminateTree` (`app/lib/launcher/discovery.dart`) gets its reach on POSIX by SIGTERMing first, which the bridge traps and answers by sweeping its own PTYs with `killProcessTree`, escalating to SIGKILL only after a grace; going straight to SIGKILL there orphans every child that ignores the pty hangup. `UpdateInstallController` (`app/lib/update/update_install_controller.dart`) drains the host gracefully before handing an update to the Store, but that is an improvement on the sweep, never a replacement — the job object stays the only thing standing between a mistake here and a bricked package. The app remains fully interactive for the Store's whole window (two consent dialogs, download, deploy), so the drain alone would be undone by the first `ensureHost()`; `HostController.sealSpawns()` is what makes it hold, and every path that does NOT hand the process over owes an `unsealSpawns()` — a seal nothing lifts leaves the machine unable to start any agent.
- **Relay Bun floor**: the relay requires Bun ≥ 1.3.14 for iOS direct-APNs push — Bun 1.3.10 fails the APNs sandbox TLS/ALPN handshake. Pin CI/runtime to 1.3.14+, not floating `1.3`.
- **Encryption & command-exec boundaries are non-negotiable** — see Conventions.

Expand Down
2 changes: 1 addition & 1 deletion THIRD-PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ ghostty_vte:
git:
url: https://github.com/antgrid-ai/dart_terminal.git
path: pkgs/vte/ghostty_vte
ref: 6cd393196ed301afa1d8ada7a996cc345b899b4a
ref: c262d5f2002d26b2116b2c5c943a46a63f994133
```

`ghostty_vte_flutter` and `portable_pty` are pinned to the same repository and
Expand Down
1 change: 1 addition & 0 deletions app/lib/demo/demo_transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ class DemoTransport extends BufferedAgentTransport {
// dead end this switch exists to avoid.
case 'session:stop':
case 'session:create':
case 'session:fork':
case 'session:delete':
case 'session:rename':
case 'session:archive':
Expand Down
4 changes: 4 additions & 0 deletions app/lib/design/ab_icons.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ abstract final class AbIcons {
// off from the main line" without naming a backend — the marker stands for
// every non-`main` checkout kind, not for worktrees specifically.
static const isolated = Codicon.repo_forked;
// More than one session working in one directory. The collaborative-session
// glyph, because what the marker reports is company rather than a place:
// paired against [isolated], which says a session has a workspace to itself.
static const sharedWorkspace = Codicon.live_share;
// Sign-in method glyphs. `key` rather than `lock`: the cell offers a
// credential the user supplies, not a state of being secured.
static const password = Codicon.key;
Expand Down
12 changes: 9 additions & 3 deletions app/lib/design/widgets/ab_list_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,15 @@ class _AbListRowState extends State<AbListRow> {
onShowFocusHighlight: (v) {
if (_focused != v) setState(() => _focused = v);
},
onShowHoverHighlight: (v) {
if (_hovered != v) setState(() => _hovered = v);
},
// Only tracked when it can be seen: `_hovered` feeds nothing but the
// `showHover` fill, so on a flat row — which every drawer row is — the
// setState would rebuild the whole row to identical pixels on each
// pointer crossing.
onShowHoverHighlight: widget.hoverable
? (v) {
if (_hovered != v) setState(() => _hovered = v);
}
: null,
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
Expand Down
79 changes: 69 additions & 10 deletions app/lib/launcher/discovery.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'dart:io';

import 'package:flutter/foundation.dart' show visibleForTesting;

/// Probe whether [pid] is alive without delivering a signal.
///
/// Async to avoid blocking the UI isolate during project switches —
Expand Down Expand Up @@ -43,25 +45,82 @@ Future<void> terminatePid(int pid) async {
}
}

/// How long the POSIX path waits for a SIGTERMed host to sweep its own trees
/// before escalating. Long enough for the bridge's `shutdown()` to walk its
/// terminals (measured in the low hundreds of ms), short enough that the app
/// close this backstops doesn't visibly stall on a host that is past helping.
const _posixTreeGrace = Duration(milliseconds: 1500);

/// Force-kill [pid] AND its child process tree. Unlike [terminatePid], this
/// reaps grandchildren — the host's PTYs (claude/codex/dev servers) — which a
/// plain process kill would orphan. Used as the backstop when a graceful
/// `host:shutdown` doesn't exit in time on app close.
/// `host:shutdown` doesn't exit in time on app close, and when a host from
/// another build has to be replaced.
Future<void> terminateTree(int pid) async {
try {
if (Platform.isWindows) {
await Process.run('taskkill', ['/F', '/T', '/PID', '$pid']);
} else {
// Negative pid targets the whole process group (reaps PTY grandchildren
// when the host leads its own group). killPid returns false — it does NOT
// throw — when the group doesn't exist (e.g. the host shares the app's
// group, so `pid` is not a PGID). Fall back to the bare pid on a false
// return, not just on an exception, or the host would survive entirely.
if (!Process.killPid(-pid, ProcessSignal.sigkill)) {
Process.killPid(pid, ProcessSignal.sigkill);
}
return;
}
await terminateTreePosix(pid);
} catch (_) {
// best-effort
}
}

/// The POSIX half, with the process seams injected so the escalation is
/// testable — the real one signals live pids and cannot be exercised in a unit
/// test.
///
/// **Signalling the group is not enough here, and on this spawn it reaches
/// nothing at all.** POSIX names a process GROUP, which exists only if the
/// target leads one, and nothing makes the host lead one: `HostController`
/// starts it with `ProcessStartMode.normal`, which leaves it in the app's
/// group (the bridge states the same precondition for its own children in
/// bridge/src/terminal-session.ts, and satisfies it there with `detached`). So
/// `-pid` is a reliable ESRCH and every call lands on the bare-pid fallback,
/// which reaches the host and nothing under it.
///
/// SIGTERM first is what closes that gap without changing how the host is
/// spawned — the bridge traps it (bridge/src/index.ts) and its `shutdown()`
/// runs `killProcessTree` over every terminal, which DOES name each PTY's own
/// group, because a PTY child is a session leader by construction. Escalating
/// to SIGKILL only after [grace] keeps an unconditional kill as the floor.
///
/// Changing the spawn to a detached mode would fix the group directly, but it
/// is entangled with the Windows job-object assignment and the bootstrap-write
/// ordering in `spawnHostProcess` — the two things standing between a mistake
/// there and a bricked MSIX package. This stays on the signalling side.
@visibleForTesting
Future<void> terminateTreePosix(
int pid, {
bool Function(int pid, ProcessSignal signal) send = Process.killPid,
Future<bool> Function(int pid) alive = isPidAlive,
Future<void> Function(Duration d) delay = _delayFor,
Duration grace = _posixTreeGrace,
}) async {
// killPid returns false — it does NOT throw — when no group carries this id.
// Fall back on a false return, not just on an exception, or a host that
// leads no group is never signalled at all.
if (!send(-pid, ProcessSignal.sigterm)) {
send(pid, ProcessSignal.sigterm);
}
var waited = Duration.zero;
const step = Duration(milliseconds: 100);
while (waited < grace) {
if (!await alive(pid)) return;
await delay(step);
waited += step;
}
// The loop grants the final step but never checks it, and SIGKILL against a
// pid the OS may already have reused reaches something else entirely.
if (!await alive(pid)) return;
// Past helping: take what we can reach. A host wedged badly enough to ignore
// SIGTERM was never going to sweep its own children, so orphans here are the
// floor this escalation accepts rather than something it introduces.
if (!send(-pid, ProcessSignal.sigkill)) {
send(pid, ProcessSignal.sigkill);
}
}

Future<void> _delayFor(Duration d) => Future<void>.delayed(d);
Loading
Loading