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
13 changes: 13 additions & 0 deletions .imgbotconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"schedule": "monthly",
"minKBReduced": 50,
"prTitle": "chore(assets): lossless image compression",
"ignoredFiles": [
"**/*.xcassets/**",
"*/*.xcassets/*",
"**/Assets.xcassets/**",
"**/screenshots/**",
"*/screenshots/*",
"**/fastlane/**"
]
}
30 changes: 30 additions & 0 deletions HelperSwift/Sources/HelperKit/RemoteSessionPlane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,36 @@ public enum RemoteSessionPlane {
isEnabled && isPaired
}

/// Whether the helper may run the PRIVATE `pterm:` terminal producer.
///
/// Added 2026-09-09, after shipping that producer without this check.
///
/// `pterm:` exists to stream a terminal to the phone. This enum's own
/// docstring says the app "offers no remote sessions, terminals or
/// approvals", and the app-side consumer was removed with the rest of the
/// plane — so with the config flag on and this predicate absent, the helper
/// would have redacted, batched and POSTed a session's output to a topic
/// nothing subscribes to, for a plane both copies of this file declare
/// retired. Not a leak (the relay authorizes, and the READ policy still
/// scopes subscribers to their own sessions), but real work and real
/// egress in service of nothing.
///
/// The conjunction is the point, and it is the same shape as
/// `shouldStartCloudTask`: the ops flag can only ever turn the producer
/// OFF sooner, never on past the retirement. Un-retiring is one edit, in
/// one place, reviewed on its own — which is what the flag is for.
///
/// Lives here rather than inline in `main.swift` for the reason stated
/// above `shouldStartCloudTask`: main.swift is an executable target with no
/// test bundle, and a predicate a test cannot reach is a predicate nobody
/// has checked. That is exactly how this one shipped unchecked.
public static func shouldRunPrivateTerminalProducer(
configEnabled: Bool,
isPaired: Bool
) -> Bool {
isEnabled && configEnabled && isPaired
}

/// Why the cloud task is not running, for an operator reading the log.
/// "Retired" and "unpaired" are different facts and must not print the
/// same line — confusing them is how a real outage gets read as a config
Expand Down
12 changes: 11 additions & 1 deletion HelperSwift/Sources/cli_pulse_helper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,17 @@ case "daemon":
let pairingWithoutContainer: () -> AppGroupConfigReader.AppPairing? = { nil }

let bootCloudCfg = configStore.cloudConfigSnapshot(appGroupReader: pairingWithoutContainer)
let privateBroadcastOn = configStore.privateTerminalBroadcastEnabled
// ⛔ ANDed with the RETIREMENT, not just the ops flag. `pterm:` streams a
// terminal to the phone, and `RemoteSessionPlane` — mirrored in both
// packages with a drift test — says the app offers no remote terminals and
// the consumer was removed with the rest of that plane. Producing for it
// would be redaction, batching and egress in service of nothing.
//
// This check was MISSING when the producer shipped (#549). The flag alone
// would have turned it on.
let privateBroadcastOn = RemoteSessionPlane.shouldRunPrivateTerminalProducer(
configEnabled: configStore.privateTerminalBroadcastEnabled,
isPaired: bootCloudCfg.isPaired)
let broadcastPublisher: TerminalBroadcastPublisher?
if bootCloudCfg.isPaired && configStore.remoteRealtimeEnabled {
let provider: @Sendable () -> HelperConfigStore.CloudConfig = {
Expand Down
34 changes: 34 additions & 0 deletions HelperSwift/Tests/HelperKitTests/RemoteSessionPlaneTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,40 @@ final class RemoteSessionPlaneTests: XCTestCase {
/// "Retired" and "unpaired" are different facts. Printing the same line for
/// both is how a deliberate withdrawal gets read as a pairing problem —
/// the confidently-wrong-status class this project keeps paying for.
func test_thePrivateTerminalProducerIsGatedOnTheRETIREMENT_notJustItsOwnFlag() {
// #549 shipped the `pterm:` producer gated only on
// `remote_private_terminal_broadcast_enabled`. Flipping that flag would
// have produced terminal output for a plane this very file declares
// retired, whose consumer was removed with it.
//
// All four combinations, because the conjunction is the whole point.
for paired in [true, false] {
for cfg in [true, false] {
XCTAssertEqual(
RemoteSessionPlane.shouldRunPrivateTerminalProducer(
configEnabled: cfg, isPaired: paired),
RemoteSessionPlane.isEnabled && cfg && paired,
"cfg=\(cfg) paired=\(paired)")
}
}
// And concretely, while the plane is retired: nothing turns it on.
XCTAssertFalse(RemoteSessionPlane.shouldRunPrivateTerminalProducer(
configEnabled: true, isPaired: true),
"the ops flag must not be able to outvote the retirement")
}

func test_theProducerGateMatchesTheCloudTaskGateShape() {
// Same shape as its sibling, so un-retiring flips both together rather
// than leaving a producer running against a plane with no consumer.
for paired in [true, false] {
XCTAssertEqual(
RemoteSessionPlane.shouldRunPrivateTerminalProducer(
configEnabled: true, isPaired: paired),
RemoteSessionPlane.shouldStartCloudTask(isPaired: paired),
"with the ops flag on, the producer gate must track the cloud-task gate")
}
}

func test_theStartupNoticeDistinguishesRetiredFromUnpaired() {
let paired = RemoteSessionPlane.startupNotice(isPaired: true)
let unpaired = RemoteSessionPlane.startupNotice(isPaired: false)
Expand Down
15 changes: 11 additions & 4 deletions backend/supabase/functions/broadcast-terminal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,17 @@
// authorized to write a revoked session's topic, and this function — which
// the paragraph above calls the entire write-side boundary — would let it.
//
// Closing it means a migration adding a status/consent predicate to that
// RPC, which is owner-gated. Until then, do not describe revocation as
// server-enforced anywhere: it is client-enforced, with a server that does
// not disagree.
// HALF-CLOSED 2026-09-09 by migrate_v0.83: the RPC now also requires
// `rs.status in ('pending','running')`, so a session retired to
// status='stopped' — which is exactly what revocation posts — stops
// authorizing. Verified against real rows: the old predicate set matched 1,
// the new one matches 0.
//
// STILL OPEN: consent. `cloudShared` is an in-memory helper flag never
// mirrored to the database, so a session that was never shared but is
// running would still authorize if a helper asked. Closing that needs a
// consent column the client maintains. So revocation is now enforced on
// BOTH sides; consent is still client-only. Do not conflate them.
// The READ side is unaffected and still RLS-governed (migrate_v0.81), so
// subscribers are still restricted to their own sessions.
//
Expand Down
40 changes: 39 additions & 1 deletion backend/supabase/migrate_v0.82_r0_broadcast_insert_grant.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
-- ============================================================
-- v0.82 — the one grant v0.81 could not issue, and the policy that needs it
-- Date: 2026-09-08 · *** NOT APPLIED — REQUIRES A supabase_admin-CLASS ROLE ***
-- Date: 2026-09-08 · *** SUPERSEDED 2026-09-09 — DO NOT APPLY, DO NOT ASK ***
--
-- ╔══════════════════════════════════════════════════════════════════════╗
-- ║ READ THIS BEFORE OPENING A SUPABASE SUPPORT TICKET. Everything below ║
-- ║ is still TRUE and still worth reading for the measurements — but the ║
-- ║ ask it recommends is no longer the right one. ║
-- ╚══════════════════════════════════════════════════════════════════════╝
--
-- Three things changed on 2026-09-08/09, in the order that matters:
--
-- 1. THE WRITE PATH WAS BUILT WITHOUT THIS GRANT. The `broadcast-terminal`
-- edge function (deployed v1, 2026-09-09) takes v0.65's own recorded
-- fallback — helper → edge fn → service_role — and service_role ALREADY
-- holds INSERT on realtime.messages. Proven end to end before it shipped: a
-- service-role publish to a private topic is delivered; an anon publish
-- gets the same HTTP 202 and is silently dropped. So the grant this file
-- exists to obtain buys nothing the product needs.
--
-- 2. THE FEATURE IT SERVES IS RETIRED. `RemoteSessionPlane.isEnabled = false`
-- in three packages now (Swift helper, CLIPulseCore, and as of 2026-09-09
-- helper/remote_session_plane.py, with a drift gate across all three). The
-- app offers no remote terminals; nothing subscribes to `pterm:` on any
-- platform. Both producers are gated on that flag.
--
-- 3. THE SUPPORT ASK WAS THE EXPENSIVE PART. This file's own header calls it
-- a one-shot favour and says not to spend it before knowing the design
-- survives. It did not survive: it was replaced (1) and then retired (2).
--
-- WHAT TO DO IF THE PLANE IS EVER UN-RETIRED: start from the relay, not from
-- here. The custom-role design needs a privilege the project owner cannot
-- obtain and support may decline; the relay needs none and is already deployed
-- and tested. Re-read `broadcast-terminal/index.ts` — including its stated cost,
-- that service_role is rolbypassrls so the edge function IS the write-side
-- boundary — before reviving anything in this file.
--
-- KEPT, NOT DELETED, for the same reason the retirement was: the measurements
-- below (silent no-op GRANT, reserved membership, the NOINHERIT probe trap,
-- the definer/bypassrls trap) are the expensive part and a deleted dead end
-- gets rediscovered.
--
-- ⚠️ RUN AS `postgres` AND THIS FILE DOES NOT ERROR. It warns and does
-- nothing. The assertion block at the foot turns that silence into an
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
-- ============================================================
-- v0.83 — close the server half of R0 revoke
-- Date: 2026-09-09
--
-- ── WHAT IS WRONG ─────────────────────────────────────────────
-- `remote_helper_authorize_broadcast` is the ENTIRE write-side boundary for the
-- private terminal relay: `broadcast-terminal` runs as service_role, which is
-- rolbypassrls, so no RLS policy is consulted on that path. Its own header says
-- so, and then names this gap.
--
-- The function authorizes on exactly four predicates:
-- rs.id = p_session_id
-- rs.device_id = p_device_id
-- rs.user_id = v_user
-- rs.realtime_private is true
-- There is no status predicate and no consent column. M4.4d's `cloudShared` is
-- an IN-MEMORY helper flag never mirrored to the database, and revocation
-- (`unshareAttachedSession` -> `retireMintedRow`) only posts `status='stopped'`,
-- which this function does not read.
--
-- So after a user revokes sharing, the row survives with realtime_private=true
-- and the server keeps authorizing writes to that session's topic. Revocation
-- has been enforced entirely client-side, by a helper that could be stale,
-- buggy, or replaced.
--
-- ── WHAT THIS CHANGES ─────────────────────────────────────────
-- One predicate: the session must not be in a TERMINAL state.
--
-- and rs.status in ('pending', 'running')
--
-- Deliberately an allowlist of live states rather than `<> 'stopped'`: a future
-- terminal state (say 'errored', which `RemoteSessionStatus` already defines)
-- would otherwise keep authorizing. And deliberately including 'pending' rather
-- than requiring 'running': a helper that begins broadcasting in the window
-- between row creation and the first status post would otherwise earn a 42501,
-- which the Swift sink treats as an authoritative denial and suppresses the
-- session for a 60 s backoff. Denying a live session is a worse failure than
-- briefly admitting a pending one.
--
-- ── BLAST RADIUS, MEASURED 2026-09-09 ─────────────────────────
-- remote_sessions 3 rows
-- realtime_private = true 3
-- status 'stopped' for all 3
-- status = 'running' 0
-- So this authorizes strictly less than before and denies nothing that is
-- currently allowed-and-live: there is no live session to break. The three
-- rows it newly refuses are exactly the class this migration exists to refuse.
--
-- Two callers, both fine with the narrowing:
-- * `mint-realtime-token` — mints for a live session; a stopped one has no
-- terminal to mirror.
-- * `broadcast-terminal` — same, and it maps 42501 to 403, which the Swift
-- sink suppresses for a bounded backoff rather than permanently.
--
-- ── NOT A SUBSTITUTE FOR CONSENT ──────────────────────────────
-- This closes the REVOKE gap, not the consent gap. `cloudShared` is still not
-- in the database, so a session that was never shared but is running would
-- still authorize if a helper asked. Closing that needs a consent column the
-- helper writes, which is a bigger change and a schema the client must
-- maintain. Recorded here so the next reader does not mistake this for the
-- whole fix.
--
-- Body below is the LIVE definition read back with pg_get_functiondef on
-- 2026-09-09, plus the one predicate — not reconstructed from the repo, because
-- production function bodies drift.
--
-- Runs as ONE transaction.
-- ============================================================

create or replace function public.remote_helper_authorize_broadcast(
p_device_id uuid, p_helper_secret text, p_session_id uuid
)
returns uuid
language plpgsql
security definer
set search_path to 'pg_catalog', 'public', 'extensions'
as $function$
declare
v_user uuid;
v_owner uuid;
begin
v_user := public._remote_authenticate_helper_gated(p_device_id, p_helper_secret);
if v_user is null then
raise exception 'unauthorized' using errcode = '42501';
end if;

select rs.user_id into v_owner
from public.remote_sessions rs
where rs.id = p_session_id
and rs.device_id = p_device_id
and rs.user_id = v_user
and rs.realtime_private is true
-- v0.83: a retired/ended session must stop authorizing. Revocation posts
-- status='stopped'; without this the server kept saying yes.
and rs.status in ('pending', 'running');
if v_owner is null then
raise exception 'session not authorized for private broadcast' using errcode = '42501';
end if;

return v_owner;
end;
$function$;

-- ------------------------------------------------------------
-- In-transaction assertions. Each ABORTS, and each would have FAILED against
-- the pre-apply body measured above.
-- ------------------------------------------------------------
do $$
declare def text;
begin
select pg_get_functiondef(p.oid) into def
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'remote_helper_authorize_broadcast';

if def is null then
raise exception 'the function vanished';
end if;

-- Would have failed before: the predicate did not exist.
if def not like '%rs.status in (''pending'', ''running'')%' then
raise exception 'the status predicate is not in the deployed body';
end if;

-- The four original predicates must all survive. Narrowing is the point;
-- accidentally DROPPING one would widen authorization instead.
if def not like '%rs.realtime_private is true%'
or def not like '%rs.device_id = p_device_id%'
or def not like '%rs.user_id = v_user%'
or def not like '%rs.id = p_session_id%' then
raise exception 'an original predicate was lost — this would WIDEN authorization';
end if;

-- Shape must be unchanged: SECURITY DEFINER with a pinned search_path.
-- `create or replace` keeps these, but a future edit that retypes the header
-- could drop them silently, and this function is a write-side boundary.
if not exists (
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'remote_helper_authorize_broadcast'
and p.prosecdef
and p.proconfig::text like '%search_path=pg_catalog, public, extensions%'
) then
raise exception 'SECURITY DEFINER or the pinned search_path was lost';
end if;

-- And EXECUTE must not have widened. anon reaching this would matter: the
-- helper paths are anon-reachable by design and gated on helper_secret, so
-- this only pins that the grant set did not CHANGE under us.
if has_function_privilege('anon', 'public.remote_helper_authorize_broadcast(uuid,text,uuid)', 'EXECUTE')
is distinct from true then
raise exception 'anon EXECUTE changed — the helper path is anon-reachable by design; re-read before proceeding';
end if;
end
$$;

-- ============================================================
-- Post-apply, by hand — a green apply is not evidence:
-- select pg_get_functiondef(oid) from pg_proc
-- where proname='remote_helper_authorize_broadcast'; -- has the predicate
--
-- -- The behaviour this exists for, with a real helper_secret (owner only):
-- -- a session retired to status='stopped' must now raise 42501 where it
-- -- previously returned the owner uuid.
--
-- select count(*) filter (where status in ('pending','running')) as still_authorizable,
-- count(*) as total
-- from public.remote_sessions where realtime_private;
-- -- 2026-09-09: 0 / 3. Every private row is 'stopped', so this migration
-- -- newly refuses all three — which is the point, not a regression.
-- ============================================================
11 changes: 10 additions & 1 deletion helper/cli_pulse_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from system_collector import CollectedAlert, collect_alerts, collect_device_snapshot, collect_sessions, estimate_provider_quotas
from git_collector import GitCollector, project_paths_from_sessions
import user_secret as _user_secret_module
from remote_session_plane import should_run_terminal_broadcast

logger = logging.getLogger("cli_pulse.helper")

Expand Down Expand Up @@ -627,8 +628,16 @@ def daemon(args: argparse.Namespace) -> None:
# broadcasts, zero edge-fn calls). Even on, only PRIVATE sessions
# broadcast: the local gate in `_post_stdout_chunk` skips public/unknown
# sessions entirely, and the mint edge fn denies public.
# ⛔ ANDed with the RETIREMENT, not just the ops flag. `pterm:` streams a
# terminal to a plane that was withdrawn in #499-#514; nothing
# subscribes to that topic on any platform, and this producer's own
# write path (v0.65's direct mint+POST) has been refused by Realtime
# since 2026-08-30 because `r0_broadcast` holds no INSERT — invisibly,
# since the endpoint answers 202 either way. See remote_session_plane.py.
broadcast_publisher = None
if getattr(config_for_manager, "remote_realtime_broadcast_enabled", False):
if should_run_terminal_broadcast(
getattr(config_for_manager, "remote_realtime_broadcast_enabled", False)
):
try:
from realtime_broadcast import ( # type: ignore
RealtimeBroadcastSink,
Expand Down
Loading
Loading