diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppLaunchState.kt b/apps/android/app/src/main/java/com/litter/android/state/AppLaunchState.kt index aa7aecf6..4da944e6 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppLaunchState.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppLaunchState.kt @@ -36,8 +36,8 @@ data class AppLaunchStateSnapshot( private const val PREFS_NAME = "litter.launchState" private const val APPROVAL_POLICY_KEY = "litter.approvalPolicy" private const val SANDBOX_MODE_KEY = "litter.sandboxMode" -private const val DEFAULT_APPROVAL_POLICY = "inherit" -private const val DEFAULT_SANDBOX_MODE = "inherit" +private const val DEFAULT_APPROVAL_POLICY = "never" +private const val DEFAULT_SANDBOX_MODE = "danger-full-access" private const val CUSTOM_PERMISSION_VALUE = "custom" class AppLaunchState(context: Context) { @@ -145,7 +145,7 @@ class AppLaunchState(context: Context) { if (threadKey != null) { permissionOverride(threadKey)?.let { permission -> permission.rawApprovalPolicy ?: askForApprovalFromWireValue(permission.approvalPolicy) - } + } ?: askForApprovalFromWireValue(snapshot.value.approvalPolicy) } else { askForApprovalFromWireValue(snapshot.value.approvalPolicy) } @@ -155,7 +155,7 @@ class AppLaunchState(context: Context) { permissionOverride(threadKey)?.let { permission -> permission.rawSandboxPolicy?.toLaunchSandboxMode() ?: sandboxModeFromWireValue(permission.sandboxMode) - } + } ?: sandboxModeFromWireValue(snapshot.value.sandboxMode) } else { sandboxModeFromWireValue(snapshot.value.sandboxMode) } @@ -164,7 +164,7 @@ class AppLaunchState(context: Context) { if (threadKey != null) { permissionOverride(threadKey)?.let { permission -> permission.rawSandboxPolicy ?: sandboxModeFromWireValue(permission.sandboxMode)?.toTurnSandboxPolicy() - } + } ?: sandboxModeFromWireValue(snapshot.value.sandboxMode)?.toTurnSandboxPolicy() } else { sandboxModeValue()?.toTurnSandboxPolicy() } @@ -203,14 +203,14 @@ class AppLaunchState(context: Context) { fun selectedApprovalPolicy(threadKey: ThreadKey? = null): String = if (threadKey != null) { - permissionOverride(threadKey)?.approvalPolicy ?: DEFAULT_APPROVAL_POLICY + permissionOverride(threadKey)?.approvalPolicy ?: snapshot.value.approvalPolicy } else { snapshot.value.approvalPolicy } fun selectedSandboxMode(threadKey: ThreadKey? = null): String = if (threadKey != null) { - permissionOverride(threadKey)?.sandboxMode ?: DEFAULT_SANDBOX_MODE + permissionOverride(threadKey)?.sandboxMode ?: snapshot.value.sandboxMode } else { snapshot.value.sandboxMode } diff --git a/apps/android/app/src/main/java/com/litter/android/state/VoiceRuntimeController.kt b/apps/android/app/src/main/java/com/litter/android/state/VoiceRuntimeController.kt index caa87237..9d6c59a6 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/VoiceRuntimeController.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/VoiceRuntimeController.kt @@ -693,7 +693,11 @@ class VoiceRuntimeController { is uniffi.codex_mobile_client.HandoffAction.SendTurn -> { try { - val payload = AppComposerPayload(text = action.transcript) + val payload = AppComposerPayload( + text = action.transcript, + approvalPolicy = appModel.launchState.approvalPolicyValue(), + sandboxPolicy = appModel.launchState.turnSandboxPolicy(), + ) appModel.startTurn( ThreadKey(serverId = action.targetServerId, threadId = action.threadId), payload, diff --git a/apps/android/app/src/main/java/com/litter/android/ui/sessions/DirectoryPickerSheet.kt b/apps/android/app/src/main/java/com/litter/android/ui/sessions/DirectoryPickerSheet.kt index 6df64f6c..6f9edb79 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/sessions/DirectoryPickerSheet.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/sessions/DirectoryPickerSheet.kt @@ -58,6 +58,7 @@ import com.litter.android.state.canBrowseDirectories import kotlinx.coroutines.launch import uniffi.codex_mobile_client.AbsolutePath import uniffi.codex_mobile_client.AppExecCommandRequest +import uniffi.codex_mobile_client.AppSandboxPolicy @Composable fun DirectoryPickerSheet( @@ -124,7 +125,7 @@ fun DirectoryPickerSheet( disableTimeout = false, timeoutMs = null, cwd = "/tmp", - sandboxPolicy = null, + sandboxPolicy = AppSandboxPolicy.DangerFullAccess, ), ) }.getOrNull() @@ -157,7 +158,7 @@ fun DirectoryPickerSheet( disableTimeout = false, timeoutMs = null, cwd = normalizedPath, - sandboxPolicy = null, + sandboxPolicy = AppSandboxPolicy.DangerFullAccess, ), ) } diff --git a/apps/ios/Sources/Litter/CarPlay/CarPlayVoiceManager.swift b/apps/ios/Sources/Litter/CarPlay/CarPlayVoiceManager.swift index b2522897..32365652 100644 --- a/apps/ios/Sources/Litter/CarPlay/CarPlayVoiceManager.swift +++ b/apps/ios/Sources/Litter/CarPlay/CarPlayVoiceManager.swift @@ -118,7 +118,7 @@ final class CarPlayVoiceManager { cwd: cwd, model: nil, approvalPolicy: .never, - sandboxMode: nil + sandboxMode: .dangerFullAccess ) if let session = voiceActions.activeVoiceSession { pushActiveSession(session) diff --git a/apps/ios/Sources/Litter/Models/AppLifecycleController.swift b/apps/ios/Sources/Litter/Models/AppLifecycleController.swift index 2956f05a..3639c43f 100644 --- a/apps/ios/Sources/Litter/Models/AppLifecycleController.swift +++ b/apps/ios/Sources/Litter/Models/AppLifecycleController.swift @@ -447,8 +447,8 @@ final class AppLifecycleController { let cwd = existing?.info.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) let config = AppThreadLaunchConfig( model: existing?.resolvedModel, - approvalPolicy: nil, - sandbox: nil, + approvalPolicy: .never, + sandbox: .dangerFullAccess, developerInstructions: nil, persistExtendedHistory: true ) diff --git a/apps/ios/Sources/Litter/Models/AppState.swift b/apps/ios/Sources/Litter/Models/AppState.swift index 0ce84f4e..8fbc744f 100644 --- a/apps/ios/Sources/Litter/Models/AppState.swift +++ b/apps/ios/Sources/Litter/Models/AppState.swift @@ -41,8 +41,8 @@ final class AppState { } init() { - approvalPolicy = UserDefaults.standard.string(forKey: Self.approvalPolicyKey) ?? "inherit" - sandboxMode = UserDefaults.standard.string(forKey: Self.sandboxModeKey) ?? "inherit" + approvalPolicy = UserDefaults.standard.string(forKey: Self.approvalPolicyKey) ?? "never" + sandboxMode = UserDefaults.standard.string(forKey: Self.sandboxModeKey) ?? "danger-full-access" } func toggleSessionFolder(_ folderPath: String) { @@ -60,13 +60,13 @@ final class AppState { func approvalPolicy(for threadKey: ThreadKey?) -> String { guard let threadKey else { return approvalPolicy } return threadPermissionOverrides[permissionKey(for: threadKey)]?.approvalPolicy - ?? Self.inheritPermissionValue + ?? approvalPolicy } func sandboxMode(for threadKey: ThreadKey?) -> String { guard let threadKey else { return sandboxMode } return threadPermissionOverrides[permissionKey(for: threadKey)]?.sandboxMode - ?? Self.inheritPermissionValue + ?? sandboxMode } func launchApprovalPolicy(for threadKey: ThreadKey?) -> AppAskForApproval? { @@ -74,7 +74,7 @@ final class AppState { return AppAskForApproval(wireValue: approvalPolicy) } guard let permissions = threadPermissionOverrides[permissionKey(for: threadKey)] else { - return nil + return AppAskForApproval(wireValue: approvalPolicy) } return permissions.rawApprovalPolicy ?? AppAskForApproval(wireValue: permissions.approvalPolicy) } @@ -84,7 +84,7 @@ final class AppState { return AppSandboxMode(wireValue: sandboxMode) } guard let permissions = threadPermissionOverrides[permissionKey(for: threadKey)] else { - return nil + return AppSandboxMode(wireValue: sandboxMode) } return permissions.rawSandboxPolicy?.launchOverrideMode ?? AppSandboxMode(wireValue: permissions.sandboxMode) @@ -95,7 +95,7 @@ final class AppState { return TurnSandboxPolicy(mode: sandboxMode)?.ffiValue } guard let permissions = threadPermissionOverrides[permissionKey(for: threadKey)] else { - return nil + return TurnSandboxPolicy(mode: sandboxMode)?.ffiValue } return permissions.rawSandboxPolicy ?? TurnSandboxPolicy(mode: permissions.sandboxMode)?.ffiValue } diff --git a/apps/ios/Sources/Litter/Models/VoiceRuntimeController.swift b/apps/ios/Sources/Litter/Models/VoiceRuntimeController.swift index 04d688dc..20e7eab5 100644 --- a/apps/ios/Sources/Litter/Models/VoiceRuntimeController.swift +++ b/apps/ios/Sources/Litter/Models/VoiceRuntimeController.swift @@ -26,6 +26,8 @@ final class VoiceRuntimeController: VoiceActions { @ObservationIgnored private var voiceOutputDecayToken: UUID? @ObservationIgnored private var voiceStopRequestedThreadKey: ThreadKey? @ObservationIgnored private var lastHandledVoiceEndRequestToken: String? + @ObservationIgnored private let fallbackApprovalPolicy = "never" + @ObservationIgnored private let fallbackSandboxMode = "danger-full-access" init() { voiceSessionCoordinator.onEvent = { [weak self] event in @@ -220,6 +222,7 @@ final class VoiceRuntimeController: VoiceActions { model: String? = nil ) async throws { let appModel = requireAppModel() + let launchPolicy = currentLaunchPolicy() syncHandoffServers() await cleanupKnownRealtimeVoiceSessions(beforeStartingOn: key) @@ -229,8 +232,8 @@ final class VoiceRuntimeController: VoiceActions { serverId: key.serverId, params: AppThreadLaunchConfig( model: model, - approvalPolicy: nil, - sandbox: nil, + approvalPolicy: launchPolicy.approvalPolicy, + sandbox: launchPolicy.sandboxMode, developerInstructions: nil, persistExtendedHistory: true ).threadResumeRequest(threadId: key.threadId, cwdOverride: nil) @@ -451,13 +454,14 @@ final class VoiceRuntimeController: VoiceActions { private func executeHandoffStartThread(handoffId: String, serverId: String, cwd: String) async { guard let appModel else { return } + let launchPolicy = currentLaunchPolicy() do { let key = try await appModel.client.startThread( serverId: serverId, params: AppThreadLaunchConfig( model: handoffModel, - approvalPolicy: nil, - sandbox: nil, + approvalPolicy: launchPolicy.approvalPolicy, + sandbox: launchPolicy.sandboxMode, developerInstructions: nil, persistExtendedHistory: true ).threadStartRequest(cwd: cwd) @@ -484,6 +488,7 @@ final class VoiceRuntimeController: VoiceActions { fastMode: Bool ) async { guard let appModel else { return } + let launchPolicy = currentLaunchPolicy() let key = ThreadKey(serverId: serverId, threadId: threadId) do { try await appModel.startTurn( @@ -491,8 +496,8 @@ final class VoiceRuntimeController: VoiceActions { payload: AppComposerPayload( text: transcript, additionalInputs: [], - approvalPolicy: nil, - sandboxPolicy: nil, + approvalPolicy: launchPolicy.approvalPolicy, + sandboxPolicy: sandboxPolicy(from: launchPolicy.sandboxMode), model: model, effort: ReasoningEffort(wireValue: effort), serviceTier: fastMode ? .fast : nil @@ -815,6 +820,40 @@ final class VoiceRuntimeController: VoiceActions { syncVoiceCallActivity() } } + + private func currentLaunchPolicy() -> (approvalPolicy: AppAskForApproval?, sandboxMode: AppSandboxMode?) { + let defaults = UserDefaults.standard + let approvalRaw = defaults + .string(forKey: "litter.approvalPolicy")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let sandboxRaw = defaults + .string(forKey: "litter.sandboxMode")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + let approval = AppAskForApproval(wireValue: approvalRaw?.isEmpty == false ? approvalRaw : fallbackApprovalPolicy) + let sandbox = AppSandboxMode(wireValue: sandboxRaw?.isEmpty == false ? sandboxRaw : fallbackSandboxMode) + return (approval, sandbox) + } + + private func sandboxPolicy(from mode: AppSandboxMode?) -> AppSandboxPolicy? { + guard let mode else { return nil } + switch mode { + case .dangerFullAccess: + return .dangerFullAccess + case .readOnly: + return .readOnly(access: .fullAccess, networkAccess: false) + case .workspaceWrite: + return .workspaceWrite( + writableRoots: [], + readOnlyAccess: .fullAccess, + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + ) + } + } } private extension VoiceRuntimeController { diff --git a/apps/ios/Sources/Litter/Views/DirectoryPickerView.swift b/apps/ios/Sources/Litter/Views/DirectoryPickerView.swift index 8a477359..7e8d7a56 100644 --- a/apps/ios/Sources/Litter/Views/DirectoryPickerView.swift +++ b/apps/ios/Sources/Litter/Views/DirectoryPickerView.swift @@ -252,7 +252,7 @@ private final class DirectoryPickerSheetModel { disableTimeout: false, timeoutMs: nil, cwd: path, - sandboxPolicy: nil + sandboxPolicy: .dangerFullAccess ) ) guard serverId == lastLoadedServerId else { return } @@ -356,7 +356,7 @@ private final class DirectoryPickerSheetModel { disableTimeout: false, timeoutMs: nil, cwd: "/tmp", - sandboxPolicy: nil + sandboxPolicy: .dangerFullAccess ) ) if response.exitCode == 0 { diff --git a/apps/ios/Sources/Litter/Views/SubagentCardView.swift b/apps/ios/Sources/Litter/Views/SubagentCardView.swift index 69d6ecb4..ce7f7205 100644 --- a/apps/ios/Sources/Litter/Views/SubagentCardView.swift +++ b/apps/ios/Sources/Litter/Views/SubagentCardView.swift @@ -450,8 +450,8 @@ private struct SubagentDetailSheet: View { key: threadKey, launchConfig: AppThreadLaunchConfig( model: nil, - approvalPolicy: nil, - sandbox: nil, + approvalPolicy: .never, + sandbox: .dangerFullAccess, developerInstructions: nil, persistExtendedHistory: true ), diff --git a/shared/rust-bridge/generate-bindings.sh b/shared/rust-bridge/generate-bindings.sh index 89785658..810a6def 100755 --- a/shared/rust-bridge/generate-bindings.sh +++ b/shared/rust-bridge/generate-bindings.sh @@ -25,6 +25,7 @@ fi PROFILE="debug" GENERATE_SWIFT=1 GENERATE_KOTLIN=1 +HOST_OS="$(uname)" for arg in "$@"; do case "$arg" in @@ -49,29 +50,31 @@ if [[ "$GENERATE_SWIFT" -eq 0 && "$GENERATE_KOTLIN" -eq 0 ]]; then exit 1 fi -# --------------------------------------------------------------------------- -# 1. Build the cdylib so uniffi-bindgen can read its metadata -# --------------------------------------------------------------------------- -echo "==> Building codex-mobile-client cdylib ($PROFILE)..." +DYLIB_FILE="" +KOTLIN_LIB_FILE="" -if [[ "$PROFILE" == "release" ]]; then - cargo build -p codex-mobile-client --release -else - cargo build -p codex-mobile-client -fi +if [[ "$GENERATE_SWIFT" -eq 1 || "$HOST_OS" == "Darwin" ]]; then + # ----------------------------------------------------------------------- + # Build host cdylib for Swift generation (and optional Kotlin on macOS) + # ----------------------------------------------------------------------- + echo "==> Building codex-mobile-client host cdylib ($PROFILE)..." + if [[ "$PROFILE" == "release" ]]; then + cargo build -p codex-mobile-client --release + else + cargo build -p codex-mobile-client + fi -DYLIB_PATH="$WORKSPACE_DIR/target/$PROFILE" + DYLIB_PATH="$WORKSPACE_DIR/target/$PROFILE" + if [[ "$HOST_OS" == "Darwin" ]]; then + DYLIB_FILE="$DYLIB_PATH/libcodex_mobile_client.dylib" + else + DYLIB_FILE="$DYLIB_PATH/libcodex_mobile_client.so" + fi -# Resolve the dynamic library name per platform -if [[ "$(uname)" == "Darwin" ]]; then - DYLIB_FILE="$DYLIB_PATH/libcodex_mobile_client.dylib" -else - DYLIB_FILE="$DYLIB_PATH/libcodex_mobile_client.so" -fi - -if [[ ! -f "$DYLIB_FILE" ]]; then - echo "ERROR: Could not find built library at $DYLIB_FILE" >&2 - exit 1 + if [[ ! -f "$DYLIB_FILE" ]]; then + echo "ERROR: Could not find built host library at $DYLIB_FILE" >&2 + exit 1 + fi fi if [[ "$GENERATE_SWIFT" -eq 1 ]]; then @@ -92,13 +95,46 @@ if [[ "$GENERATE_SWIFT" -eq 1 ]]; then fi if [[ "$GENERATE_KOTLIN" -eq 1 ]]; then + if [[ "$HOST_OS" == "Linux" ]]; then + # Linux host builds can fail when upstream pulls V8 for code-mode. + # Prefer an Android .so as UniFFI metadata source for Kotlin bindings. + if [[ -n "${UNIFFI_KOTLIN_LIBRARY:-}" ]]; then + KOTLIN_LIB_FILE="$UNIFFI_KOTLIN_LIBRARY" + else + DEFAULT_ANDROID_SO="$WORKSPACE_DIR/../../apps/android/core/bridge/src/main/jniLibs/arm64-v8a/libcodex_mobile_client.so" + if [[ -f "$DEFAULT_ANDROID_SO" ]]; then + KOTLIN_LIB_FILE="$DEFAULT_ANDROID_SO" + else + if ! command -v cargo-ndk >/dev/null 2>&1; then + echo "ERROR: cargo-ndk not found and no Android library available for Kotlin bindings" >&2 + echo "hint: install cargo-ndk or set UNIFFI_KOTLIN_LIBRARY to a built libcodex_mobile_client.so" >&2 + exit 1 + fi + if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then + echo "ERROR: ANDROID_NDK_HOME is required to build Android library for Kotlin bindings" >&2 + exit 1 + fi + echo "==> Building Android arm64-v8a cdylib for Kotlin bindings..." + cargo ndk -t arm64-v8a build -p codex-mobile-client + KOTLIN_LIB_FILE="$WORKSPACE_DIR/target/aarch64-linux-android/debug/libcodex_mobile_client.so" + fi + fi + else + KOTLIN_LIB_FILE="$DYLIB_FILE" + fi + + if [[ -z "$KOTLIN_LIB_FILE" || ! -f "$KOTLIN_LIB_FILE" ]]; then + echo "ERROR: Could not find Kotlin binding library at $KOTLIN_LIB_FILE" >&2 + exit 1 + fi + echo "==> Generating Kotlin bindings -> $OUT_KOTLIN" mkdir -p "$OUT_KOTLIN" rm -rf \ "$OUT_KOTLIN/uniffi/codex_app_server_protocol" \ "$OUT_KOTLIN/uniffi/codex_protocol" cargo run -p uniffi-bindgen -- generate \ - --library "$DYLIB_FILE" \ + --library "$KOTLIN_LIB_FILE" \ --language kotlin \ --out-dir "$OUT_KOTLIN" fi