Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
bb655be
feat(pcloud): add CloudBackupProvider protocol contract
ahmedcompk Oct 3, 2026
aa816ec
feat(pcloud): add PCloudUserSettings data models
ahmedcompk Oct 3, 2026
a48959c
feat(pcloud): implement PCloudClient with multi-region US/EU routing
ahmedcompk Oct 3, 2026
152c672
test(pcloud): add 10 hermetic unit tests for pcloud client and contract
ahmedcompk Oct 3, 2026
f370923
feat(pcloud): add requirements.txt for pcloud app
ahmedcompk Oct 3, 2026
2a82c85
docs(pcloud): add README documentation for pCloud backup app
ahmedcompk Oct 3, 2026
70182fa
fix(pcloud): pin exact dependency versions matching omi app standards
ahmedcompk Oct 3, 2026
c1e4a49
fix(pcloud): enforce Literal[1, 2] validation on location_id
ahmedcompk Oct 3, 2026
930630e
fix(pcloud): sanitize folder components, default overwrite to True, p…
ahmedcompk Oct 3, 2026
3e98ee0
fix(pcloud): align upload_file overwrite default in CloudBackupProvid…
ahmedcompk Oct 3, 2026
919cf71
test(pcloud): add outgoing mock assertions, test overwrite idempotenc…
ahmedcompk Oct 3, 2026
2300078
docs(pcloud): wrap README lines to <= 88 chars
ahmedcompk Oct 3, 2026
bcff5a9
fix(pcloud): validate location_id in constructor and remove sys.path …
ahmedcompk Oct 3, 2026
a6431ea
test(pcloud): add test for PCloudClient constructor ValueError on inv…
ahmedcompk Oct 3, 2026
3ce5647
style(pcloud): format with black 120
ahmedcompk Oct 4, 2026
5497b20
style(pcloud): format with black 120
ahmedcompk Oct 4, 2026
14d072c
fix(pcloud): sanitize string folder_ref in upload_file and format bla…
ahmedcompk Oct 4, 2026
9341d69
test(pcloud): test string folder_ref sanitization and format black 120
ahmedcompk Oct 4, 2026
695915e
fix(pcloud): restore ValueError constructor validation and remove sys…
ahmedcompk Oct 5, 2026
29f4b3f
test(pcloud): restore constructor ValueError assertions in test_multi…
ahmedcompk Oct 5, 2026
6bae145
docs(pcloud): soften architecture description regarding provider impl…
ahmedcompk Oct 5, 2026
6090f44
style(pcloud): strip trailing blank line at EOF for diff-hygiene
ahmedcompk Oct 6, 2026
1d9a628
feat(pcloud): add turnkey FastAPI service, encrypted storage, and con…
ahmedcompk Oct 6, 2026
2f8a42c
fix(pcloud): Starlette response compatibility, fail-fast crypto key, …
ahmedcompk Oct 7, 2026
0e44c35
fix(pcloud): unwrap Query default sentinels in callback coroutine inv…
ahmedcompk Oct 8, 2026
b1ea9e9
style(pcloud): drop unused tenacity and restore pure framework typing…
ahmedcompk Oct 9, 2026
75b106a
Merge pCloud foundation from #20498 for safety completion
aryanorastar Oct 20, 2026
9bf631c
fix(backups): bind pCloud exports to verified account and durable rec…
aryanorastar Oct 20, 2026
bbace15
fix(backups): preserve completed receipts when disconnecting
aryanorastar Oct 20, 2026
f56e6ce
fix(backups): fresh launch grants, expiry recovery, canonical summary…
aryanorastar Oct 20, 2026
3747b15
chore(app): changelog for fresh app-home launch grants
aryanorastar Oct 20, 2026
dcb0bc2
test(backend): parse the shipped route manifest once per module
aryanorastar Oct 20, 2026
9514483
fix(pcloud): fresh launch grants on every client, folder follow, dura…
aryanorastar Oct 21, 2026
c0f3361
Merge origin/main into pCloud backup safety
aryanorastar Oct 23, 2026
c43b217
fix(pcloud): refresh setup capabilities and fence client handoffs
aryanorastar Oct 23, 2026
7f34a3c
docs(backend): keep plugin guidance within the component budget
aryanorastar Oct 23, 2026
9deac74
docs: pin the removed splash reference to its measured snapshot
aryanorastar Oct 23, 2026
1375090
fix(pcloud): disconnect retires pending OAuth; Windows Install uses f…
aryanorastar Oct 23, 2026
df89f72
Merge origin/main into fix/pcloud-backup-safety
aryanorastar Oct 24, 2026
1f18e24
Merge origin/main into fix/pcloud-backup-safety
aryanorastar Oct 24, 2026
922986c
Merge origin/main into fix/pcloud-backup-safety; restore the backend …
aryanorastar Oct 25, 2026
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
5 changes: 5 additions & 0 deletions .github/checks-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
# Checks that consume PR metadata declare `requires_pr_body: true`; post-merge
# push runs exclude only that category because no authoritative PR body is available.
checks:
- id: pcloud-backup-contract-tests
command: ["bash", "scripts/run-plugin-pcloud-tests.sh"]
triggers: ["plugins/omi-pcloud-app/**", "plugins/omi-plugin-sdk/**", "backend/utils/plugin_transport.py", "backend/utils/plugin_backup_audio.py", "scripts/run-plugin-pcloud-tests.sh", ".github/checks-manifest.yaml"]
lanes: ["local", "ci"]
reason: "#20498 passed unrelated checks while UID-only relinking, default-on private export and destructive upload retries escaped its direct-coroutine tests; exercise real installed HTTP routing, encrypted durable state, backend-issued grants and failure recovery"
- id: batch-policy-boundary
command: ["python3", "backend/config/policy/guard.py"]
triggers: ["backend/**/*.py", "config/feature-flags.yaml", ".github/checks-manifest.yaml"]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"change": "Opening an installed app's settings page now always uses a fresh sign-in link, so backup apps like pCloud no longer open to an expired session"
}
39 changes: 29 additions & 10 deletions app/lib/pages/apps/app_detail/app_detail.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:async';

Check warning on line 1 in app/lib/pages/apps/app_detail/app_detail.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/pages/apps/app_detail/app_detail.dart is 999 lines; consider splitting files over 800 lines.

import 'package:omi/utils/error_message.dart';
import 'package:omi/utils/platform/platform_manager.dart';
Expand Down Expand Up @@ -48,6 +48,7 @@
final Future<(bool, String)> Function(String) enableApp;
final Future<Map<String, dynamic>?> Function(String) appDetailsLoader;
final Future<bool> Function(String?) setupChecker;
final void Function(BuildContext context, App app) openAppHome;

const AppDetailPage({
super.key,
Expand All @@ -56,8 +57,11 @@
this.enableApp = enableAppServer,
this.appDetailsLoader = getAppDetailsServer,
this.setupChecker = isAppSetupCompleted,
this.openAppHome = _routeToAppHome,
});

static void _routeToAppHome(BuildContext context, App app) => routeToPage(context, AppHomeWebPage(app: app));

@override
State<AppDetailPage> createState() => _AppDetailPageState();
}
Expand Down Expand Up @@ -224,12 +228,16 @@
);

WidgetsBinding.instance.addPostFrameCallback((_) async {
// Automatically open app home page if conditions are met
if (!widget.preventAutoOpenHomePage && app.enabled && app.externalIntegration?.appHomeUrl?.isNotEmpty == true) {
routeToPage(context, AppHomeWebPage(app: app));
// Load details first: a transport-backed app's home URL carries a one-use
// launch grant that only a fresh details response contains.
final refreshed = await _refreshAppDetails();
if (!mounted) return;
if (refreshed &&
!widget.preventAutoOpenHomePage &&
app.enabled &&
app.externalIntegration?.appHomeUrl?.isNotEmpty == true) {
widget.openAppHome(context, app);
}
// Load details
await _refreshAppDetails();
});
if (app.worksExternally()) {
checkSetupCompleted();
Expand All @@ -239,7 +247,8 @@
super.initState();
}

Future<void> _refreshAppDetails() async {
/// Returns whether fresh details were applied.
Future<bool> _refreshAppDetails() async {
setIsLoading(true);
var res = await context.read<AppProvider>().getAppDetails(app.id);
if (mounted) {
Expand All @@ -258,6 +267,18 @@
_loadSubscriptionData();
}
}
return res != null;
}

/// Opens the app's home page with a launch grant fetched for this open. A grant
/// is one-use and short-lived, so a catalog copy or an earlier response is never
/// reused; if the fetch fails the current details are opened as before.
Future<void> _openAppHome() async {
final fresh = await context.read<AppProvider>().getAppDetails(app.id);
if (!mounted) return;
if (fresh != null) setState(() => app = fresh);
if (app.externalIntegration?.appHomeUrl?.isNotEmpty != true) return;
widget.openAppHome(context, app);
}

void _onExternalIntegrationUpdated() {
Expand Down Expand Up @@ -363,7 +384,7 @@
}

@override
Widget build(BuildContext context) {

Check warning on line 387 in app/lib/pages/apps/app_detail/app_detail.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

Widget build(BuildContext context) is 292 lines; consider extracting focused helpers over 150 lines.
// Watch for changes to the app in AppProvider and update local state
return Consumer<AppProvider>(
builder: (context, appProvider, child) {
Expand Down Expand Up @@ -401,7 +422,7 @@
OmiIconButton.filled(
icon: const FaIcon(FontAwesomeIcons.gear, size: 16),
label: l10n.appSettingsLabel(app.name.decodeString),
onPressed: () => routeToPage(context, AppHomeWebPage(app: app)),
onPressed: _openAppHome,
),
if (!isLoading && !app.private)
Builder(
Expand Down Expand Up @@ -838,9 +859,7 @@
// Automatically open app home page after installation if available
if (app.externalIntegration?.appHomeUrl?.isNotEmpty == true) {
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
routeToPage(context, AppHomeWebPage(app: app));
}
if (mounted) _openAppHome();
});
}
}
Expand Down
74 changes: 74 additions & 0 deletions app/test/widgets/app_install_flow_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
bool? get useWebAuth => false;
}

void main() {

Check warning on line 87 in app/test/widgets/app_install_flow_test.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

void main() is 366 lines; consider extracting focused helpers over 150 lines.
setUpAll(() {
Env.init(const _EnvFields());
PlatformManager.initializeForLocalHarness();
Expand Down Expand Up @@ -375,4 +375,78 @@
expect(enableCalls, 0, reason: 'missing payment link must not attempt an unpaid install');
expect(find.text('Invalid payment URL'), findsOneWidget);
});

group('app home with a one-use launch grant', () {
App homeApp(String homeUrl, {bool enabled = true}) => App(
id: 'app-1',
name: 'Backups',
author: 'Omi',
description: 'Backs up conversations',
image: '',
capabilities: {'external_integration'},
status: 'approved',
category: 'productivity',
approved: true,
ratingCount: 0,
enabled: enabled,
deleted: false,
isPaid: false,
isUserPaid: false,
externalIntegration: ExternalIntegration(appHomeUrl: homeUrl),
);

Future<List<String>> pumpHome(WidgetTester tester, App catalog, App fresh,
{bool preventAutoOpen = false, Future<(bool, String)> Function(String)? enableApp}) async {
final opened = <String>[];
await tester.pumpWidget(MultiProvider(
providers: [
ChangeNotifierProvider<AppProvider>.value(value: _FakeAppProvider(fresh)),
ChangeNotifierProvider<AddAppProvider>.value(value: AddAppProvider()),
ChangeNotifierProvider<MessageProvider>.value(value: MessageProvider()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: const [Locale('en')],
home: AppDetailPage(
app: catalog,
preventAutoOpenHomePage: preventAutoOpen,
enableApp: enableApp ?? (_) async => (true, ''),
appDetailsLoader: (_) async => null,
setupChecker: (_) async => false,
openAppHome: (_, app) => opened.add(app.externalIntegration!.appHomeUrl!),
),
),
));
await settle(tester);
return opened;
}

testWidgets('auto-open uses the freshly fetched grant, never the catalog URL', (tester) async {
final opened = await pumpHome(
tester, homeApp('https://plugin.test/home'), homeApp('https://plugin.test/home?omi_token=fresh-1'));
expect(opened, ['https://plugin.test/home?omi_token=fresh-1']);
});

testWidgets('the settings button fetches a new grant for each open', (tester) async {
final opened = await pumpHome(tester, homeApp('https://plugin.test/home?omi_token=cached'),
homeApp('https://plugin.test/home?omi_token=fresh-2'),
preventAutoOpen: true);
expect(opened, isEmpty);
await tester.tap(find.byTooltip('Backups settings'));
await settle(tester);
expect(opened, ['https://plugin.test/home?omi_token=fresh-2']);
});

testWidgets('opening after install uses a fresh grant', (tester) async {
final opened = await pumpHome(tester, homeApp('https://plugin.test/home', enabled: false),
homeApp('https://plugin.test/home?omi_token=fresh-3', enabled: false),
preventAutoOpen: true);
await tester.tap(find.text('Enable'));
await settle(tester);
await tester.tap(find.text('Enable').last); // the data-access consent dialog
await tester.pump(const Duration(seconds: 2));
await settle(tester);
expect(opened, ['https://plugin.test/home?omi_token=fresh-3']);
});
});
}
4 changes: 4 additions & 0 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ backend/

## Service Map

Authenticated standalone-plugin backup handoff and its source-only rollout boundary:
[`docs/plugin-transport.md`](docs/plugin-transport.md). The pCloud service owns its
durable export jobs, not Omi conversation/audio identity.

```
Shared: Firestore, Redis

Expand Down
10 changes: 10 additions & 0 deletions backend/docs/listen_pusher_pipeline.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,16 @@ retains the pending item for reconnect replay; buffers are cleared only after th
send completes. Finalization opcode `104` carries the durable job id and dispatch
generation, and Pusher claims that Firestore-owned job before processing.

The opt-in pCloud backup completion (#20213/#20498) uses finalized conversation
fanout, not the live UID-only raw-audio webhook. Registered transports sign the
account, destination path and redacted payload, preserving existing idempotency
and SSRF pinning. The standalone service acknowledges only durable admission,
then exports consented files through leased, per-file-checkpointed jobs. Audio
reads use canonical saved-audio identity and recheck account/app/conversation
access; no independent buffer may guess conversation boundaries or retain a
discarded recording for the next conversation. Architecture, unprovisioned
runtime bindings and release checklist: [`plugin-transport.md`](plugin-transport.md).

After a finalization lease is acquired, `finalizer.py` may admit a Parakeet
**shadow** pass before canonical processing. Admission schedules a tracked task
on the finalizer host: the pusher GKE pod for live WebSocket handoff, or
Expand Down
93 changes: 93 additions & 0 deletions backend/docs/plugin-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Authenticated standalone-plugin transport

Owner: `utils/plugin_transport.py`. First consumer: the pCloud completion of
#20498; delivery problem and acceptance are tracked in #20213.

## Authority and composition

Authenticated `GET /v1/apps/{app_id}` decorates a deep copy of the app's setup,
home and completion URLs with per-account capabilities. Cached/public marketplace
records never receive them; owner edits strip launch credentials before storage.
The existing Flutter/macOS setup flows already use these fields and append the
same UID hint, so no new client-specific auth channel or mobile-only state store
is introduced. The plugin consumes setup nonces atomically and uses an HttpOnly
session plus CSRF-protected POST forms for mutations. A query UID never authorizes.
A launch grant is one-use and short-lived, so every client fetches fresh details
immediately before each home-page open instead of reusing a catalog or earlier
response: the Flutter app detail page (auto-open, the settings button and
post-install), the macOS and Windows app sheets, and the web app detail page.

Finalized conversation fanout preserves SSRF target pinning, privacy redaction,
the existing durable idempotency key and legacy apps. Only a registered app gets
a signed delivery header and conversation-scoped saved-audio capability. Its
existing webhook URL must match the trusted registration origin; metadata edits
cannot redirect a capability to a different host. Re-enabling an auto-disabled
registered app checks that origin and probes the service's non-mutating
`GET /health`, because the authenticated delivery endpoint rightly rejects the
unsigned empty `POST` legacy webhooks receive; deliveries stay signed.

The plugin durably admits a job before acknowledging delivery. Its persistent
store owns idempotency, per-file receipts, retry state, consent and connection
generation. It is not a new Omi conversation or audio source of truth. It reads
canonical saved audio through the existing playback path instead of attempting
to join uncorrelated live raw-audio callbacks. Missing audio is explicit.

`HEAD /v1/apps/{app_id}/backup-audio/{conversation_id}` rechecks current account
deletion, enabled-app availability and retained/unlocked/non-discarded conversation
ownership before a pending provider write. It does not inspect or enqueue audio.
`GET` applies the same checks and delegates the saved-audio manifest to
`utils/sync/playback.py`; it never accepts a caller-provided UID or download URL.
These are internal plugin-protocol endpoints, excluded from released-client SDKs.

## Wire protocol

Per-app HS256 JWTs use issuer `omi-plugin-transport/v1`, audience = app ID,
`sub` = authenticated UID, `iat`, `exp`, random `jti` and an exact `scope`:

| Scope | Lifetime | Binding |
| --- | --- | --- |
| `setup` | 10 minutes, consumed once | Registered HTTPS service and account |
| `status` | 10 minutes, repeatable read | Connected-state boolean for that account |
| `delivery` | 10 minutes | Account, POST path, canonical JSON SHA-256 |
| `audio` | 7 days maximum | Account and one conversation; fresh access checked on HEAD/GET |

Setup/status URL parameter: `omi_token`. Delivery header:
`X-Omi-Plugin-Authorization` (raw JWT). Canonical JSON is UTF-8 encoding of
`json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=True,
allow_nan=False)`. The signed payload includes `_omi_backup_audio_token`.
Audio/access reads use `Authorization: Bearer <audio-scoped JWT>`.

The pCloud verifier is independently deployed and uses the same explicit wire
contract, tested against the actual backend signer. The models-only plugin SDK
is not expanded into an auth/worker framework. No unsigned fallback is accepted
by this new service. Old unregistered apps retain their existing behavior; this
does not retroactively secure all other plugins.

## Configuration and rollout boundary

`OMI_PLUGIN_TRANSPORT_JSON` is a **server-only secret**, mapping registered app IDs
to `base_url` and a distinct random `key` of at least 32 bytes. No value is
provisioned here. The pCloud service gets only its own app ID and key. Existing
backend runtime releases do not acquire a new required secret from this PR.
Before enabling a registration, its optional runtime binding must be reviewed
and provisioned across every host that executes finalized conversation fanout.
That deployment/configuration work is intentionally not performed by this
source-only task. The completion PR remains draft until the release checklist
is satisfied; do not merge an unsigned foundation first as a workaround.

Do not log capability-bearing URLs, header values, provider error bodies,
credentials or private payloads. The standalone app/proxy disable query access
logs. Signing-key rotation requires coordinated admission pause and handling of
outstanding jobs; encryption-key rotation is separate and tested in the plugin.

Architecture/implementation checklist and durable-storage topology:
[`plugins/omi-pcloud-app/README.md`](../../plugins/omi-pcloud-app/README.md).

## Verification ownership

The manifest-backed pCloud suite exercises real HTTP routing, cookie/CSRF/expiry,
body/account binding, durable partial success, consent, revocation and key rotation.
Backend tests exercise metadata copying, registered-origin restrictions, actual
fanout composition, legacy behavior and current access checks. These are
hermetic; live pCloud grant/callback and volume recovery verification belong to
the deploying operator. No live verification is implied by mocked provider IO.
2 changes: 2 additions & 0 deletions backend/docs/route_policy_manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ On pull requests, CI compares this file with the target branch copy and fails if
6. Use `review_status: exempt` only with an `exempt_reason`.

Keep declared policy separate from observed evidence. Dependency names, OpenAPI tags, timeout override hints, and endpoint modules are generated to help review, but they are not a substitute for owner-reviewed policy.

`plugin_capability` denotes the opt-in plugin transport's app/account/conversation-scoped HS256 bearer, not a Firebase or Google OIDC token. The audio handler also checks current account, installation and conversation eligibility; see [plugin transport](plugin-transport.md).
4 changes: 4 additions & 0 deletions backend/route_policy_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
schema_version: 1
service: backend-main
routes:
# Exact app/account/conversation-scoped HS256 capability, followed by current
# account, installation and conversation eligibility checks in the handler.
- {route_type: http, method: GET, path: "/v1/apps/{app_id}/backup-audio/{conversation_id}", policy: {review_status: reviewed, auth: {mechanisms: [plugin_capability], placement: handler, scopes: [audio]}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: developer_api, visibility: internal, data_domain: sync_audio, deprecation: {state: active}, owner: backend}}
- {route_type: http, method: HEAD, path: "/v1/apps/{app_id}/backup-audio/{conversation_id}", policy: {review_status: reviewed, auth: {mechanisms: [plugin_capability], placement: handler, scopes: [audio]}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: developer_api, visibility: internal, data_domain: sync_audio, deprecation: {state: active}, owner: backend}}
- {route_type: http, method: POST, path: /v2/cap-hold-recovery/sweep, policy: {review_status: reviewed, auth: {mechanisms: [service_oidc], placement: dependency, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: sync_job, surface: internal_task, visibility: internal, data_domain: sync_audio, deprecation: {state: active}, owner: backend}}
- route_type: http
method: POST
Expand Down
Loading
Loading