diff --git a/Cargo.lock b/Cargo.lock index f054ff0..838301f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,6 +1090,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-agent-contract-test" +version = "0.1.0" +dependencies = [ + "libc", + "tempfile", +] + +[[package]] +name = "core-dev-credential-protocol-test" +version = "0.1.0" +dependencies = [ + "zeroize", +] + [[package]] name = "core-foundation" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 6b8465c..b63ca8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ "crates/android-provider-acceptance", "crates/android-system-authority", "crates/compositor-control-protocol", + "crates/core-agent-contract-test", + "crates/core-dev-credential-protocol-test", "crates/experience-host-protocol", "crates/experience-ir", "crates/linux-input-method", diff --git a/aosp/device/sos/a33x/Android.bp b/aosp/device/sos/a33x/Android.bp index 5d5105d..9803bca 100644 --- a/aosp/device/sos/a33x/Android.bp +++ b/aosp/device/sos/a33x/Android.bp @@ -55,6 +55,20 @@ cc_binary { ], } +// Packaged only by the explicit Core development-credential product switch. +// It has one stdin-only protocol and never accepts credential bytes in argv. +cc_binary { + name: "sos-core-dev-credential", + srcs: ["core/dev_credential_client.cpp"], + compile_multilib: "64", + system_ext_specific: true, + cflags: [ + "-Wall", + "-Werror", + "-Wextra", + ], +} + cc_binary { name: "sos-core-platform-adapter", stem: "sos-core-platform", @@ -230,6 +244,20 @@ cc_prebuilt_binary { }, } +// Packaged only by Core-dev. Its distinct executable label enters the sole +// agent domain permitted to reach the fixed adb-reverse loopback port. +cc_prebuilt_binary { + name: "sos-node-core-dev", + srcs: ["prebuilts/arm64/sos-node"], + stem: "sos-node-core-dev", + compile_multilib: "64", + check_elf_files: false, + system_ext_specific: true, + strip: { + none: true, + }, +} + cc_prebuilt_library_shared { name: "sos-node-cxx-shared", srcs: ["prebuilts/arm64/libc++_shared.so"], @@ -250,6 +278,17 @@ prebuilt_etc { system_ext_specific: true, } +// Core-dev uses a distinct immutable path. Keeping the module output and +// install identity distinct prevents Soong from emitting two Make recipes for +// one destination even when only one module belongs to the selected product. +prebuilt_etc { + name: "sos-agent-runner-core-dev", + src: "prebuilts/sos-agent/agent-runner-core-dev.cjs", + filename: "agent-runner-core-dev.cjs", + sub_dir: "sos-agent", + system_ext_specific: true, +} + prebuilt_etc { name: "sos-agent-experience-api", src: "prebuilts/sos-agent/experience-api.md", diff --git a/aosp/device/sos/a33x/AndroidProducts.mk b/aosp/device/sos/a33x/AndroidProducts.mk index 18ce9ff..7bdd4d3 100644 --- a/aosp/device/sos/a33x/AndroidProducts.mk +++ b/aosp/device/sos/a33x/AndroidProducts.mk @@ -3,4 +3,10 @@ PRODUCT_MAKEFILES := \ $(LOCAL_DIR)/lineage_sos_compat_a33x.mk \ $(LOCAL_DIR)/lineage_sos_core0b_a33x.mk \ $(LOCAL_DIR)/lineage_sos_core1_a33x.mk \ + $(LOCAL_DIR)/lineage_sos_core1_dev_a33x.mk \ $(LOCAL_DIR)/lineage_sos_core_a33x.mk + +# The credential-bearing product is deliberately exposed only as a +# debuggable lunch choice. Its product makefile rejects a user build too. +COMMON_LUNCH_CHOICES += \ + lineage_sos_core1_dev_a33x-userdebug diff --git a/aosp/device/sos/a33x/core/dev_credential_client.cpp b/aosp/device/sos/a33x/core/dev_credential_client.cpp new file mode 100644 index 0000000..2d59dfe --- /dev/null +++ b/aosp/device/sos/a33x/core/dev_credential_client.cpp @@ -0,0 +1,308 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "dev_credential_protocol_v1.h" + +namespace { + +constexpr char kSocketName[] = "sos_core_dev_credential_v1"; +constexpr std::array kMagic{ + SOS_CORE_DEV_V1_MAGIC_0, SOS_CORE_DEV_V1_MAGIC_1, SOS_CORE_DEV_V1_MAGIC_2, + SOS_CORE_DEV_V1_MAGIC_3}; +constexpr uint8_t kVersion = SOS_CORE_DEV_V1_VERSION; +constexpr uint8_t kProbe = SOS_CORE_DEV_V1_OP_PROBE; +constexpr uint8_t kSet = SOS_CORE_DEV_V1_OP_SET; +constexpr uint8_t kClear = SOS_CORE_DEV_V1_OP_CLEAR; +constexpr uint8_t kStatus = SOS_CORE_DEV_V1_OP_STATUS; +constexpr uint8_t kAgentSmoke = SOS_CORE_DEV_V1_OP_AGENT_SMOKE; +constexpr uint8_t kOk = SOS_CORE_DEV_V1_STATUS_OK; +constexpr uint8_t kWrongPeer = SOS_CORE_DEV_V1_STATUS_WRONG_PEER; +constexpr uint8_t kProtocolMismatch = SOS_CORE_DEV_V1_STATUS_PROTOCOL_MISMATCH; +constexpr uint8_t kConfigured = SOS_CORE_DEV_V1_STATUS_CONFIGURED; +constexpr uint8_t kEmpty = SOS_CORE_DEV_V1_STATUS_EMPTY; +constexpr size_t kMinimumCredentialBytes = 20; +constexpr size_t kMaximumCredentialBytes = SOS_CORE_DEV_V1_MAX_PAYLOAD_BYTES; +constexpr std::string_view kOpenRouterPrefix = "sk-or-v1-"; +constexpr int kTimeoutMilliseconds = 2000; +constexpr size_t kRequestHeaderBytes = SOS_CORE_DEV_V1_REQUEST_HEADER_BYTES; +constexpr size_t kAckBytes = SOS_CORE_DEV_V1_ACK_BYTES; + +static_assert(kMagic == std::array{'S', 'O', 'S', 'K'}); +static_assert(kRequestHeaderBytes == 8); +static_assert(kAckBytes == 6); +static_assert(kMaximumCredentialBytes <= UINT16_MAX); + +class SecretBuffer { +public: + ~SecretBuffer() { memset_explicit(bytes.data(), 0, bytes.size()); } + + std::array bytes{}; + size_t size = 0; +}; + +bool writeAll(int fd, const void *data, size_t size) { + const auto *cursor = static_cast(data); + while (size > 0) { + size_t chunk = size; +#ifdef SOS_CORE_DEV_CREDENTIAL_TEST_MAX_IO_BYTES + chunk = std::min( + chunk, static_cast(SOS_CORE_DEV_CREDENTIAL_TEST_MAX_IO_BYTES)); +#endif + const ssize_t written = write(fd, cursor, chunk); + if (written < 0 && errno == EINTR) + continue; + if (written <= 0) + return false; + cursor += written; + size -= static_cast(written); + } + return true; +} + +bool readAll(int fd, void *data, size_t size) { + auto *cursor = static_cast(data); + while (size > 0) { + size_t chunk = size; +#ifdef SOS_CORE_DEV_CREDENTIAL_TEST_MAX_IO_BYTES + chunk = std::min( + chunk, static_cast(SOS_CORE_DEV_CREDENTIAL_TEST_MAX_IO_BYTES)); +#endif + const ssize_t received = read(fd, cursor, chunk); + if (received < 0 && errno == EINTR) + continue; + if (received <= 0) + return false; + cursor += received; + size -= static_cast(received); + } + return true; +} + +bool waitFor(int fd, short events) { + pollfd descriptor{fd, events, 0}; + int result; + do { + result = poll(&descriptor, 1, kTimeoutMilliseconds); + } while (result < 0 && errno == EINTR); + return result == 1 && (descriptor.revents & events) != 0 && + (descriptor.revents & (POLLERR | POLLNVAL)) == 0; +} + +bool readCredential(SecretBuffer *secret) { + for (;;) { + uint8_t byte = 0; + const ssize_t received = read(STDIN_FILENO, &byte, 1); + if (received < 0 && errno == EINTR) + continue; + if (received == 0 || byte == '\n') + break; + if (secret->size == secret->bytes.size()) + return false; + secret->bytes[secret->size++] = byte; + } + if (secret->size > 0 && secret->bytes[secret->size - 1] == '\r') + --secret->size; + if (secret->size < kMinimumCredentialBytes || + secret->size > kMaximumCredentialBytes || + secret->size < kOpenRouterPrefix.size() || + memcmp(secret->bytes.data(), kOpenRouterPrefix.data(), + kOpenRouterPrefix.size()) != 0) { + return false; + } + for (size_t index = 0; index < secret->size; ++index) { + if (secret->bytes[index] < 0x21 || secret->bytes[index] > 0x7e) + return false; + } + return true; +} + +int connectEndpoint() { + const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + timeval timeout{2, 0}; + if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) != 0 || + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)) != 0) { + close(fd); + return -1; + } + sockaddr_un address{}; + address.sun_family = AF_UNIX; + address.sun_path[0] = '\0'; + static_assert(sizeof(kSocketName) < sizeof(address.sun_path)); + memcpy(address.sun_path + 1, kSocketName, sizeof(kSocketName) - 1); + const socklen_t addressLength = + offsetof(sockaddr_un, sun_path) + sizeof(kSocketName); + if (connect(fd, reinterpret_cast(&address), + addressLength) != 0) { + close(fd); + return -1; + } + return fd; +} + +enum class ExchangeResult { + kOk, + kEndpointUnavailable, + kShortIo, + kWrongPeer, + kBadMagic, + kBadVersion, + kProtocolMismatchStatus, + kBadStatus, + kRequestRejected, + kConfigured, + kEmpty, +}; + +ExchangeResult exchangeOnFd(int fd, uint8_t operation, + const SecretBuffer &secret) { + std::array request{}; + std::copy(kMagic.begin(), kMagic.end(), request.begin()); + request[4] = kVersion; + request[5] = operation; + request[6] = static_cast((secret.size >> 8) & 0xff); + request[7] = static_cast(secret.size & 0xff); + if (secret.size > 0) { + memcpy(request.data() + kRequestHeaderBytes, secret.bytes.data(), + secret.size); + } + const size_t requestBytes = kRequestHeaderBytes + secret.size; + const bool sent = waitFor(fd, POLLOUT) && + writeAll(fd, request.data(), requestBytes) && + shutdown(fd, SHUT_WR) == 0; + memset_explicit(request.data(), 0, request.size()); + std::array response{}; + const bool received = sent && waitFor(fd, POLLIN) && + readAll(fd, response.data(), response.size()); + if (!received) + return ExchangeResult::kShortIo; + if (memcmp(response.data(), kMagic.data(), kMagic.size()) != 0) + return ExchangeResult::kBadMagic; + if (response[4] != kVersion) + return ExchangeResult::kBadVersion; + if (response[5] == kOk) + return ExchangeResult::kOk; + if (response[5] == kWrongPeer) + return ExchangeResult::kWrongPeer; + if (response[5] == kProtocolMismatch) + return ExchangeResult::kProtocolMismatchStatus; + if (response[5] == SOS_CORE_DEV_V1_STATUS_REJECTED) + return ExchangeResult::kRequestRejected; + if (response[5] == kConfigured) + return ExchangeResult::kConfigured; + if (response[5] == kEmpty) + return ExchangeResult::kEmpty; + return ExchangeResult::kBadStatus; +} + +[[maybe_unused]] ExchangeResult exchange(uint8_t operation, + const SecretBuffer &secret) { + const int fd = connectEndpoint(); + if (fd < 0) + return ExchangeResult::kEndpointUnavailable; + const ExchangeResult result = exchangeOnFd(fd, operation, secret); + close(fd); + return result; +} + +bool writeMessage(int fd, std::string_view message) { + return writeAll(fd, message.data(), message.size()); +} + +int fail(std::string_view category) { + constexpr std::string_view kPrefix = + "error: Core development credential request failed ("; + constexpr std::string_view kSuffix = ")\n"; + const bool prefixWritten = writeMessage(STDERR_FILENO, kPrefix); + const bool categoryWritten = + prefixWritten && writeMessage(STDERR_FILENO, category); + if (categoryWritten) + (void)writeMessage(STDERR_FILENO, kSuffix); + return 1; +} + +} // namespace + +int runClient(int argc, char **argv, + ExchangeResult (*exchangeRequest)(uint8_t, + const SecretBuffer &)) { + if (argc != 2) + return fail("usage"); + SecretBuffer secret; + uint8_t operation = 0; + std::string_view confirmation; + if (strcmp(argv[1], "probe") == 0) { + operation = kProbe; + confirmation = "core_dev_credential=READY\n"; + } else if (strcmp(argv[1], "set") == 0) { + operation = kSet; + confirmation = "core_dev_credential=SET\n"; + if (!readCredential(&secret)) + return fail("credential_format"); + } else if (strcmp(argv[1], "clear") == 0) { + operation = kClear; + confirmation = "core_dev_credential=CLEARED\n"; + } else if (strcmp(argv[1], "status") == 0) { + operation = kStatus; + } else if (strcmp(argv[1], "agent-smoke") == 0) { + operation = kAgentSmoke; + confirmation = "core_dev_agent_smoke=SUBMITTED\n"; + } else { + return fail("usage"); + } + const ExchangeResult result = exchangeRequest(operation, secret); + if (operation == kStatus) { + if (result == ExchangeResult::kConfigured) { + confirmation = "core_dev_credential=CONFIGURED\n"; + } else if (result == ExchangeResult::kEmpty) { + confirmation = "core_dev_credential=EMPTY\n"; + } + } + if ((operation != kStatus && result == ExchangeResult::kOk) || + (operation == kStatus && + (result == ExchangeResult::kConfigured || + result == ExchangeResult::kEmpty))) { + if (!writeMessage(STDOUT_FILENO, confirmation)) + return fail("stdout_io"); + return 0; + } + switch (result) { + case ExchangeResult::kOk: + return fail("unexpected_status"); + case ExchangeResult::kEndpointUnavailable: + return fail("endpoint_unavailable"); + case ExchangeResult::kShortIo: + return fail("short_io"); + case ExchangeResult::kWrongPeer: + return fail("wrong_peer"); + case ExchangeResult::kBadMagic: + return fail("bad_magic"); + case ExchangeResult::kBadVersion: + return fail("bad_version"); + case ExchangeResult::kProtocolMismatchStatus: + return fail("protocol_mismatch_status"); + case ExchangeResult::kBadStatus: + return fail("bad_status"); + case ExchangeResult::kRequestRejected: + return fail("request_rejected"); + case ExchangeResult::kConfigured: + case ExchangeResult::kEmpty: + return fail("unexpected_status"); + } + return fail("unknown"); +} + +#ifndef SOS_CORE_DEV_CREDENTIAL_NO_MAIN +int main(int argc, char **argv) { return runClient(argc, argv, exchange); } +#endif diff --git a/aosp/device/sos/a33x/core/dev_credential_protocol_v1.h b/aosp/device/sos/a33x/core/dev_credential_protocol_v1.h new file mode 100644 index 0000000..eb5c336 --- /dev/null +++ b/aosp/device/sos/a33x/core/dev_credential_protocol_v1.h @@ -0,0 +1,51 @@ +#ifndef SOS_CORE_DEV_CREDENTIAL_PROTOCOL_V1_H_ +#define SOS_CORE_DEV_CREDENTIAL_PROTOCOL_V1_H_ + +// Canonical SOS Core development credential protocol v1 wire contract. +// All multi-byte integers are unsigned and encoded in network byte order. +// Request: magic[4], version[1], operation[1], payload_length[2], payload. +// Ack: magic[4], version[1], status[1]. +// A request is complete only after exactly payload_length bytes and EOF. +#define SOS_CORE_DEV_V1_MAGIC_0 0x53 +#define SOS_CORE_DEV_V1_MAGIC_1 0x4f +#define SOS_CORE_DEV_V1_MAGIC_2 0x53 +#define SOS_CORE_DEV_V1_MAGIC_3 0x4b +#define SOS_CORE_DEV_V1_VERSION 0x01 +#define SOS_CORE_DEV_V1_OP_PROBE 0x00 +#define SOS_CORE_DEV_V1_OP_SET 0x01 +#define SOS_CORE_DEV_V1_OP_CLEAR 0x02 +#define SOS_CORE_DEV_V1_OP_STATUS 0x03 +#define SOS_CORE_DEV_V1_OP_AGENT_SMOKE 0x04 +#define SOS_CORE_DEV_V1_STATUS_OK 0x01 +#define SOS_CORE_DEV_V1_STATUS_REJECTED 0x02 +#define SOS_CORE_DEV_V1_STATUS_WRONG_PEER 0x03 +#define SOS_CORE_DEV_V1_STATUS_PROTOCOL_MISMATCH 0x04 +#define SOS_CORE_DEV_V1_STATUS_CONFIGURED 0x05 +#define SOS_CORE_DEV_V1_STATUS_EMPTY 0x06 +#define SOS_CORE_DEV_V1_REQUEST_HEADER_BYTES 8 +#define SOS_CORE_DEV_V1_ACK_BYTES 6 +#define SOS_CORE_DEV_V1_MAX_PAYLOAD_BYTES 512 + +// Golden vectors (hex): +// probe: 53 4f 53 4b 01 00 00 00 +// clear: 53 4f 53 4b 01 02 00 00 +// status: 53 4f 53 4b 01 03 00 00 +// agent-smoke: 53 4f 53 4b 01 04 00 00 +// set("sk-or-v1-0123456789abcdef01234567"): +// 53 4f 53 4b 01 01 00 21 +// 73 6b 2d 6f 72 2d 76 31 2d 30 31 32 33 34 35 36 +// 37 38 39 61 62 63 64 65 66 30 31 32 33 34 35 36 37 +// ok: 53 4f 53 4b 01 01 +// rejected: 53 4f 53 4b 01 02 +// wrong-peer: 53 4f 53 4b 01 03 +// protocol-mismatch: 53 4f 53 4b 01 04 +// configured: 53 4f 53 4b 01 05 +// empty: 53 4f 53 4b 01 06 +// +// STATUS and AGENT_SMOKE are compatible v1 extensions. STATUS has no payload and reveals only +// configured versus empty. An older endpoint rejects its unknown opcode with +// protocol-mismatch; a client must reject CONFIGURED/EMPTY for any other op. +// AGENT_SMOKE has no payload and queues one source-embedded, non-secret prompt +// through the production UI submit path only when the credential is configured. + +#endif // SOS_CORE_DEV_CREDENTIAL_PROTOCOL_V1_H_ diff --git a/aosp/device/sos/a33x/lineage_sos_compat0_a33x.mk b/aosp/device/sos/a33x/lineage_sos_compat0_a33x.mk index d236aa9..a07e381 100644 --- a/aosp/device/sos/a33x/lineage_sos_compat0_a33x.mk +++ b/aosp/device/sos/a33x/lineage_sos_compat0_a33x.mk @@ -7,6 +7,7 @@ PRODUCT_NAME := lineage_sos_compat0_a33x PRODUCT_PACKAGES += \ SosA33xFrameworkOverlay \ SosShell \ + sos-agent-runner \ sos-compat-privapp-permissions PRODUCT_SYSTEM_EXT_PROPERTIES += \ diff --git a/aosp/device/sos/a33x/lineage_sos_compat_a33x.mk b/aosp/device/sos/a33x/lineage_sos_compat_a33x.mk index f79e06a..15d09ff 100644 --- a/aosp/device/sos/a33x/lineage_sos_compat_a33x.mk +++ b/aosp/device/sos/a33x/lineage_sos_compat_a33x.mk @@ -13,6 +13,7 @@ PRODUCT_PACKAGES += \ SosA33xFrameworkOverlay \ SosCompat1FrameworkOverlay \ SosShell \ + sos-agent-runner \ sos-compat-privapp-permissions \ sos-compat-ui-removal-marker diff --git a/aosp/device/sos/a33x/lineage_sos_core0b_a33x.mk b/aosp/device/sos/a33x/lineage_sos_core0b_a33x.mk index 12d9ede..2d330a1 100644 --- a/aosp/device/sos/a33x/lineage_sos_core0b_a33x.mk +++ b/aosp/device/sos/a33x/lineage_sos_core0b_a33x.mk @@ -7,6 +7,7 @@ $(call inherit-product, device/sos/a33x/sos_headless_android_adapter_common.mk) PRODUCT_NAME := lineage_sos_core0b_a33x PRODUCT_PACKAGES += \ + sos-agent-runner \ sos-ui-removal-marker PRODUCT_SYSTEM_EXT_PROPERTIES += \ diff --git a/aosp/device/sos/a33x/lineage_sos_core1_a33x.mk b/aosp/device/sos/a33x/lineage_sos_core1_a33x.mk index 49e166b..3864a6f 100644 --- a/aosp/device/sos/a33x/lineage_sos_core1_a33x.mk +++ b/aosp/device/sos/a33x/lineage_sos_core1_a33x.mk @@ -1,24 +1,11 @@ -# SOS Core 1 validation target: no Zygote is started, so system_server and APK -# processes cannot exist. The native host owns display/input and exposes a -# fixed locked/recovery surface until synthetic-password unlock is native. -# The shared Samsung definition normally selects core_64_bit_only.mk. The -# audited source patch for this target substitutes core_no_zygote.mk there so -# the vendor property has one authoritative ro.zygote assignment. -$(call inherit-product, device/sos/a33x/sos_a33x_common.mk) -$(call inherit-product, device/sos/a33x/sos_native_host_common.mk) +# Shipping/ordinary Core 1 product. Development credentials use the distinct +# lineage_sos_core1_dev_a33x product and can never be selected by this graph. +$(call inherit-product, device/sos/a33x/sos_core1_common.mk) PRODUCT_NAME := lineage_sos_core1_a33x PRODUCT_PACKAGES += \ - sos-core-app-manifest \ - sos-core-platform-adapter \ - sos-ui-removal-marker + sos-agent-runner PRODUCT_SYSTEM_EXT_PROPERTIES += \ - ro.sos.block_android_activities=true \ - ro.sos.disable_user_apk_install=true \ - ro.sos.core.stage=1 \ - ro.sos.lifecycle=active \ - ro.sos.providers=core-native \ - ro.sos.profile=core \ - ro.sos.ui_owner=native-sos-no-zygote + ro.sos.build_variant=core1-ordinary diff --git a/aosp/device/sos/a33x/lineage_sos_core1_dev_a33x.mk b/aosp/device/sos/a33x/lineage_sos_core1_dev_a33x.mk new file mode 100644 index 0000000..26f59e6 --- /dev/null +++ b/aosp/device/sos/a33x/lineage_sos_core1_dev_a33x.mk @@ -0,0 +1,21 @@ +# Non-shipping Core 1 development product. Product make sees this immutable +# selection directly; no caller-provided shell variable controls packaging. +ifneq ($(TARGET_BUILD_VARIANT),userdebug) +$(error SOS Core development credentials require the registered userdebug build) +endif + +$(call inherit-product, device/sos/a33x/sos_core1_common.mk) + +PRODUCT_NAME := lineage_sos_core1_dev_a33x + +PRODUCT_PACKAGES += \ + sos-core-dev-credential \ + sos-node-core-dev \ + sos-agent-runner-core-dev + +SYSTEM_EXT_PRIVATE_SEPOLICY_DIRS += \ + device/sos/a33x/sepolicy/core_dev_private + +PRODUCT_SYSTEM_EXT_PROPERTIES += \ + ro.sos.build_variant=core1-dev-credential \ + ro.sos.dev_credential=1 diff --git a/aosp/device/sos/a33x/lineage_sos_core_a33x.mk b/aosp/device/sos/a33x/lineage_sos_core_a33x.mk index 76a3ab1..8732be3 100644 --- a/aosp/device/sos/a33x/lineage_sos_core_a33x.mk +++ b/aosp/device/sos/a33x/lineage_sos_core_a33x.mk @@ -7,6 +7,7 @@ $(call inherit-product, device/sos/a33x/sos_a33x_common.mk) PRODUCT_NAME := lineage_sos_core_a33x PRODUCT_PACKAGES += \ + sos-agent-runner \ sos-core-experience-runtime \ sos-core-host \ sos-core-surface-probe diff --git a/aosp/device/sos/a33x/sepolicy/core_dev_private/file.te b/aosp/device/sos/a33x/sepolicy/core_dev_private/file.te new file mode 100644 index 0000000..c41af9a --- /dev/null +++ b/aosp/device/sos/a33x/sepolicy/core_dev_private/file.te @@ -0,0 +1,2 @@ +type sos_node_core_dev_exec, system_file_type, exec_type, file_type; +type sos_core_dev_credential_exec, system_file_type, exec_type, file_type; diff --git a/aosp/device/sos/a33x/sepolicy/core_dev_private/file_contexts b/aosp/device/sos/a33x/sepolicy/core_dev_private/file_contexts new file mode 100644 index 0000000..928a075 --- /dev/null +++ b/aosp/device/sos/a33x/sepolicy/core_dev_private/file_contexts @@ -0,0 +1,2 @@ +/system_ext/bin/sos-node-core-dev u:object_r:sos_node_core_dev_exec:s0 +/system_ext/bin/sos-core-dev-credential u:object_r:sos_core_dev_credential_exec:s0 diff --git a/aosp/device/sos/a33x/sepolicy/core_dev_private/port.te b/aosp/device/sos/a33x/sepolicy/core_dev_private/port.te new file mode 100644 index 0000000..e6f8465 --- /dev/null +++ b/aosp/device/sos/a33x/sepolicy/core_dev_private/port.te @@ -0,0 +1 @@ +type sos_core_dev_proxy_port, port_type; diff --git a/aosp/device/sos/a33x/sepolicy/core_dev_private/port_contexts b/aosp/device/sos/a33x/sepolicy/core_dev_private/port_contexts new file mode 100644 index 0000000..9dda143 --- /dev/null +++ b/aosp/device/sos/a33x/sepolicy/core_dev_private/port_contexts @@ -0,0 +1 @@ +portcon tcp 37173 u:object_r:sos_core_dev_proxy_port:s0 diff --git a/aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_agent.te b/aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_agent.te new file mode 100644 index 0000000..05c096d --- /dev/null +++ b/aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_agent.te @@ -0,0 +1,32 @@ +# Core-dev uses a distinct Node executable and domain. This is the only domain +# allowed to reach the fixed adb-reverse loopback port; ordinary Core's +# sos_core_agent retains its production-only TCP-443 boundary. +type sos_core_dev_agent, domain, coredomain; +domain_auto_trans(sos_core_host, sos_node_core_dev_exec, sos_core_dev_agent) +allow sos_core_host sos_core_dev_agent:process sigkill; + +allow sos_core_dev_agent sos_core_host:fd use; +allow sos_core_dev_agent sos_core_host:fifo_file { getattr ioctl read write }; +allowxperm sos_core_dev_agent sos_core_host:fifo_file ioctl { TCGETS }; +allow sos_core_dev_agent proc_meminfo:file { open read }; + +allow sos_core_dev_agent self:udp_socket create; +unix_socket_connect(sos_core_dev_agent, dnsproxyd, netd) +unix_socket_connect(sos_core_dev_agent, fwmarkd, netd) +# Android passes the caller's socket to netd for fwmark processing. This +# reciprocal permissions match netd's platform passed-socket contract and are +# narrower than assigning the broad netdomain attribute. +allow netd sos_core_dev_agent:fd use; +allow netd sos_core_dev_agent:tcp_socket { read write getattr setattr getopt setopt }; +allow sos_core_dev_agent self:tcp_socket create_stream_socket_perms; +allow sos_core_dev_agent sos_agent_https_port:tcp_socket name_connect; +allow sos_core_dev_agent sos_core_dev_proxy_port:tcp_socket name_connect; + +neverallow sos_core_dev_agent self:udp_socket ~{ create }; +neverallow sos_core_dev_agent self:{ icmp_socket rawip_socket } *; +neverallow sos_core_dev_agent port_type:udp_socket *; +neverallow sos_core_dev_agent node_type:{ icmp_socket rawip_socket udp_socket } *; +neverallow sos_core_dev_agent + { port_type -sos_agent_https_port -sos_core_dev_proxy_port }:tcp_socket name_connect; +neverallow sos_core_dev_agent net_dns_prop:file *; +neverallow sos_core_dev_agent self:process execmem; diff --git a/aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_credential.te b/aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_credential.te new file mode 100644 index 0000000..f6b4b33 --- /dev/null +++ b/aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_credential.te @@ -0,0 +1,21 @@ +# ADB invokes this immutable development-only client as uid 2000, but the +# executable immediately transitions out of shell before it can reach the +# credential endpoint. The product package, type, and endpoint feature are +# absent from ordinary Core. +type sos_core_dev_credential, domain, coredomain; + +userdebug_or_eng(` +domain_auto_trans(shell, sos_core_dev_credential_exec, sos_core_dev_credential) + +# stdin/stdout are inherited from the one adb exec transaction. No pathname, +# property, log, Binder, capability, or network permission is granted. +allow sos_core_dev_credential shell:fd use; +allow sos_core_dev_credential adbd:fd use; +allow sos_core_dev_credential adbd:unix_stream_socket { read write }; +allow sos_core_dev_credential shell:fifo_file { getattr read write }; + +allow sos_core_dev_credential self:unix_stream_socket create_socket_perms; +allow sos_core_dev_credential sos_core_host:unix_stream_socket connectto; +') + +neverallow sos_core_dev_credential self:{ icmp_socket rawip_socket tcp_socket udp_socket } *; diff --git a/aosp/device/sos/a33x/sepolicy/system_ext/private/port.te b/aosp/device/sos/a33x/sepolicy/system_ext/private/port.te index 3cd02c9..58eafa2 100644 --- a/aosp/device/sos/a33x/sepolicy/system_ext/private/port.te +++ b/aosp/device/sos/a33x/sepolicy/system_ext/private/port.te @@ -1,2 +1,3 @@ type sos_provider_port, port_type; type sos_revision_port, port_type; +type sos_agent_https_port, port_type; diff --git a/aosp/device/sos/a33x/sepolicy/system_ext/private/port_contexts b/aosp/device/sos/a33x/sepolicy/system_ext/private/port_contexts index af5bf4b..d6484aa 100644 --- a/aosp/device/sos/a33x/sepolicy/system_ext/private/port_contexts +++ b/aosp/device/sos/a33x/sepolicy/system_ext/private/port_contexts @@ -1,2 +1,3 @@ portcon tcp 47777 u:object_r:sos_provider_port:s0 portcon tcp 47778 u:object_r:sos_revision_port:s0 +portcon tcp 443 u:object_r:sos_agent_https_port:s0 diff --git a/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_agent.te b/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_agent.te new file mode 100644 index 0000000..271aa43 --- /dev/null +++ b/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_agent.te @@ -0,0 +1,49 @@ +# The immutable Node/Pi child is the only Core UI process allowed to reach the +# Android resolver or the public network. system_ext entrypoints must remain a +# coredomain, but this transition prevents network authority from accumulating +# in the trusted render/compile/activation host. +type sos_core_agent, domain, coredomain; +domain_auto_trans(sos_core_host, sos_node_exec, sos_core_agent) +allow sos_core_host sos_core_agent:process sigkill; + +# The host creates the bounded stdin/stdout pipes before exec. The child may +# only consume/produce those inherited streams; it receives no host sockets. +allow sos_core_agent sos_core_host:fd use; +allow sos_core_agent sos_core_host:fifo_file { getattr ioctl read write }; +# Node/libuv unconditionally uses TCGETS to classify its pipe-backed standard +# streams. Android's domain policy already constrains fifo_file ioctl to this +# harmless query; repeat the exact xperm here so the child contract is explicit. +allowxperm sos_core_agent sos_core_host:fifo_file ioctl { TCGETS }; + +# Node reads the system memory total during fixed startup. Keep this to the +# two read-only permissions observed and required by that immutable runtime. +allow sos_core_agent proc_meminfo:file { open read }; + +# Bionic's supported dnsproxyd client creates and immediately closes an IPv6 +# UDP socket as a network-eligibility probe before opening the labeled Unix +# socket. Permit only that creation: there is deliberately no UDP bind, +# connect, read/write, port, or node authority. If the probe fails, Bionic +# falls back to obsolete local resolution that reads net.dns* and sends UDP; +# the neverallows below keep that fallback unusable rather than broadening it. +allow sos_core_agent self:udp_socket create; +unix_socket_connect(sos_core_agent, dnsproxyd, netd) +unix_socket_connect(sos_core_agent, fwmarkd, netd) +# fwmarkd receives the caller's TCP fd and asks netd to mark it before +# connect(2). Keep the child out of the broad netdomain attribute while +# granting only the reciprocal fd handoff and socket operations used by netd +# on that passed descriptor, matching the platform netdomain handoff contract. +allow netd sos_core_agent:fd use; +allow netd sos_core_agent:tcp_socket { read write getattr setattr getopt setopt }; +allow sos_core_agent self:tcp_socket create_stream_socket_perms; +allow sos_core_agent sos_agent_https_port:tcp_socket name_connect; + +# Keep both halves of the boundary structural: the UI host never becomes a +# network client, and neither process may regain the rejected property or JIT. +neverallow sos_core_host self:{ icmp_socket rawip_socket tcp_socket udp_socket } *; +neverallow sos_core_agent self:udp_socket ~{ create }; +neverallow sos_core_agent self:{ icmp_socket rawip_socket } *; +neverallow sos_core_agent port_type:udp_socket *; +neverallow sos_core_agent node_type:{ icmp_socket rawip_socket udp_socket } *; +neverallow sos_core_agent { port_type -sos_agent_https_port }:tcp_socket name_connect; +neverallow { sos_core_agent sos_core_host } net_dns_prop:file *; +neverallow { sos_core_agent sos_core_host } self:process execmem; diff --git a/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_host.te b/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_host.te index 57164d8..3fd3a67 100644 --- a/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_host.te +++ b/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_host.te @@ -1,12 +1,7 @@ init_daemon_domain(sos_core_host) -net_domain(sos_core_host) # The fixed supervisor re-execs this one signed binary for a clean GPUI child. allow sos_core_host sos_core_host_exec:file execute_no_trans; -# Core's deterministic agent path executes the same immutable Pi bundle as -# Compat, while the host remains the compile/render/activation authority. -allow sos_core_host sos_node_exec:file rx_file_perms; -allow sos_core_host sos_node_exec:file execute_no_trans; binder_use(sos_core_host) binder_call(sos_core_host, surfaceflinger) @@ -33,7 +28,6 @@ allow sos_core_host sos_authority_data_file:dir create_dir_perms; allow sos_core_host sos_authority_data_file:file create_file_perms; allow sos_core_host sos_authority_data_file:sock_file rw_file_perms; allow sos_core_host sos_authority:unix_stream_socket connectto; -allow sos_core_host { sos_provider_port sos_revision_port }:tcp_socket name_connect; # Core 0B's only Java dependency is a direct-boot, system-UID process with no # Activities. Peer credentials and this domain transition bound the abstract @@ -44,6 +38,10 @@ allow sos_core_host system_app:unix_stream_socket connectto; # control socket in native code. allow system_app sos_core_host:unix_stream_socket connectto; +# ADB shell never reaches the host's authenticated local control socket. The +# Core-dev product grants only its dedicated client domain this transition. +neverallow shell sos_core_host:unix_stream_socket connectto; + # The fixed Volume Up+Down chord is the recovery path even when system_server # and Android's power UI are absent. set_prop(sos_core_host, powerctl_prop) diff --git a/aosp/device/sos/a33x/sos_a33x_common.mk b/aosp/device/sos/a33x/sos_a33x_common.mk index 1cefb4e..c220496 100644 --- a/aosp/device/sos/a33x/sos_a33x_common.mk +++ b/aosp/device/sos/a33x/sos_a33x_common.mk @@ -9,7 +9,6 @@ PRODUCT_PACKAGES += \ sos-android-system-authority \ sos-node \ sos-node-cxx-shared \ - sos-agent-runner \ sos-agent-experience-api \ sos-agent-example-primary \ sos-agent-example-secondary \ diff --git a/aosp/device/sos/a33x/sos_core1_common.mk b/aosp/device/sos/a33x/sos_core1_common.mk new file mode 100644 index 0000000..00418cd --- /dev/null +++ b/aosp/device/sos/a33x/sos_core1_common.mk @@ -0,0 +1,23 @@ +# Shared Core 1 composition: no Zygote is started, so system_server and APK +# processes cannot exist. The native host owns display/input and exposes a +# fixed locked/recovery surface until synthetic-password unlock is native. +# The shared Samsung definition normally selects core_64_bit_only.mk. The +# audited source patch substitutes core_no_zygote.mk for both Core 1 products. +# Lineage common.mk deliberately sets PRODUCT_NOT_DEBUGGABLE_IN_USERDEBUG, so +# both Core products retain ro.debuggable=0 and do not enable broad adb root. +$(call inherit-product, device/sos/a33x/sos_a33x_common.mk) +$(call inherit-product, device/sos/a33x/sos_native_host_common.mk) + +PRODUCT_PACKAGES += \ + sos-core-app-manifest \ + sos-core-platform-adapter \ + sos-ui-removal-marker + +PRODUCT_SYSTEM_EXT_PROPERTIES += \ + ro.sos.block_android_activities=true \ + ro.sos.disable_user_apk_install=true \ + ro.sos.core.stage=1 \ + ro.sos.lifecycle=active \ + ro.sos.providers=core-native \ + ro.sos.profile=core \ + ro.sos.ui_owner=native-sos-no-zygote diff --git a/aosp/patches/a33x-lineage-23.0/0008-s5e8825-select-no-zygote-for-sos-core1-dev.patch b/aosp/patches/a33x-lineage-23.0/0008-s5e8825-select-no-zygote-for-sos-core1-dev.patch new file mode 100644 index 0000000..93dab4f --- /dev/null +++ b/aosp/patches/a33x-lineage-23.0/0008-s5e8825-select-no-zygote-for-sos-core1-dev.patch @@ -0,0 +1,31 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: SOS bring-up +Date: Tue, 18 Aug 2026 12:05:00 +0200 +Subject: [PATCH] s5e8825-common: Include Core 1 dev in no-Zygote products + +Core 1 development credentials now use a distinct product so Kati and Soong +observe immutable package selection. Keep that non-shipping product on the +same no-Zygote base as ordinary Core 1. + +This is an ordered continuation of patch 0005, not an independently reversible +edit. Bootstrap validates and classifies the complete per-project series from +the pinned baseline so a finished tree is idempotent after this line changes +the result of 0005. +--- + common.mk | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/common.mk b/common.mk +index 010fad1..44d90c4 100644 +--- a/common.mk ++++ b/common.mk +@@ -18,6 +18,6 @@ + # retains the shared Samsung hardware product while selecting AOSP's empty + # Zygote init configuration; every other a33x product remains 64-bit-only. +-ifeq ($(TARGET_PRODUCT),lineage_sos_core1_a33x) ++ifneq ($(filter lineage_sos_core1_a33x lineage_sos_core1_dev_a33x,$(TARGET_PRODUCT)),) + $(call inherit-product, $(SRC_TARGET_DIR)/product/core_no_zygote.mk) + else + $(call inherit-product, $(SRC_TARGET_DIR)/product/core_64_bit_only.mk) +-- +2.51.0 diff --git a/apps/experience/Cargo.toml b/apps/experience/Cargo.toml index 810c717..04e01f4 100644 --- a/apps/experience/Cargo.toml +++ b/apps/experience/Cargo.toml @@ -12,6 +12,7 @@ gate-strict = [] aosp-system = ["dep:android-authority-protocol"] core-native = ["aosp-system"] core-provider-acceptance = ["core-native", "dep:android-provider-acceptance"] +core-dev-credential = ["core-native"] linux-host = [] [lib] @@ -68,6 +69,7 @@ wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" } [dev-dependencies] gpui = { git = "https://github.com/zed-industries/zed", rev = "5688167d224b5eca54875d49afb8bfd73a07915a", package = "gpui", default-features = false } gpui-mobile = { path = "../../vendor/gpui-mobile", default-features = false, features = ["deeplink"] } +libc = "0.2" log.workspace = true tempfile = "3" zeroize.workspace = true diff --git a/apps/experience/build.rs b/apps/experience/build.rs new file mode 100644 index 0000000..6ec3f25 --- /dev/null +++ b/apps/experience/build.rs @@ -0,0 +1,10 @@ +#[path = "../../build-support/core_dev_credential_protocol.rs"] +mod core_dev_credential_protocol; + +fn main() { + core_dev_credential_protocol::generate( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .as_path(), + ); +} diff --git a/apps/experience/src/android.rs b/apps/experience/src/android.rs index 043cc97..0381e8c 100644 --- a/apps/experience/src/android.rs +++ b/apps/experience/src/android.rs @@ -50,7 +50,12 @@ use sha2::{Digest, Sha256}; #[cfg(feature = "core-native")] use zeroize::Zeroize; -use crate::android_agent_contract::{AgentActivationEvidence, AgentActivationPhase}; +use crate::android_agent_contract::{ + ui_failure, AgentActivationEvidence, AgentActivationPhase, AgentUiAttempt, AgentUiAttemptEvent, + AgentUiAttemptEventKind, AgentUiFailure, AgentUiTransport, +}; +#[cfg(feature = "core-dev-credential")] +use crate::android_agent_contract::{CoreDevSmokeAuthorization, CORE_DEV_AGENT_SMOKE_PROMPT}; #[cfg(not(feature = "core-native"))] use crate::android_interaction_contract::{text_tap_outcome, TextTapOutcome}; use crate::assets::{self, SosAssets, ALBUM_ASSET}; @@ -64,6 +69,9 @@ use native_input::NativeTextInput; static FILES_DIR: OnceLock = OnceLock::new(); static RELOAD_REQUESTED: AtomicBool = AtomicBool::new(false); static WORKER_RESTART_REQUESTED: AtomicBool = AtomicBool::new(false); +#[cfg(feature = "core-dev-credential")] +static CORE_DEV_AGENT_SMOKE_AUTHORIZATION: Mutex = + Mutex::new(CoreDevSmokeAuthorization::new()); static STRESS_REQUEST: OnceLock>> = OnceLock::new(); #[cfg(feature = "core-native")] static CORE_PLATFORM: OnceLock>>> = OnceLock::new(); @@ -72,6 +80,82 @@ static CORE_STOP_REQUESTED: AtomicBool = AtomicBool::new(false); #[cfg(feature = "core-native")] static CORE_EXIT_REASON: AtomicI32 = AtomicI32::new(0); +fn log_agent_attempt_event(event: AgentUiAttemptEvent) { + let event_name = match event.kind { + AgentUiAttemptEventKind::Received => "attempt_received", + AgentUiAttemptEventKind::DispatchStarted => "dispatch_started", + AgentUiAttemptEventKind::Terminal => "terminal", + }; + let status = if event.kind == AgentUiAttemptEventKind::Terminal { + if event.category == "completed" { + "completed" + } else { + "failed" + } + } else { + "pending" + }; + if event.kind == AgentUiAttemptEventKind::Terminal { + log::info!( + "{}", + event + .request_terminal_marker() + .expect("request terminal evidence requires a terminal attempt") + ); + log::info!( + "{}", + event + .ui_terminal_marker() + .expect("UI terminal evidence requires a terminal attempt") + ); + } + log::info!( + "core_ui_attempt event={event_name} attempt={} provider={} model={} configured={} busy={} input_present={} stage={} category={} status={status} correlation=serialized", + event.attempt_id, + event.provider, + event.model, + event.configured, + event.busy, + event.input_present, + event.stage, + event.category, + ); +} + +#[cfg(feature = "core-dev-credential")] +pub(crate) fn install_dev_openrouter_credential(key: &[u8]) -> bool { + agent::install_dev_openrouter_credential(key) +} + +#[cfg(feature = "core-dev-credential")] +pub(crate) fn clear_dev_openrouter_credential() { + agent::clear_dev_openrouter_credential(); +} + +#[cfg(feature = "core-dev-credential")] +pub(crate) fn dev_openrouter_credential_configured() -> bool { + agent::dev_openrouter_credential_configured() +} + +#[cfg(feature = "core-dev-credential")] +pub(crate) fn request_dev_agent_smoke() -> bool { + if !dev_openrouter_credential_configured() { + return false; + } + let mut authorization = CORE_DEV_AGENT_SMOKE_AUTHORIZATION + .lock() + .expect("Core-dev smoke authorization lock"); + if !authorization.arm_authenticated() { + return false; + } + drop(authorization); + log::info!( + "core_dev_agent_smoke state=queued prompt=fixed_non_secret transport=adb_reverse_connect" + ); + request_host_frame(); + true +} + fn request_host_frame() { #[cfg(not(feature = "core-native"))] { @@ -341,6 +425,11 @@ pub unsafe extern "C" fn sos_core_main( } log::info!("sos_experience_host role=core-native density_dpi={density_dpi}"); + #[cfg(feature = "core-dev-credential")] + if let Err(category) = crate::core_dev_credential::start() { + log::warn!("core_dev_credential state=unavailable category={category}"); + } + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { run_experience(SharedPlatform::new(platform)); })); @@ -413,9 +502,12 @@ struct ExperienceHost { system_revision_id: String, status: Option<(String, bool)>, next_request_id: u64, + next_agent_attempt_id: u64, candidates: HashMap, pending_authority_activations: HashMap, pending_agent_activations: HashMap, + pending_agent_attempt: Option, + agent_action_attempts: HashMap, action_in_flight: bool, pending_input_events: VecDeque, input_state_shadow: HashMap, @@ -619,9 +711,12 @@ impl ExperienceHost { system_revision_id, status: Some(("Starting Luau worker…".into(), true)), next_request_id: 1, + next_agent_attempt_id: 1, candidates: HashMap::new(), pending_authority_activations: HashMap::new(), pending_agent_activations: HashMap::new(), + pending_agent_attempt: None, + agent_action_attempts: HashMap::new(), action_in_flight: false, pending_input_events: VecDeque::new(), input_state_shadow: HashMap::new(), @@ -874,6 +969,107 @@ impl ExperienceHost { request_id } + fn allocate_agent_attempt_id(&mut self) -> u64 { + let attempt_id = self.next_agent_attempt_id; + self.next_agent_attempt_id = self.next_agent_attempt_id.wrapping_add(1).max(1); + attempt_id + } + + fn agent_submit_input_present(&self, event: &SceneEvent) -> bool { + event + .value + .as_deref() + .or_else(|| { + event + .target + .as_deref() + .and_then(|target| find_text_session(&self.scene.root, target)) + .or_else(|| find_submit_session(&self.scene.root, "agent_submit")) + .and_then(|session| self.state.get(&session.state_key)) + .and_then(JsonValue::as_str) + }) + .is_some_and(|value| !value.trim().is_empty()) + } + + fn begin_agent_attempt( + &mut self, + event: &SceneEvent, + transport: Option, + ) -> bool { + #[cfg(not(feature = "core-dev-credential"))] + debug_assert!(transport.is_none()); + let status = agent::status(); + let (provider, configured) = status + .as_ref() + .map(|status| (status.provider.as_str(), status.configured)) + .unwrap_or(("unsupported", false)); + let busy = self.model.agent.busy + || self.pending_agent_attempt.is_some() + || !self.agent_action_attempts.is_empty(); + let input_present = self.agent_submit_input_present(event); + #[cfg(feature = "aosp-system")] + let network_available = self.model.providers.connectivity.connected + && self.model.providers.connectivity.validated; + #[cfg(not(feature = "aosp-system"))] + let network_available = self.model.network.connected && self.model.network.validated; + #[cfg(feature = "core-dev-credential")] + let (attempt, received) = if let Some(transport) = transport { + AgentUiAttempt::receive_core_dev_fixed_smoke( + self.allocate_agent_attempt_id(), + provider, + configured, + busy, + event.value.as_deref().unwrap_or_default(), + transport, + ) + .expect("authenticated Core-dev smoke must retain its fixed dispatch contract") + } else { + AgentUiAttempt::receive( + self.allocate_agent_attempt_id(), + provider, + configured, + busy, + input_present, + network_available, + ) + }; + #[cfg(not(feature = "core-dev-credential"))] + let (attempt, received) = AgentUiAttempt::receive( + self.allocate_agent_attempt_id(), + provider, + configured, + busy, + input_present, + network_available, + ); + log_agent_attempt_event(received); + let failure = if status.is_err() { + Some(ui_failure("runtime", "runtime_start")) + } else { + attempt.preflight().err() + }; + if let Some(failure) = failure { + self.fail_agent_attempt(attempt, failure); + false + } else { + self.pending_agent_attempt = Some(attempt); + true + } + } + + fn fail_agent_attempt(&mut self, mut attempt: AgentUiAttempt, failure: AgentUiFailure) { + let terminal = attempt + .terminal(Some(failure)) + .expect("active agent UI attempt must not already be terminal"); + log_agent_attempt_event(terminal); + if failure.category != "busy" { + self.model.agent.busy = false; + } + self.model.agent.activity = "Agent request failed".into(); + self.model.agent.error = Some(failure.display.into()); + self.status = Some((format!("Agent request failed: {}", failure.display), false)); + } + fn restart_worker(&mut self, cx: &mut Context) { if self.stress.is_some() || !self.candidates.is_empty() || self.action_in_flight { log::warn!("runtime_worker_restart_rejected reason=runtime_busy"); @@ -913,7 +1109,7 @@ impl ExperienceHost { } fn dispatch(&mut self, action: String, cx: &mut Context) { - self.dispatch_event( + self.queue_input_event( SceneEvent { action, ..Default::default() @@ -946,13 +1142,31 @@ impl ExperienceHost { event.target.as_deref().unwrap_or("none") ); self.action_in_flight = true; - if let Err(error) = - self.worker - .action(request_id, self.model.clone(), self.state.clone(), event) + let agent_attempt = (event.action == "agent_submit") + .then(|| self.pending_agent_attempt.take()) + .flatten(); + match self + .worker + .action(request_id, self.model.clone(), self.state.clone(), event) { - self.action_in_flight = false; - self.status = Some((format!("Action could not start: {error}"), false)); - cx.notify(); + Ok(()) => { + if let Some(mut attempt) = agent_attempt { + let started = attempt + .dispatch_started() + .expect("agent UI attempt dispatch must follow receipt"); + log_agent_attempt_event(started); + self.agent_action_attempts.insert(request_id, attempt); + } + } + Err(error) => { + self.action_in_flight = false; + if let Some(attempt) = agent_attempt { + self.fail_agent_attempt(attempt, ui_failure("dispatch", "dispatch_channel")); + } else { + self.status = Some((format!("Action could not start: {error}"), false)); + } + cx.notify(); + } } } @@ -963,6 +1177,8 @@ impl ExperienceHost { value: String, cx: &mut Context, ) { + let agent_composer = find_text_session(&self.scene.root, &node_id) + .is_some_and(|session| session.submit_action.as_deref() == Some("agent_submit")); if !self.state.is_object() { self.state = json!({}); } @@ -971,11 +1187,15 @@ impl ExperienceHost { } self.input_state_shadow.insert(state_key, value.clone()); persist_state(&self.state); - log::info!( - "native_text_changed node_id={} bytes={} marked_safe=true", - node_id, - value.len() - ); + if agent_composer { + log::debug!("native_text_changed node_id={node_id} content=redacted"); + } else { + log::info!( + "native_text_changed node_id={} bytes={} marked_safe=true", + node_id, + value.len() + ); + } self.queue_input_event( SceneEvent { action: "text_changed".into(), @@ -1268,6 +1488,19 @@ impl ExperienceHost { } fn queue_input_event(&mut self, event: SceneEvent, cx: &mut Context) { + self.queue_input_event_with_transport(event, None, cx); + } + + fn queue_input_event_with_transport( + &mut self, + event: SceneEvent, + transport: Option, + cx: &mut Context, + ) { + if event.action == "agent_submit" && !self.begin_agent_attempt(&event, transport) { + cx.notify(); + return; + } if self.action_in_flight || self.stress.is_some() { self.enqueue_pending_event(event); } else { @@ -1289,14 +1522,27 @@ impl ExperienceHost { } } if self.pending_input_events.len() >= 64 { - if let Some(position) = self + if let Some(position) = self.pending_input_events.iter().position(|queued| { + queued.action != "agent_submit" + && matches!(queued.phase.as_deref(), Some("move" | "update")) + }) { + self.pending_input_events.remove(position); + } else if let Some(position) = self .pending_input_events .iter() - .position(|queued| matches!(queued.phase.as_deref(), Some("move" | "update"))) + .position(|queued| queued.action != "agent_submit") { self.pending_input_events.remove(position); } else { - self.pending_input_events.pop_front(); + if event.action == "agent_submit" { + if let Some(attempt) = self.pending_agent_attempt.take() { + self.fail_agent_attempt( + attempt, + ui_failure("dispatch", "dispatch_channel"), + ); + } + } + return; } } self.pending_input_events.push_back(event); @@ -1770,6 +2016,7 @@ impl ExperienceHost { worker_us, } => { self.action_in_flight = false; + let agent_attempt = self.agent_action_attempts.remove(&request_id); self.merge_native_input_state(&mut state); let effect_count = effects.len(); #[cfg(feature = "aosp-system")] @@ -1780,6 +2027,19 @@ impl ExperienceHost { let (host_effects, provider_effects): (Vec<_>, Vec<_>) = effects .into_iter() .partition(|effect| matches!(effect.provider.as_str(), "network" | "agent")); + if agent_attempt.is_some() + && (effect_count != 1 + || host_effects.len() != 1 + || host_effects[0].provider != "agent" + || host_effects[0].action != "prompt") + { + self.fail_agent_attempt( + agent_attempt.expect("tracked agent UI attempt"), + ui_failure("runtime", "model_policy"), + ); + self.dispatch_pending_input_event(cx); + return; + } if let Some(expected_revision) = self.remote_state_revision { let source_sha256 = source_sha256(&self.source); let mut committed = provider_client::commit_state( @@ -1816,7 +2076,15 @@ impl ExperienceHost { ); } Err(error) => { - self.status = Some((format!("State commit failed: {error}"), false)); + if let Some(attempt) = agent_attempt { + self.fail_agent_attempt( + attempt, + ui_failure("dispatch", "dispatch_channel"), + ); + } else { + self.status = + Some((format!("State commit failed: {error}"), false)); + } log::warn!("experience_state_rejected error={error}"); self.dispatch_pending_input_event(cx); return; @@ -1829,14 +2097,14 @@ impl ExperienceHost { persist_state(&self.state); self.status = None; #[cfg(feature = "aosp-system")] - self.execute_agent_effects(host_effects); + self.execute_agent_effects(host_effects, agent_attempt); #[cfg(not(feature = "aosp-system"))] { let (network_effects, agent_effects): (Vec<_>, Vec<_>) = host_effects .into_iter() .partition(|effect| effect.provider == "network"); self.execute_network_effects(network_effects); - self.execute_agent_effects(agent_effects); + self.execute_agent_effects(agent_effects, agent_attempt); } log::info!( "experience_action_completed request_id={request_id} worker_us={worker_us}" @@ -1849,7 +2117,11 @@ impl ExperienceHost { worker_us, } => { self.action_in_flight = false; - self.status = Some((format!("Action rejected: {error}"), false)); + if let Some(attempt) = self.agent_action_attempts.remove(&request_id) { + self.fail_agent_attempt(attempt, ui_failure("runtime", "runtime_start")); + } else { + self.status = Some((format!("Action rejected: {error}"), false)); + } log::warn!( "experience_action_rejected request_id={request_id} worker_us={worker_us} error={error}" ); @@ -1862,7 +2134,7 @@ impl ExperienceHost { } => { self.scene = scene; self.accessibility_dirty = true; - log::info!( + log::debug!( "experience_model_refreshed request_id={request_id} worker_us={worker_us}" ); } @@ -1925,7 +2197,27 @@ impl ExperienceHost { } } - fn execute_agent_effects(&mut self, effects: Vec) { + fn execute_agent_effects( + &mut self, + effects: Vec, + agent_attempt: Option, + ) { + let mut agent_attempt = agent_attempt; + let prompt_effects = effects + .iter() + .filter(|effect| effect.provider == "agent" && effect.action == "prompt") + .count(); + if agent_attempt.is_some() && (prompt_effects != 1 || effects.len() != 1) { + self.fail_agent_attempt( + agent_attempt.take().expect("tracked agent attempt"), + ui_failure("runtime", "model_policy"), + ); + if let Ok(status) = agent::status() { + agent::apply_status(&mut self.model.agent, &status); + self.refresh_model_from_authority(); + } + return; + } for effect in effects { log::info!("android_agent_effect_dispatch action={}", effect.action); if matches!( @@ -1945,23 +2237,44 @@ impl ExperienceHost { "configure_codex" => agent::configure_codex(), "use_fake" => agent::use_fake(), "clear_credential" => agent::clear_credential(), - "prompt" => effect - .payload - .get("prompt") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|prompt| !prompt.is_empty() && prompt.len() <= MAX_AGENT_MESSAGE_BYTES) - .map(|prompt| self.start_agent_prompt(prompt.to_owned())) - .unwrap_or_else(|| { - Err("agent.prompt omitted a bounded non-empty prompt".into()) - }), + "prompt" => match agent_attempt.take() { + Some(attempt) => match effect + .payload + .get("prompt") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|prompt| { + !prompt.is_empty() && prompt.len() <= MAX_AGENT_MESSAGE_BYTES + }) { + Some(prompt) => self.start_agent_prompt(prompt.to_owned(), attempt), + None => { + self.fail_agent_attempt( + attempt, + ui_failure("dispatch", "dispatch_channel"), + ); + Ok(()) + } + }, + None => Err("agent.prompt had no received UI attempt".into()), + }, _ => Err(format!( "unsupported trusted agent action: {}", effect.action )), }; if let Err(error) = &result { - self.status = Some((format!("Agent action failed: {error}"), false)); + if effect.action == "prompt" { + if let Some(attempt) = agent_attempt.take() { + self.fail_agent_attempt( + attempt, + ui_failure("dispatch", "dispatch_channel"), + ); + } else { + self.status = Some((format!("Agent action failed: {error}"), false)); + } + } else { + self.status = Some((format!("Agent action failed: {error}"), false)); + } log::warn!("android_agent_action_failed action={}", effect.action); } #[cfg(feature = "core-native")] @@ -1975,63 +2288,142 @@ impl ExperienceHost { } } } + if let Some(attempt) = agent_attempt { + self.fail_agent_attempt(attempt, ui_failure("runtime", "model_policy")); + } if let Ok(status) = agent::status() { agent::apply_status(&mut self.model.agent, &status); self.refresh_model_from_authority(); } } - fn start_agent_prompt(&mut self, prompt: String) -> Result<(), String> { + fn start_agent_prompt( + &mut self, + prompt: String, + attempt: AgentUiAttempt, + ) -> Result<(), String> { if self.model.agent.busy { - return Err("the resident agent is already handling a prompt".into()); + self.fail_agent_attempt(attempt, ui_failure("preflight", "busy")); + return Ok(()); + } + let status = match agent::status() { + Ok(status) => status, + Err(_) => { + self.fail_agent_attempt(attempt, ui_failure("runtime", "runtime_start")); + return Ok(()); + } + }; + if attempt.provider() + != crate::android_agent_contract::safe_provider_identity(&status.provider) + { + let category = if attempt.provider() != "fake" && !status.configured { + "credential_missing" + } else { + "model_policy" + }; + self.fail_agent_attempt(attempt, ui_failure("preflight", category)); + return Ok(()); } - let status = agent::status()?; if status.provider != "fake" && !status.configured { - return Err("The selected Pi provider has no configured credential".into()); + self.fail_agent_attempt(attempt, ui_failure("preflight", "credential_missing")); + return Ok(()); } - agent::spawn_prompt( + #[cfg(feature = "aosp-system")] + let network_available = self.model.providers.connectivity.connected + && self.model.providers.connectivity.validated; + #[cfg(not(feature = "aosp-system"))] + let network_available = self.model.network.connected && self.model.network.validated; + if !attempt.prompt_matches_transport(&prompt) { + self.fail_agent_attempt(attempt, ui_failure("preflight", "model_policy")); + return Ok(()); + } + if status.provider != "fake" && !attempt.transport_ready(network_available) { + self.fail_agent_attempt(attempt, ui_failure("preflight", "network_unavailable")); + return Ok(()); + } + // Retain only the local fallback copy needed to emit a terminal if the + // OS refuses to create the bounded agent thread. + if agent::spawn_prompt( + attempt.clone(), status, prompt, self.source.clone(), self.model.clone(), self.agent_updates.clone(), - ); + ) + .is_err() + { + self.fail_agent_attempt(attempt, ui_failure("runtime", "runtime_start")); + return Ok(()); + } + self.model.agent.busy = true; Ok(()) } fn handle_agent_update(&mut self, update: agent::AgentUpdate) { match update { - agent::AgentUpdate::Started { prompt } => { + agent::AgentUpdate::Started { attempt_id, prompt } => { self.model.agent.busy = true; self.model.agent.error = None; self.model.agent.activity = "Understanding the request".into(); push_agent_message(&mut self.model, AgentMessageRole::User, prompt); + log::info!( + "android_agent_ui_transition attempt={attempt_id} state=started correlation=serialized" + ); } - agent::AgentUpdate::ToolStarted(name) => { + agent::AgentUpdate::ToolStarted { attempt_id, name } => { self.model.agent.activity = format!("Using {}", display_agent_tool(&name)); + log::debug!( + "android_agent_ui_transition attempt={attempt_id} state=tool_started correlation=serialized" + ); } - agent::AgentUpdate::ToolFinished { name, ok } => { + agent::AgentUpdate::ToolFinished { + attempt_id, + name, + ok, + } => { self.model.agent.activity = if ok { format!("{} complete", display_agent_tool(&name)) } else { format!("{} failed", display_agent_tool(&name)) }; + log::debug!( + "android_agent_ui_transition attempt={attempt_id} state=tool_finished ok={ok} correlation=serialized" + ); } - agent::AgentUpdate::Candidate { source, summary } => { + agent::AgentUpdate::Candidate { + attempt_id, + source, + summary, + } => { push_agent_message(&mut self.model, AgentMessageRole::Assistant, summary); self.model.agent.activity = "Validating the proposed experience".into(); self.submit_agent_candidate_source(source); + log::info!( + "android_agent_ui_transition attempt={attempt_id} state=candidate_received correlation=serialized" + ); } - agent::AgentUpdate::Completed => { + agent::AgentUpdate::Completed { attempt_id } => { self.model.agent.busy = false; self.model.agent.activity = agent::status() .map(|status| status.activity) .unwrap_or_else(|_| "Agent ready".into()); + log::info!( + "android_agent_ui_transition attempt={attempt_id} state=response_complete correlation=serialized" + ); } - agent::AgentUpdate::Failed(error) => { + agent::AgentUpdate::Failed { + attempt_id, + failure, + } => { self.model.agent.busy = false; self.model.agent.activity = "Agent request failed".into(); - self.model.agent.error = Some(error); + self.model.agent.error = Some(failure.display.into()); + log::info!( + "android_agent_ui_transition attempt={attempt_id} state=response_failed stage={} category={} correlation=serialized", + failure.stage, + failure.category, + ); } } self.refresh_model_from_authority(); @@ -2784,6 +3176,10 @@ fn core_credential_overlay() -> impl IntoElement { .child(div().text_size(px(13.0)).child( "Memory-only until this Core process exits · deepseek/deepseek-v4-flash-0731", )) + .child(div().text_size(px(13.0)).child(format!( + "OpenRouter prefix is prefilled · {} characters entered after the prefix", + snapshot.suffix_count + ))) .child( div() .h(px(44.0)) @@ -2958,6 +3354,25 @@ impl Render for ExperienceHost { if WORKER_RESTART_REQUESTED.swap(false, Ordering::AcqRel) { self.restart_worker(cx); } + #[cfg(feature = "core-dev-credential")] + let core_dev_smoke_transport = CORE_DEV_AGENT_SMOKE_AUTHORIZATION + .lock() + .expect("Core-dev smoke authorization lock") + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT); + #[cfg(feature = "core-dev-credential")] + if let Some(transport) = core_dev_smoke_transport { + self.queue_input_event_with_transport( + SceneEvent { + action: "agent_submit".into(), + target: Some("agent-prompt".into()), + value: Some(CORE_DEV_AGENT_SMOKE_PROMPT.into()), + focused: Some(true), + ..Default::default() + }, + Some(transport), + cx, + ); + } let stress_request = stress_request_slot() .lock() .expect("stress request lock") @@ -3047,6 +3462,20 @@ fn find_text_session<'a>(node: &'a SceneNode, id: &str) -> Option<&'a experience .find_map(|child| find_text_session(child, id)) } +fn find_submit_session<'a>( + node: &'a SceneNode, + action: &str, +) -> Option<&'a experience_ir::TextSession> { + if let Some(Content::TextSession(input)) = &node.content { + if input.submit_action.as_deref() == Some(action) { + return Some(input); + } + } + node.children + .iter() + .find_map(|child| find_submit_session(child, action)) +} + fn stress_request_slot() -> &'static Mutex> { STRESS_REQUEST.get_or_init(|| Mutex::new(None)) } diff --git a/apps/experience/src/android/agent.rs b/apps/experience/src/android/agent.rs index 7035f48..f9a9f2d 100644 --- a/apps/experience/src/android/agent.rs +++ b/apps/experience/src/android/agent.rs @@ -3,6 +3,8 @@ use std::io::{Read, Write}; #[cfg(feature = "core-native")] use std::os::fd::AsRawFd; #[cfg(feature = "core-native")] +use std::os::unix::process::ExitStatusExt; +#[cfg(feature = "core-native")] use std::process::{Child, Command, ExitStatus, Stdio}; #[cfg(feature = "core-native")] use std::sync::{ @@ -26,10 +28,17 @@ use serde::Serialize; use zeroize::{Zeroize, Zeroizing}; use crate::android_agent_contract::{ - expected_model, model_is_exact, reconciled_request_error, verified_action_sequence, + expected_model, model_is_exact, reconciled_request_error, safe_failure_category, + safe_failure_stage, safe_http_status, ui_failure, verified_action_sequence, AgentUiAttempt, + AgentUiFailure, +}; +#[cfg(feature = "core-native")] +use crate::android_agent_contract::{ + pi_timeout_seconds, safe_core_launch_cause, SafeCorePiFailure, CORE_CHILD_LAUNCH, + CORE_NODE_ARGS, OPENROUTER_MODEL, }; #[cfg(feature = "core-native")] -use crate::android_agent_contract::{pi_timeout_seconds, OPENROUTER_MODEL}; +use crate::core_child_fds::restrict_to_standard_fds; #[cfg(feature = "core-native")] use crate::core_credential::{CeremonySnapshot, CredentialState}; use crate::deterministic_agent_candidate; @@ -46,16 +55,37 @@ pub struct AgentStatus { #[derive(Clone, Debug, PartialEq, Eq)] pub enum AgentUpdate { - Started { prompt: String }, - ToolStarted(String), - ToolFinished { name: String, ok: bool }, - Candidate { source: String, summary: String }, - Completed, - Failed(String), + Started { + attempt_id: u64, + prompt: String, + }, + ToolStarted { + attempt_id: u64, + name: String, + }, + ToolFinished { + attempt_id: u64, + name: String, + ok: bool, + }, + Candidate { + attempt_id: u64, + source: String, + summary: String, + }, + Completed { + attempt_id: u64, + }, + Failed { + attempt_id: u64, + failure: AgentUiFailure, + }, } #[derive(Deserialize)] struct LiveEnvelope { + protocol_version: Option, + terminal: Option, #[cfg(not(feature = "core-native"))] #[serde(default)] ok: Option, @@ -106,6 +136,37 @@ fn core_credential() -> &'static Mutex { CORE_CREDENTIAL.get_or_init(|| Mutex::new(CredentialState::default())) } +#[cfg(feature = "core-dev-credential")] +pub(super) fn install_dev_openrouter_credential(key: &[u8]) -> bool { + let installed = core_credential() + .lock() + .expect("Core credential lock") + .install_openrouter(key); + if installed { + CORE_CREDENTIAL_CHANGED.store(true, Ordering::Release); + log::info!("core_dev_credential state=set"); + } + installed +} + +#[cfg(feature = "core-dev-credential")] +pub(super) fn clear_dev_openrouter_credential() { + core_credential() + .lock() + .expect("Core credential lock") + .clear(); + CORE_CREDENTIAL_CHANGED.store(true, Ordering::Release); + log::info!("core_dev_credential state=cleared"); +} + +#[cfg(feature = "core-dev-credential")] +pub(super) fn dev_openrouter_credential_configured() -> bool { + core_credential() + .lock() + .expect("Core credential lock") + .configured() +} + #[cfg(feature = "core-native")] struct ReapedChild { child: Option, @@ -136,7 +197,12 @@ impl Drop for ReapedChild { fn drop(&mut self) { if let Some(child) = self.child.as_mut() { let _ = child.kill(); - let _ = child.wait(); + match child.wait() { + Ok(status) => log_child_exit(status, "forced_cleanup"), + Err(_) => log::warn!( + "android_agent_child_exit code=unknown signal=unknown cleanup=wait_failed platform=core" + ), + } } } } @@ -272,59 +338,121 @@ pub fn clear_credential() -> Result<(), String> { call_bool("clearCredential") } +fn accepted_request_marker(attempt: &mut AgentUiAttempt) -> Result { + let marker = attempt.accepted_marker()?; + if !marker.starts_with("android_agent_request_accepted provider=") { + return Err("agent request acceptance marker violated its sanitized prefix"); + } + Ok(marker) +} + pub fn spawn_prompt( + attempt: AgentUiAttempt, status: AgentStatus, prompt: String, current_source: String, model: ExperienceModel, updates: Sender, -) { +) -> Result<(), String> { let provider = status.provider.clone(); let model_name = expected_model(&provider).unwrap_or("unsupported"); - log::info!("android_agent_thread_start provider={provider} model={model_name}"); + let core_dev_tunnel = attempt.uses_core_dev_fixed_tunnel(); thread::Builder::new() .name("sos-android-agent".into()) .spawn(move || { - let _ = updates.send_blocking(AgentUpdate::Started { - prompt: prompt.clone(), - }); - if let Err(error) = run_prompt(&status, &prompt, ¤t_source, &model, &updates) { - let (stage, category) = failure_marker(&error); - log::warn!( - "android_agent_failure stage={stage} category={category} model={model_name}" + let attempt_id = attempt.attempt_id(); + let mut attempt = attempt; + let accepted_marker = accepted_request_marker(&mut attempt) + .expect("agent request acceptance must follow UI dispatch"); + log::info!("{accepted_marker}"); + if updates + .send_blocking(AgentUpdate::Started { + attempt_id, + prompt: prompt.clone(), + }) + .is_err() + { + let failure = ui_failure("dispatch", "dispatch_channel"); + super::log_agent_attempt_event( + attempt + .terminal(Some(failure)) + .expect("agent attempt must have one terminal"), ); - let _ = updates.send_blocking(AgentUpdate::Failed(error)); + return; + } + match run_prompt( + attempt_id, + &status, + &prompt, + ¤t_source, + &model, + &updates, + core_dev_tunnel, + ) { + Ok(()) => { + super::log_agent_attempt_event( + attempt + .terminal(None) + .expect("agent attempt must have one terminal"), + ); + let _ = updates.send_blocking(AgentUpdate::Completed { attempt_id }); + } + Err(error) => { + let (stage, category) = failure_marker(&error); + let status = failure_http_status(&error) + .map_or("none".to_owned(), |status| status.to_string()); + log::warn!( + "android_agent_request_result attempt={attempt_id} stage={stage} category={category} status={status} model={model_name} correlation=serialized" + ); + let failure = ui_failure(stage, category); + super::log_agent_attempt_event( + attempt + .terminal(Some(failure)) + .expect("agent attempt must have one terminal"), + ); + let _ = updates.send_blocking(AgentUpdate::Failed { + attempt_id, + failure, + }); + } } }) - .expect("Android agent thread must start"); + .map(|_| ()) + .map_err(|_| "Android agent thread could not start".to_owned()) } fn failure_marker(error: &str) -> (&'static str, &'static str) { - if error.contains("[credential/") { - ("credential", "credential_error") - } else if error.contains("[provider/") { - ("provider", "provider_error") - } else if error.contains("[validation/invalid_candidate") { - ("validation", "invalid_candidate") - } else if error.contains("[protocol/wrong_model") { - ("protocol", "wrong_model") - } else if error.contains("[protocol/tool_sequence") { - ("protocol", "tool_sequence") - } else if error.contains("[child/") { - ("child", "process_failure") - } else if error.contains("[bridge/") { - ("bridge", "bridge_failure") - } else { - ("protocol", "request_failed") - } + let Some((_, tagged)) = error.rsplit_once('[') else { + return ("protocol", "unknown"); + }; + let Some((stage, rest)) = tagged.split_once('/') else { + return ("protocol", "unknown"); + }; + let category = rest.split([';', ']']).next(); + ( + safe_failure_stage(Some(stage)), + safe_failure_category(category), + ) +} + +fn failure_http_status(error: &str) -> Option { + let marker = "; HTTP "; + let start = error.find(marker)? + marker.len(); + let digits = error[start..].split([';', ']']).next()?; + digits + .parse() + .ok() + .filter(|status| (100..=599).contains(status)) } fn run_prompt( + attempt_id: u64, status: &AgentStatus, prompt: &str, current_source: &str, model: &ExperienceModel, updates: &Sender, + core_dev_tunnel: bool, ) -> Result<(), String> { let model_name = expected_model(&status.provider).unwrap_or("unsupported"); log::info!( @@ -336,36 +464,51 @@ fn run_prompt( } else { None }; - let candidate = run_live(&status.provider, prompt, current_source, faux_candidate)?; - emit_completed_tool(updates, "get_experience_context")?; - emit_tool(updates, "validate_experience", || { + let candidate = run_live( + &status.provider, + prompt, + current_source, + faux_candidate, + core_dev_tunnel, + )?; + emit_completed_tool(attempt_id, updates, "get_experience_context")?; + emit_tool(attempt_id, updates, "validate_experience", || { validate_candidate(&candidate.source, model) })?; - emit_completed_tool(updates, "submit_experience")?; + emit_completed_tool(attempt_id, updates, "submit_experience")?; updates .send_blocking(AgentUpdate::Candidate { + attempt_id, source: candidate.source, summary: candidate.summary, }) .map_err(|_| "agent host stopped receiving updates".to_owned())?; - let _ = updates.send_blocking(AgentUpdate::Completed); Ok(()) } -fn emit_completed_tool(updates: &Sender, name: &str) -> Result<(), String> { - emit_tool(updates, name, || Ok(())) +fn emit_completed_tool( + attempt_id: u64, + updates: &Sender, + name: &str, +) -> Result<(), String> { + emit_tool(attempt_id, updates, name, || Ok(())) } fn emit_tool( + attempt_id: u64, updates: &Sender, name: &str, operation: impl FnOnce() -> Result, ) -> Result { updates - .send_blocking(AgentUpdate::ToolStarted(name.into())) + .send_blocking(AgentUpdate::ToolStarted { + attempt_id, + name: name.into(), + }) .map_err(|_| "agent host stopped receiving updates".to_owned())?; let result = operation(); let _ = updates.send_blocking(AgentUpdate::ToolFinished { + attempt_id, name: name.into(), ok: result.is_ok(), }); @@ -395,48 +538,8 @@ fn validate_candidate(source: &str, model: &ExperienceModel) -> Result<(), Strin } fn structured_failure(envelope: &LiveEnvelope, expected_model: &str) -> String { - const STAGES: [&str; 7] = [ - "request", - "credential", - "provider", - "protocol", - "validation", - "bridge", - "child", - ]; - const CATEGORIES: [&str; 21] = [ - "invalid_request", - "credential_rejected", - "provider_rejected", - "rate_limited", - "provider_unavailable", - "provider_error", - "tool_sequence", - "invalid_candidate", - "protocol_error", - "internal", - "launch_failure", - "timeout", - "response_timeout", - "response_io", - "empty_response", - "linker_or_exit", - "invalid_response", - "unexpected_response", - "wrong_model", - "refresh_failed", - "request_io", - ]; - let stage = envelope - .stage - .as_deref() - .filter(|value| STAGES.contains(value)) - .unwrap_or("protocol"); - let category = envelope - .category - .as_deref() - .filter(|value| CATEGORIES.contains(value)) - .unwrap_or("protocol_error"); + let stage = safe_failure_stage(envelope.stage.as_deref()); + let category = safe_failure_category(envelope.category.as_deref()); let (stage, category) = if envelope .model .as_deref() @@ -446,19 +549,21 @@ fn structured_failure(envelope: &LiveEnvelope, expected_model: &str) -> String { } else { (stage, category) }; - let status = envelope - .status - .filter(|status| (100..=599).contains(status)); - log::warn!( - "android_agent_failure stage={stage} category={category} model={expected_model} status={}", - status.map_or("none".to_owned(), |value| value.to_string()) - ); + let status = safe_http_status(envelope.status); let detail = match category { "credential_rejected" => "The provider rejected the configured credential.", "provider_rejected" => "The provider rejected this request.", "rate_limited" => "The provider rate-limited this request.", "provider_unavailable" => "The provider is temporarily unavailable.", "provider_error" => "The provider request failed.", + "dns_resolution" => "The provider hostname could not be resolved.", + "dns_timeout" => "Provider DNS resolution timed out.", + "dns_proxy_unavailable" => "The Android DNS proxy was unavailable.", + "connect_timeout" => "The provider connection timed out.", + "connect_refused" => "The provider connection was refused.", + "connect_reset" => "The provider connection was reset.", + "network_unreachable" => "The provider network was unreachable.", + "tls_failure" => "The provider TLS handshake or certificate validation failed.", "invalid_request" => "The Pi request was invalid.", "tool_sequence" => "Pi used an invalid authoring tool sequence.", "invalid_candidate" => "Pi proposed an invalid candidate.", @@ -474,6 +579,7 @@ fn structured_failure(envelope: &LiveEnvelope, expected_model: &str) -> String { "refresh_failed" => "The refreshed provider credential could not be stored.", "request_io" => "The local Pi process could not accept its request.", "internal" => "The trusted on-device Pi bridge failed.", + "unknown" => "The provider failure category was unknown.", _ => "The Pi protocol failed.", }; match status { @@ -546,28 +652,82 @@ fn common_pi_timeout_error(timeout: Duration) -> String { ) } +#[cfg(feature = "core-native")] +fn process_status_failure(status: ExitStatus) -> SafeCorePiFailure { + if let Some(code) = status.code() { + SafeCorePiFailure::unsuccessful_exit(code) + } else { + SafeCorePiFailure::signal(status.signal().unwrap_or(0)) + } +} + +#[cfg(feature = "core-native")] +fn log_child_exit(status: ExitStatus, cleanup: &str) { + log::info!( + "android_agent_child_exit code={} signal={} cleanup={cleanup} platform=core", + status + .code() + .map_or("none".to_owned(), |code| code.to_string()), + status + .signal() + .map_or("none".to_owned(), |signal| signal.to_string()) + ); +} + +#[cfg(feature = "core-native")] +fn report_core_pi_failure(failure: SafeCorePiFailure, model: &str) -> String { + log::warn!( + "android_agent_failure stage={} category={} model={model}{}", + failure.stage, + failure.category, + failure.safe_metadata() + ); + failure.tagged_error(model) +} + #[cfg(feature = "core-native")] fn run_core_pi( request: &[u8], timeout: Duration, + model: &str, + core_dev_tunnel: bool, ) -> Result<(ExitStatus, Zeroizing>), String> { - let child = Command::new("/system_ext/bin/sos-node") - .args([ - "/system_ext/etc/sos-agent/agent-runner.cjs", - "stdio", - "--api-doc", - "/system_ext/etc/sos-agent/experience-api.md", - "--example", - "/system_ext/etc/sos-agent/example-primary.luau", - "--example-secondary", - "/system_ext/etc/sos-agent/example-secondary.luau", - ]) + let mut command = Command::new(CORE_CHILD_LAUNCH.node_path); + command + .args(CORE_NODE_ARGS) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(|error| format!("start common Pi runner: {error}"))?; - log::info!("android_agent_child_start pid={} platform=core", child.id()); + .stderr(Stdio::null()); + restrict_to_standard_fds(&mut command); + let child = command.spawn().map_err(|error| { + log::warn!( + "android_agent_child_launch_failure cause={} node={} runner={} expected_domain={} platform=core", + safe_core_launch_cause(error.kind()), + CORE_CHILD_LAUNCH.node_identity, + CORE_CHILD_LAUNCH.runner_identity, + CORE_CHILD_LAUNCH.expected_domain, + ); + "start common Pi runner".to_owned() + })?; + #[cfg(feature = "core-dev-credential")] + let transport = if core_dev_tunnel { + "adb_reverse_connect" + } else { + "direct" + }; + #[cfg(not(feature = "core-dev-credential"))] + let transport = { + debug_assert!(!core_dev_tunnel); + "direct" + }; + log::info!( + "android_agent_child_start pid={} expected_domain={} node={} runner={} provider_identity=openrouter model={model} platform=core hardening=jitless fd_boundary=stdio_only stderr=discarded transport={}", + child.id(), + CORE_CHILD_LAUNCH.expected_domain, + CORE_CHILD_LAUNCH.node_identity, + CORE_CHILD_LAUNCH.runner_identity, + transport, + ); let mut child = ReapedChild::new(child); let mut input = Some( child @@ -616,6 +776,9 @@ fn run_core_pi( written += count; if written == request.len() { input.take(); + log::info!( + "android_agent_child_request state=written provider_identity=openrouter model={model}" + ); } } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} @@ -663,12 +826,7 @@ fn run_core_pi( poll_until(&mut [], deadline, Duration::from_millis(10), timeout)?; } let status = child.finish()?; - log::info!( - "android_agent_child_exit code={} platform=core", - status - .code() - .map_or("signal".to_owned(), |code| code.to_string()) - ); + log_child_exit(status, "normal"); Ok((status, response)) } @@ -678,6 +836,7 @@ fn run_live( prompt: &str, current_source: &str, faux_candidate: Option<&str>, + core_dev_tunnel: bool, ) -> Result { if prompt.is_empty() || prompt.len() > 32 * 1024 { return Err("agent prompt is outside the bounded size".into()); @@ -706,6 +865,9 @@ fn run_live( credential: CredentialRequest<'a>, prompt: &'a str, current_source: &'a str, + #[cfg(feature = "core-dev-credential")] + #[serde(skip_serializing_if = "Option::is_none")] + core_dev_proxy: Option<&'static str>, } let credential = faux_candidate @@ -742,6 +904,8 @@ fn run_live( }, prompt, current_source, + #[cfg(feature = "core-dev-credential")] + core_dev_proxy: core_dev_tunnel.then_some("http://127.0.0.1:37173"), }) } .map(Zeroizing::new) @@ -755,30 +919,47 @@ fn run_live( pi_timeout_seconds(provider) .ok_or_else(|| "Core selected an unsupported Pi provider".to_owned())?, ); - let (status, response) = run_core_pi(&request, timeout).map_err(|error| { - let (category, detail) = if error.contains("timed out") { - ("timeout", "The local Pi process timed out.") - } else if error.starts_with("start common Pi runner") { - ("launch_failure", "The local Pi process could not start.") + let expected_model = expected_model(provider) + .ok_or_else(|| "Core selected an unsupported Pi provider".to_owned())?; + let (status, response) = run_core_pi(&request, timeout, expected_model, core_dev_tunnel) + .map_err(|error| { + let failure = if error.contains("timed out") { + SafeCorePiFailure::timeout() + } else if error.starts_with("start common Pi runner") { + SafeCorePiFailure::launch() + } else if error.contains("request") || error.contains("stdin") { + SafeCorePiFailure::request_io() + } else if error.contains("response") || error.contains("stdout") { + SafeCorePiFailure::response_io() + } else { + SafeCorePiFailure::process_io() + }; + report_core_pi_failure(failure, expected_model) + })?; + let line = response + .split(|byte| *byte == b'\n') + .rev() + .find(|line| !line.is_empty()); + let Some(line) = line else { + let failure = if status.success() { + SafeCorePiFailure::empty_response(status.code().unwrap_or(0)) } else { - ("process_io", "The local Pi process failed.") + process_status_failure(status) }; - log::warn!( - "android_agent_failure stage=child category={category} model={}", - expected_model(provider).unwrap_or("unsupported") - ); - format!( - "{detail} [child/{category}; model {}]", - expected_model(provider).unwrap_or("unsupported") + return Err(report_core_pi_failure(failure, expected_model)); + }; + let envelope: LiveEnvelope = serde_json::from_slice(line).map_err(|_| { + report_core_pi_failure( + SafeCorePiFailure::invalid_response(status.code(), status.signal()), + expected_model, ) })?; - let line = response - .split(|byte| *byte == b'\n') - .rev() - .find(|line| !line.is_empty()) - .ok_or_else(|| "common Pi runner returned no response".to_owned())?; - let envelope: LiveEnvelope = serde_json::from_slice(line) - .map_err(|_| "common Pi runner returned an invalid response".to_owned())?; + if envelope.protocol_version != Some(2) { + return Err(report_core_pi_failure( + SafeCorePiFailure::invalid_response(status.code(), status.signal()), + expected_model, + )); + } let response_type = if envelope.source.is_some() { "prompt_complete" } else if envelope.category.is_some() { @@ -786,17 +967,31 @@ fn run_live( } else { "unexpected" }; + let expected_terminal = if response_type == "prompt_complete" { + "completed" + } else if response_type == "error" { + "failed" + } else { + "unknown" + }; + if envelope.terminal.as_deref() != Some(expected_terminal) { + return Err(report_core_pi_failure( + SafeCorePiFailure::invalid_response(status.code(), status.signal()), + expected_model, + )); + } log::info!( - "android_agent_child_response type={response_type} provider={provider} model={}", - expected_model(provider).unwrap_or("unsupported") + "android_agent_child_response_header protocol=2 type={response_type} terminal={expected_terminal} provider={provider} model={expected_model}" ); - if !status.success() { - let expected_model = expected_model(provider) - .ok_or_else(|| "Core selected an unsupported Pi provider".to_owned())?; + if envelope.category.is_some() { return Err(structured_failure(&envelope, expected_model)); } - let expected_model = expected_model(provider) - .ok_or_else(|| "Core selected an unsupported Pi provider".to_owned())?; + if !status.success() { + return Err(report_core_pi_failure( + process_status_failure(status), + expected_model, + )); + } if !envelope .model .as_deref() @@ -846,7 +1041,9 @@ fn run_live( prompt: &str, current_source: &str, faux_candidate: Option<&str>, + core_dev_tunnel: bool, ) -> Result { + debug_assert!(!core_dev_tunnel); with_env(|env| { let helper = find_app_class(env, HELPER_CLASS)?; let activity = activity(env)?; diff --git a/apps/experience/src/android/native_input.rs b/apps/experience/src/android/native_input.rs index d7502e1..bd11a1b 100644 --- a/apps/experience/src/android/native_input.rs +++ b/apps/experience/src/android/native_input.rs @@ -606,18 +606,26 @@ impl NativeTextInput { } pub fn apply_ime_state(&mut self, state: ImeState, cx: &mut Context) -> ImeApplyOutcome { - log::info!( - "ime_state_applied node_id={} kind={} selection={}:{} marked={}", - self.node_id, - state.kind, - state.selection_start, - state.selection_end, - state - .marked - .as_ref() - .map(|range| format!("{}:{}", range.start, range.end)) - .unwrap_or_else(|| "none".into()) - ); + if self.submit_action.as_deref() == Some("agent_submit") { + log::debug!( + "ime_state_applied node_id={} kind={} content=redacted", + self.node_id, + state.kind, + ); + } else { + log::info!( + "ime_state_applied node_id={} kind={} selection={}:{} marked={}", + self.node_id, + state.kind, + state.selection_start, + state.selection_end, + state + .marked + .as_ref() + .map(|range| format!("{}:{}", range.start, range.end)) + .unwrap_or_else(|| "none".into()) + ); + } let mut text = state.text.replace(['\r', '\n'], ""); if text.len() > MAX_TEXT_BYTES { let mut end = MAX_TEXT_BYTES; diff --git a/apps/experience/src/android_agent_contract.rs b/apps/experience/src/android_agent_contract.rs index 74fc477..6e2414d 100644 --- a/apps/experience/src/android_agent_contract.rs +++ b/apps/experience/src/android_agent_contract.rs @@ -11,6 +11,595 @@ pub const VERIFIED_ACTIONS: [&str; 3] = [ "validate_experience", "submit_experience", ]; +#[cfg(any(feature = "core-dev-credential", test))] +pub const CORE_DEV_AGENT_SMOKE_PROMPT: &str = + "Create one visible item titled Blue smoke check with body Fixed Core development smoke item."; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentUiTransport { + ValidatedNetwork, + #[cfg(any(feature = "core-dev-credential", test))] + CoreDevFixedTunnel, +} + +#[cfg(any(feature = "core-dev-credential", test))] +#[derive(Debug, Default, PartialEq, Eq)] +pub struct CoreDevSmokeAuthorization { + armed: bool, +} + +#[cfg(any(feature = "core-dev-credential", test))] +impl CoreDevSmokeAuthorization { + pub const fn new() -> Self { + Self { armed: false } + } + + pub fn arm_authenticated(&mut self) -> bool { + if self.armed { + return false; + } + self.armed = true; + true + } + + pub fn consume_fixed_prompt(&mut self, prompt: &str) -> Option { + if !std::mem::take(&mut self.armed) || prompt != CORE_DEV_AGENT_SMOKE_PROMPT { + return None; + } + Some(AgentUiTransport::CoreDevFixedTunnel) + } + + #[cfg(test)] + pub fn is_armed(&self) -> bool { + self.armed + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentUiAttemptEventKind { + Received, + DispatchStarted, + Terminal, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AgentUiAttemptEvent { + pub attempt_id: u64, + pub kind: AgentUiAttemptEventKind, + pub provider: &'static str, + pub model: &'static str, + pub configured: bool, + pub busy: bool, + pub input_present: bool, + pub stage: &'static str, + pub category: &'static str, +} + +impl AgentUiAttemptEvent { + pub fn request_terminal_marker(&self) -> Result { + if self.kind != AgentUiAttemptEventKind::Terminal { + return Err("agent request terminal marker requires a terminal event"); + } + Ok(format!( + "android_agent_request_terminal stage={} category={} provider={} model={} model_policy=pinned attempt={} correlation=serialized", + self.stage, self.category, self.provider, self.model, self.attempt_id + )) + } + + pub fn ui_terminal_marker(&self) -> Result { + if self.kind != AgentUiAttemptEventKind::Terminal { + return Err("agent UI terminal marker requires a terminal event"); + } + let status = if self.category == "completed" { + "completed" + } else { + "failed" + }; + Ok(format!( + "android_agent_ui_terminal status={status} stage={} category={} provider={} model={} model_policy=pinned attempt={} correlation=serialized", + self.stage, self.category, self.provider, self.model, self.attempt_id + )) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AgentUiFailure { + pub stage: &'static str, + pub category: &'static str, + pub display: &'static str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AgentUiAttemptPhase { + Received, + DispatchStarted, + Accepted, + Terminal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentUiAttempt { + attempt_id: u64, + provider: &'static str, + model: &'static str, + configured: bool, + busy: bool, + input_present: bool, + network_available: bool, + transport: AgentUiTransport, + phase: AgentUiAttemptPhase, +} + +impl AgentUiAttempt { + pub fn receive( + attempt_id: u64, + provider: &str, + configured: bool, + busy: bool, + input_present: bool, + network_available: bool, + ) -> (Self, AgentUiAttemptEvent) { + let provider = safe_provider_identity(provider); + let model = expected_model(provider).unwrap_or("unsupported"); + let attempt = Self { + attempt_id, + provider, + model, + configured, + busy, + input_present, + network_available, + transport: AgentUiTransport::ValidatedNetwork, + phase: AgentUiAttemptPhase::Received, + }; + let received = attempt.event(AgentUiAttemptEventKind::Received, "ui", "none"); + (attempt, received) + } + + pub fn preflight(&self) -> Result<(), AgentUiFailure> { + if !self.input_present { + Err(ui_failure("preflight", "empty_input")) + } else if self.busy { + Err(ui_failure("preflight", "busy")) + } else if self.model == "unsupported" { + Err(ui_failure("preflight", "model_policy")) + } else if self.provider != "fake" && !self.configured { + Err(ui_failure("preflight", "credential_missing")) + } else if self.provider != "fake" && !self.transport_ready(self.network_available) { + Err(ui_failure("preflight", "network_unavailable")) + } else { + Ok(()) + } + } + + pub fn dispatch_started(&mut self) -> Result { + if self.phase != AgentUiAttemptPhase::Received { + return Err("agent UI attempt dispatch is out of order"); + } + self.phase = AgentUiAttemptPhase::DispatchStarted; + Ok(self.event(AgentUiAttemptEventKind::DispatchStarted, "dispatch", "none")) + } + + pub fn accepted_marker(&mut self) -> Result { + if self.phase != AgentUiAttemptPhase::DispatchStarted { + return Err("agent UI attempt acceptance is out of order"); + } + self.phase = AgentUiAttemptPhase::Accepted; + Ok(format!( + "android_agent_request_accepted provider={} model={} model_policy=pinned attempt={} correlation=serialized", + self.provider, self.model, self.attempt_id + )) + } + + pub fn terminal( + &mut self, + failure: Option, + ) -> Result { + if self.phase == AgentUiAttemptPhase::Terminal { + return Err("agent UI attempt already has a terminal event"); + } + self.phase = AgentUiAttemptPhase::Terminal; + let (stage, category) = failure + .map(|failure| (failure.stage, failure.category)) + .unwrap_or(("ui", "completed")); + Ok(self.event(AgentUiAttemptEventKind::Terminal, stage, category)) + } + + pub fn attempt_id(&self) -> u64 { + self.attempt_id + } + + pub fn provider(&self) -> &'static str { + self.provider + } + + #[cfg(any(feature = "core-dev-credential", test))] + pub fn receive_core_dev_fixed_smoke( + attempt_id: u64, + provider: &str, + configured: bool, + busy: bool, + prompt: &str, + transport: AgentUiTransport, + ) -> Result<(Self, AgentUiAttemptEvent), AgentUiFailure> { + if transport != AgentUiTransport::CoreDevFixedTunnel + || prompt != CORE_DEV_AGENT_SMOKE_PROMPT + || safe_provider_identity(provider) != "openrouter" + { + return Err(ui_failure("preflight", "model_policy")); + } + let (mut attempt, received) = + Self::receive(attempt_id, provider, configured, busy, true, false); + attempt.transport = AgentUiTransport::CoreDevFixedTunnel; + Ok((attempt, received)) + } + + #[cfg(test)] + pub fn transport(&self) -> AgentUiTransport { + self.transport + } + + pub fn uses_core_dev_fixed_tunnel(&self) -> bool { + match self.transport { + AgentUiTransport::ValidatedNetwork => false, + #[cfg(any(feature = "core-dev-credential", test))] + AgentUiTransport::CoreDevFixedTunnel => true, + } + } + + pub fn transport_ready(&self, network_available: bool) -> bool { + match self.transport { + AgentUiTransport::ValidatedNetwork => network_available, + #[cfg(any(feature = "core-dev-credential", test))] + AgentUiTransport::CoreDevFixedTunnel => true, + } + } + + pub fn prompt_matches_transport(&self, _prompt: &str) -> bool { + match self.transport { + AgentUiTransport::ValidatedNetwork => true, + #[cfg(any(feature = "core-dev-credential", test))] + AgentUiTransport::CoreDevFixedTunnel => _prompt == CORE_DEV_AGENT_SMOKE_PROMPT, + } + } + + fn event( + &self, + kind: AgentUiAttemptEventKind, + stage: &'static str, + category: &'static str, + ) -> AgentUiAttemptEvent { + AgentUiAttemptEvent { + attempt_id: self.attempt_id, + kind, + provider: self.provider, + model: self.model, + configured: self.configured, + busy: self.busy, + input_present: self.input_present, + stage, + category, + } + } +} + +pub fn safe_provider_identity(provider: &str) -> &'static str { + match provider { + "fake" | "faux" => "fake", + "openrouter" => "openrouter", + "openai" => "openai", + "openai-codex" => "openai-codex", + _ => "unsupported", + } +} + +pub fn ui_failure(stage: &str, category: &str) -> AgentUiFailure { + let stage = safe_ui_failure_stage(stage); + let category = safe_ui_failure_category(category); + let display = match category { + "empty_input" => "Enter a request before submitting.", + "credential_missing" => "The selected provider is not configured.", + "busy" => "SOS is already handling a request.", + "model_policy" => "The selected provider does not match the pinned model policy.", + "dispatch_channel" => "The request could not reach the experience runtime.", + "runtime_start" => "The request runtime could not start.", + "network_unavailable" => "Connect to a validated network before submitting.", + "credential_rejected" => "The provider rejected the configured credential.", + "provider_rejected" => "The provider rejected this request.", + "rate_limited" => "The provider rate-limited this request.", + "provider_unavailable" => "The provider is temporarily unavailable.", + "provider_error" => "The provider request failed.", + "dns_resolution" => "The provider hostname could not be resolved.", + "dns_timeout" => "Provider DNS resolution timed out.", + "dns_proxy_unavailable" => "The Android DNS proxy was unavailable.", + "connect_timeout" => "The provider connection timed out.", + "connect_refused" => "The provider connection was refused.", + "connect_reset" => "The provider connection was reset.", + "network_unreachable" => "The provider network was unreachable.", + "tls_failure" => "The provider TLS handshake or certificate validation failed.", + "invalid_request" => "The Pi request was invalid.", + "tool_sequence" => "Pi used an invalid authoring tool sequence.", + "invalid_candidate" => "Pi proposed an invalid candidate.", + "wrong_model" => "Pi returned a response for the wrong model.", + "launch_failure" => "The local Pi process could not start.", + "timeout" => "The local Pi process timed out.", + "response_timeout" => "The local Pi response reader timed out.", + "response_io" => "The local Pi response could not be read.", + "empty_response" => "The local Pi process returned no response.", + "linker_or_exit" | "exit_failure" | "signal" => { + "The local Pi process exited before returning a response." + } + "process_io" => "The local Pi process could not be observed.", + "invalid_response" => "The local Pi process returned an invalid response.", + "unexpected_response" => "The local Pi process returned an unexpected response type.", + "refresh_failed" => "The refreshed provider credential could not be stored.", + "request_io" => "The local Pi process could not accept its request.", + "internal" => "The trusted on-device Pi bridge failed.", + "protocol_error" => "The Pi protocol failed.", + _ => "The provider failure category was unknown.", + }; + AgentUiFailure { + stage, + category, + display, + } +} + +fn safe_ui_failure_stage(value: &str) -> &'static str { + match value { + "preflight" => "preflight", + "dispatch" => "dispatch", + "runtime" => "runtime", + other => safe_failure_stage(Some(other)), + } +} + +fn safe_ui_failure_category(value: &str) -> &'static str { + match value { + "empty_input" => "empty_input", + "credential_missing" => "credential_missing", + "busy" => "busy", + "model_policy" => "model_policy", + "dispatch_channel" => "dispatch_channel", + "runtime_start" => "runtime_start", + "network_unavailable" => "network_unavailable", + other => safe_failure_category(Some(other)), + } +} + +pub fn safe_failure_stage(value: Option<&str>) -> &'static str { + match value { + Some("request") => "request", + Some("credential") => "credential", + Some("transport") => "transport", + Some("provider") => "provider", + Some("protocol") => "protocol", + Some("validation") => "validation", + Some("bridge") => "bridge", + Some("child") => "child", + _ => "protocol", + } +} + +pub fn safe_failure_category(value: Option<&str>) -> &'static str { + match value { + Some("invalid_request") => "invalid_request", + Some("credential_rejected") => "credential_rejected", + Some("provider_rejected") => "provider_rejected", + Some("rate_limited") => "rate_limited", + Some("provider_unavailable") => "provider_unavailable", + Some("provider_error") => "provider_error", + Some("dns_resolution") => "dns_resolution", + Some("dns_timeout") => "dns_timeout", + Some("dns_proxy_unavailable") => "dns_proxy_unavailable", + Some("connect_timeout") => "connect_timeout", + Some("connect_refused") => "connect_refused", + Some("connect_reset") => "connect_reset", + Some("network_unreachable") => "network_unreachable", + Some("tls_failure") => "tls_failure", + Some("tool_sequence") => "tool_sequence", + Some("invalid_candidate") => "invalid_candidate", + Some("protocol_error") => "protocol_error", + Some("unknown") => "unknown", + Some("internal") => "internal", + Some("launch_failure") => "launch_failure", + Some("timeout") => "timeout", + Some("response_timeout") => "response_timeout", + Some("response_io") => "response_io", + Some("empty_response") => "empty_response", + Some("linker_or_exit") => "linker_or_exit", + Some("exit_failure") => "exit_failure", + Some("signal") => "signal", + Some("process_io") => "process_io", + Some("invalid_response") => "invalid_response", + Some("unexpected_response") => "unexpected_response", + Some("wrong_model") => "wrong_model", + Some("refresh_failed") => "refresh_failed", + Some("request_io") => "request_io", + _ => "unknown", + } +} + +pub fn safe_http_status(value: Option) -> Option { + value.filter(|status| (100..=599).contains(status)) +} + +#[cfg(any(feature = "core-native", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CoreChildLaunchContract { + pub node_path: &'static str, + pub runner_path: &'static str, + pub node_identity: &'static str, + pub runner_identity: &'static str, + pub expected_domain: &'static str, +} + +#[cfg(any(feature = "core-native", test))] +#[cfg(not(feature = "core-dev-credential"))] +pub const CORE_CHILD_LAUNCH: CoreChildLaunchContract = CoreChildLaunchContract { + node_path: "/system_ext/bin/sos-node", + runner_path: "/system_ext/etc/sos-agent/agent-runner.cjs", + node_identity: "ordinary_node", + runner_identity: "ordinary_runner", + expected_domain: "sos_core_agent", +}; + +#[cfg(any(feature = "core-native", test))] +#[cfg(feature = "core-dev-credential")] +pub const CORE_CHILD_LAUNCH: CoreChildLaunchContract = CoreChildLaunchContract { + node_path: "/system_ext/bin/sos-node-core-dev", + runner_path: "/system_ext/etc/sos-agent/agent-runner-core-dev.cjs", + node_identity: "core_dev_node", + runner_identity: "core_dev_runner", + expected_domain: "sos_core_dev_agent", +}; + +#[cfg(any(feature = "core-native", test))] +pub const CORE_NODE_ARGS: [&str; 9] = [ + "--jitless", + CORE_CHILD_LAUNCH.runner_path, + "stdio", + "--api-doc", + "/system_ext/etc/sos-agent/experience-api.md", + "--example", + "/system_ext/etc/sos-agent/example-primary.luau", + "--example-secondary", + "/system_ext/etc/sos-agent/example-secondary.luau", +]; + +#[cfg(any(feature = "core-native", test))] +pub fn safe_core_launch_cause(kind: std::io::ErrorKind) -> &'static str { + match kind { + std::io::ErrorKind::NotFound => "path_missing", + std::io::ErrorKind::PermissionDenied => "permission_denied", + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::OutOfMemory => "resource_exhausted", + std::io::ErrorKind::Unsupported => "unsupported", + _ => "other", + } +} + +#[cfg(any(feature = "core-native", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SafeCorePiFailure { + pub stage: &'static str, + pub category: &'static str, + pub detail: &'static str, + pub child_exit: Option, + pub signal: Option, +} + +#[cfg(any(feature = "core-native", test))] +impl SafeCorePiFailure { + pub const fn launch() -> Self { + Self::new( + "child", + "launch_failure", + "The local Pi process could not start.", + ) + } + + pub const fn timeout() -> Self { + Self::new("child", "timeout", "The local Pi process timed out.") + } + + pub const fn request_io() -> Self { + Self::new( + "child", + "request_io", + "The local Pi process could not accept its request.", + ) + } + + pub const fn response_io() -> Self { + Self::new( + "child", + "response_io", + "The local Pi response could not be read.", + ) + } + + pub const fn process_io() -> Self { + Self::new( + "child", + "process_io", + "The local Pi process could not be observed.", + ) + } + + pub const fn unsuccessful_exit(code: i32) -> Self { + Self::new( + "child", + "exit_failure", + "The local Pi process exited unsuccessfully.", + ) + .with_child_exit(code) + } + + pub const fn signal(signal: i32) -> Self { + Self::new( + "child", + "signal", + "The local Pi process was terminated by a signal.", + ) + .with_signal(signal) + } + + pub const fn empty_response(child_exit: i32) -> Self { + Self::new( + "protocol", + "empty_response", + "The local Pi process returned no response.", + ) + .with_child_exit(child_exit) + } + + pub const fn invalid_response(child_exit: Option, signal: Option) -> Self { + let mut failure = Self::new( + "protocol", + "invalid_response", + "The local Pi process returned an invalid response.", + ); + failure.child_exit = child_exit; + failure.signal = signal; + failure + } + + const fn new(stage: &'static str, category: &'static str, detail: &'static str) -> Self { + Self { + stage, + category, + detail, + child_exit: None, + signal: None, + } + } + + const fn with_child_exit(mut self, child_exit: i32) -> Self { + self.child_exit = Some(child_exit); + self + } + + const fn with_signal(mut self, signal: i32) -> Self { + self.signal = Some(signal); + self + } + + pub fn tagged_error(self, model: &str) -> String { + let metadata = self.safe_metadata(); + format!( + "{} [{}/{}{}; model={model}]", + self.detail, self.stage, self.category, metadata + ) + } + + pub fn safe_metadata(self) -> String { + match (self.child_exit, self.signal) { + (Some(code), None) => format!("; child_exit={code}"), + (None, Some(signal)) => format!("; signal={signal}"), + _ => String::new(), + } + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AgentActivationPhase { @@ -135,6 +724,32 @@ mod tests { assert_eq!(pi_timeout_seconds("unknown"), None); } + #[test] + fn structured_failures_are_allowlisted_and_unknown_content_stays_unknown() { + for (input, expected) in [ + ("dns_resolution", "dns_resolution"), + ("dns_timeout", "dns_timeout"), + ("dns_proxy_unavailable", "dns_proxy_unavailable"), + ("connect_timeout", "connect_timeout"), + ("connect_refused", "connect_refused"), + ("connect_reset", "connect_reset"), + ("network_unreachable", "network_unreachable"), + ("tls_failure", "tls_failure"), + ("rate_limited", "rate_limited"), + ("exit_failure", "exit_failure"), + ] { + assert_eq!(safe_failure_category(Some(input)), expected); + } + assert_eq!( + safe_failure_category(Some("ENOTFOUND\nandroid_agent_failure category=injected")), + "unknown" + ); + assert_eq!(safe_failure_stage(Some("provider\nchild")), "protocol"); + assert_eq!(safe_http_status(Some(429)), Some(429)); + assert_eq!(safe_http_status(Some(99)), None); + assert_eq!(safe_http_status(Some(600)), None); + } + #[test] fn routine_status_preserves_a_request_error_until_an_intentional_action() { let error = Some("Provider request failed (provider/rate_limited).".to_owned()); @@ -165,6 +780,106 @@ mod tests { } } + #[test] + fn core_node_hardening_precedes_the_fixed_script_and_request_mode() { + assert_eq!(CORE_NODE_ARGS[0], "--jitless"); + #[cfg(not(feature = "core-dev-credential"))] + assert_eq!( + CORE_CHILD_LAUNCH, + CoreChildLaunchContract { + node_path: "/system_ext/bin/sos-node", + runner_path: "/system_ext/etc/sos-agent/agent-runner.cjs", + node_identity: "ordinary_node", + runner_identity: "ordinary_runner", + expected_domain: "sos_core_agent", + } + ); + #[cfg(feature = "core-dev-credential")] + assert_eq!( + CORE_CHILD_LAUNCH, + CoreChildLaunchContract { + node_path: "/system_ext/bin/sos-node-core-dev", + runner_path: "/system_ext/etc/sos-agent/agent-runner-core-dev.cjs", + node_identity: "core_dev_node", + runner_identity: "core_dev_runner", + expected_domain: "sos_core_dev_agent", + } + ); + assert_eq!(CORE_NODE_ARGS[1], CORE_CHILD_LAUNCH.runner_path); + assert_eq!(CORE_NODE_ARGS[2], "stdio"); + assert_eq!( + CORE_NODE_ARGS + .iter() + .filter(|arg| **arg == "--jitless") + .count(), + 1 + ); + assert!(CORE_NODE_ARGS.iter().all(|arg| !arg.contains("credential"))); + } + + #[test] + fn launch_failure_causes_are_allowlisted_without_error_text() { + assert_eq!( + safe_core_launch_cause(std::io::ErrorKind::NotFound), + "path_missing" + ); + assert_eq!( + safe_core_launch_cause(std::io::ErrorKind::PermissionDenied), + "permission_denied" + ); + assert_eq!( + safe_core_launch_cause(std::io::ErrorKind::WouldBlock), + "resource_exhausted" + ); + assert_eq!( + safe_core_launch_cause(std::io::ErrorKind::Unsupported), + "unsupported" + ); + assert_eq!(safe_core_launch_cause(std::io::ErrorKind::Other), "other"); + } + + #[test] + fn core_child_and_protocol_failures_are_distinct_and_secret_safe() { + let cases = [ + SafeCorePiFailure::launch(), + SafeCorePiFailure::timeout(), + SafeCorePiFailure::request_io(), + SafeCorePiFailure::response_io(), + SafeCorePiFailure::process_io(), + SafeCorePiFailure::unsuccessful_exit(23), + SafeCorePiFailure::signal(6), + SafeCorePiFailure::empty_response(0), + SafeCorePiFailure::invalid_response(Some(0), None), + ]; + assert_eq!( + cases.map(|failure| failure.category), + [ + "launch_failure", + "timeout", + "request_io", + "response_io", + "process_io", + "exit_failure", + "signal", + "empty_response", + "invalid_response", + ] + ); + let reports = cases.map(|failure| failure.tagged_error(OPENROUTER_MODEL)); + assert!(reports[5].contains("child_exit=23")); + assert!(reports[6].contains("signal=6")); + for report in reports { + for secret in [ + "stderr-secret", + "provider-body", + "request-secret", + "sk-or-v1-", + ] { + assert!(!report.contains(secret)); + } + } + } + #[test] fn activation_evidence_cannot_claim_commit_from_staged_or_validated_state() { let mut evidence = AgentActivationEvidence::submitted(41); @@ -182,4 +897,401 @@ mod tests { assert_eq!(evidence.phase(), AgentActivationPhase::Committed); assert!(evidence.advance(AgentActivationPhase::Committed).is_err()); } + + #[test] + fn every_ui_preflight_exit_has_one_allowlisted_terminal() { + let cases = [ + ("openrouter", true, false, false, true, "empty_input"), + ("openrouter", true, true, true, true, "busy"), + ("openrouter", false, false, true, true, "credential_missing"), + ( + "openrouter", + true, + false, + true, + false, + "network_unavailable", + ), + ( + "provider-body\nforged", + true, + false, + true, + true, + "model_policy", + ), + ]; + for (index, (provider, configured, busy, input_present, network, category)) in + cases.into_iter().enumerate() + { + let (mut attempt, received) = AgentUiAttempt::receive( + index as u64 + 1, + provider, + configured, + busy, + input_present, + network, + ); + assert_eq!(received.kind, AgentUiAttemptEventKind::Received); + let failure = attempt.preflight().unwrap_err(); + assert_eq!(failure.category, category); + let terminal = attempt.terminal(Some(failure)).unwrap(); + assert_eq!(terminal.kind, AgentUiAttemptEventKind::Terminal); + assert_eq!(terminal.category, category); + assert!(terminal + .request_terminal_marker() + .unwrap() + .starts_with("android_agent_request_terminal stage=preflight category=")); + assert!(terminal + .ui_terminal_marker() + .unwrap() + .starts_with("android_agent_ui_terminal status=failed stage=preflight category=")); + assert!(attempt.terminal(Some(failure)).is_err()); + assert!(!terminal.provider.contains('\n')); + } + } + + #[test] + fn full_ui_dispatch_orders_received_start_and_one_terminal() { + let (mut attempt, received) = + AgentUiAttempt::receive(17, "openrouter", true, false, true, true); + assert_eq!(attempt.attempt_id(), 17); + assert_eq!(attempt.provider(), "openrouter"); + assert_eq!(attempt.preflight(), Ok(())); + let started = attempt.dispatch_started().unwrap(); + let accepted = attempt.accepted_marker().unwrap(); + let terminal = attempt.terminal(None).unwrap(); + assert_eq!( + [received.kind, started.kind, terminal.kind], + [ + AgentUiAttemptEventKind::Received, + AgentUiAttemptEventKind::DispatchStarted, + AgentUiAttemptEventKind::Terminal, + ] + ); + assert_eq!(terminal.category, "completed"); + let request_terminal = terminal.request_terminal_marker().unwrap(); + assert!(accepted.starts_with("android_agent_request_accepted provider=")); + assert!(request_terminal.starts_with("android_agent_request_terminal stage=")); + assert_eq!( + [accepted.as_str(), request_terminal.as_str()] + .iter() + .filter(|marker| marker.starts_with("android_agent_request_terminal stage=")) + .count(), + 1 + ); + assert_eq!( + terminal.ui_terminal_marker().unwrap(), + "android_agent_ui_terminal status=completed stage=ui category=completed provider=openrouter model=deepseek/deepseek-v4-flash-0731 model_policy=pinned attempt=17 correlation=serialized" + ); + assert!(attempt.terminal(None).is_err()); + } + + #[test] + fn accepted_marker_follows_dispatch_and_emits_exactly_once() { + let (mut attempt, received) = + AgentUiAttempt::receive(18, "openrouter", true, false, true, true); + assert_eq!(received.kind, AgentUiAttemptEventKind::Received); + assert!(attempt.accepted_marker().is_err()); + let started = attempt.dispatch_started().unwrap(); + assert_eq!(started.kind, AgentUiAttemptEventKind::DispatchStarted); + + let marker = attempt.accepted_marker().unwrap(); + assert_eq!( + marker, + "android_agent_request_accepted provider=openrouter model=deepseek/deepseek-v4-flash-0731 model_policy=pinned attempt=18 correlation=serialized" + ); + assert!(attempt.accepted_marker().is_err()); + assert_eq!( + attempt.terminal(None).unwrap().kind, + AgentUiAttemptEventKind::Terminal + ); + } + + #[test] + fn rejected_attempts_do_not_accept_and_marker_metadata_is_secret_safe() { + let (mut rejected, _) = AgentUiAttempt::receive(19, "openrouter", false, false, true, true); + let failure = rejected.preflight().unwrap_err(); + assert_eq!(failure.category, "credential_missing"); + let rejected_terminal = rejected.terminal(Some(failure)).unwrap(); + assert!(rejected.accepted_marker().is_err()); + assert_eq!( + rejected_terminal.request_terminal_marker().unwrap(), + "android_agent_request_terminal stage=preflight category=credential_missing provider=openrouter model=deepseek/deepseek-v4-flash-0731 model_policy=pinned attempt=19 correlation=serialized" + ); + + let (mut sanitized, _) = AgentUiAttempt::receive( + 20, + "provider-body\nprompt=secret key=sk-or-v1-response-secret", + true, + false, + true, + true, + ); + sanitized.dispatch_started().unwrap(); + let marker = sanitized.accepted_marker().unwrap(); + assert_eq!( + marker, + "android_agent_request_accepted provider=unsupported model=unsupported model_policy=pinned attempt=20 correlation=serialized" + ); + for secret in ["provider-body", "prompt", "secret", "key", "response"] { + assert!(!marker.contains(secret)); + } + let terminal = sanitized + .terminal(Some(ui_failure( + "provider\nresponse-secret", + "provider-body prompt=secret key=sk-or-v1-", + ))) + .unwrap(); + for terminal_marker in [ + terminal.request_terminal_marker().unwrap(), + terminal.ui_terminal_marker().unwrap(), + ] { + assert!(terminal_marker.contains("provider=unsupported model=unsupported")); + assert!(terminal_marker.contains("stage=protocol category=unknown")); + for secret in [ + "provider-body", + "response-secret", + "prompt=secret", + "sk-or-v1-", + ] { + assert!(!terminal_marker.contains(secret)); + } + } + } + + #[test] + fn offline_authenticated_fixed_tunnel_dispatches_but_ordinary_requests_still_reject() { + let (ordinary, _) = AgentUiAttempt::receive(18, "openrouter", true, false, true, false); + assert_eq!( + ordinary.preflight().unwrap_err().category, + "network_unavailable" + ); + + let mut authorization = CoreDevSmokeAuthorization::new(); + assert!(authorization.arm_authenticated()); + let transport = authorization + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT) + .unwrap(); + let (mut attempt, _) = AgentUiAttempt::receive_core_dev_fixed_smoke( + 19, + "openrouter", + true, + false, + CORE_DEV_AGENT_SMOKE_PROMPT, + transport, + ) + .unwrap(); + assert_eq!(attempt.transport(), AgentUiTransport::CoreDevFixedTunnel); + assert!(attempt.uses_core_dev_fixed_tunnel()); + assert_eq!(attempt.preflight(), Ok(())); + assert!(attempt.transport_ready(false)); + assert!(attempt.prompt_matches_transport(CORE_DEV_AGENT_SMOKE_PROMPT)); + assert_eq!( + attempt.dispatch_started().unwrap().kind, + AgentUiAttemptEventKind::DispatchStarted + ); + + assert!(authorization + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT) + .is_none()); + } + + #[test] + fn fixed_tunnel_authorization_is_single_use_fail_closed_and_credential_preserving() { + let mut authorization = CoreDevSmokeAuthorization::new(); + assert!(authorization + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT) + .is_none()); + assert!(authorization.arm_authenticated()); + assert!(!authorization.arm_authenticated()); + assert!(authorization + .consume_fixed_prompt("forged prompt") + .is_none()); + assert!(!authorization.is_armed()); + assert!(authorization + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT) + .is_none()); + + assert!(authorization.arm_authenticated()); + let transport = authorization + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT) + .unwrap(); + assert!(!authorization.is_armed()); + let (missing_credential, _) = AgentUiAttempt::receive_core_dev_fixed_smoke( + 20, + "openrouter", + false, + false, + CORE_DEV_AGENT_SMOKE_PROMPT, + transport, + ) + .unwrap(); + assert_eq!( + missing_credential.preflight().unwrap_err().category, + "credential_missing" + ); + assert!(AgentUiAttempt::receive_core_dev_fixed_smoke( + 21, + "openai", + true, + false, + CORE_DEV_AGENT_SMOKE_PROMPT, + AgentUiTransport::CoreDevFixedTunnel, + ) + .is_err()); + assert!(AgentUiAttempt::receive_core_dev_fixed_smoke( + 22, + "openrouter", + true, + false, + "forged prompt", + AgentUiTransport::CoreDevFixedTunnel, + ) + .is_err()); + } + + #[test] + fn fixed_tunnel_state_is_consumed_before_every_exit_and_terminal_is_ordered_once() { + for (attempt_id, configured, busy, expected_category) in [ + (30, false, false, "credential_missing"), + (31, true, true, "busy"), + ] { + let mut authorization = CoreDevSmokeAuthorization::new(); + assert!(authorization.arm_authenticated()); + let transport = authorization + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT) + .unwrap(); + let (mut attempt, received) = AgentUiAttempt::receive_core_dev_fixed_smoke( + attempt_id, + "openrouter", + configured, + busy, + CORE_DEV_AGENT_SMOKE_PROMPT, + transport, + ) + .unwrap(); + assert!(!authorization.is_armed()); + let failure = attempt.preflight().unwrap_err(); + assert_eq!(failure.category, expected_category); + let terminal = attempt.terminal(Some(failure)).unwrap(); + assert_eq!( + [received.kind, terminal.kind], + [ + AgentUiAttemptEventKind::Received, + AgentUiAttemptEventKind::Terminal + ] + ); + assert!(attempt.terminal(Some(failure)).is_err()); + } + + let mut authorization = CoreDevSmokeAuthorization::new(); + assert!(authorization.arm_authenticated()); + let transport = authorization + .consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT) + .unwrap(); + let (mut attempt, received) = AgentUiAttempt::receive_core_dev_fixed_smoke( + 32, + "openrouter", + true, + false, + CORE_DEV_AGENT_SMOKE_PROMPT, + transport, + ) + .unwrap(); + let dispatch = attempt.dispatch_started().unwrap(); + let accepted = attempt.accepted_marker().unwrap(); + let terminal = attempt.terminal(None).unwrap(); + assert!(!authorization.is_armed()); + assert_eq!( + [received.kind, dispatch.kind, terminal.kind], + [ + AgentUiAttemptEventKind::Received, + AgentUiAttemptEventKind::DispatchStarted, + AgentUiAttemptEventKind::Terminal, + ] + ); + assert!(accepted.starts_with("android_agent_request_accepted provider=openrouter")); + assert!(attempt.terminal(None).is_err()); + } + + #[test] + fn every_post_dispatch_error_is_terminal_and_displayed_from_the_same_mapping() { + for (index, (stage, category)) in [ + ("dispatch", "dispatch_channel"), + ("runtime", "runtime_start"), + ("transport", "dns_resolution"), + ("provider", "rate_limited"), + ("protocol", "invalid_response"), + ("validation", "invalid_candidate"), + ] + .into_iter() + .enumerate() + { + let (mut attempt, _) = + AgentUiAttempt::receive(index as u64 + 30, "openrouter", true, false, true, true); + attempt.dispatch_started().unwrap(); + let failure = ui_failure(stage, category); + assert!(!failure.display.is_empty()); + let terminal = attempt.terminal(Some(failure)).unwrap(); + assert_eq!(terminal.stage, failure.stage); + assert_eq!(terminal.category, failure.category); + let marker = terminal.request_terminal_marker().unwrap(); + assert!(marker.starts_with("android_agent_request_terminal stage=")); + assert!(marker.contains(&format!(" category={} ", failure.category))); + assert!(attempt.terminal(Some(failure)).is_err()); + } + } + + #[test] + fn nonterminal_events_cannot_construct_terminal_markers() { + let (mut attempt, received) = + AgentUiAttempt::receive(61, "openrouter", true, false, true, true); + assert!(received.request_terminal_marker().is_err()); + assert!(received.ui_terminal_marker().is_err()); + let started = attempt.dispatch_started().unwrap(); + assert!(started.request_terminal_marker().is_err()); + assert!(started.ui_terminal_marker().is_err()); + } + + #[test] + fn ui_failure_mapping_cannot_display_provider_or_protocol_content() { + let injected = ui_failure( + "provider\ncore_ui_attempt_terminal", + "provider-body secret prompt sk-or-v1-", + ); + assert_eq!(injected.stage, "protocol"); + assert_eq!(injected.category, "unknown"); + assert_eq!( + injected.display, + "The provider failure category was unknown." + ); + } + + #[test] + fn development_smoke_prompt_is_fixed_bounded_and_non_secret() { + assert!(!CORE_DEV_AGENT_SMOKE_PROMPT.trim().is_empty()); + assert!(CORE_DEV_AGENT_SMOKE_PROMPT.len() < 256); + assert!(CORE_DEV_AGENT_SMOKE_PROMPT.contains("Blue smoke check")); + for forbidden in ["sk-or-v1-", "credential", "Authorization", "Bearer"] { + assert!(!CORE_DEV_AGENT_SMOKE_PROMPT.contains(forbidden)); + } + } + + #[test] + fn every_packaged_agent_composer_uses_the_single_trusted_submit_contract() { + for source in [ + include_str!("../../../experiences/default.luau"), + include_str!("../../../experiences/timeflow.luau"), + include_str!("../../../experiences/daily-flow.luau"), + ] { + assert_eq!( + source.matches("submit_action = \"agent_submit\"").count(), + 1 + ); + assert!(source.contains("event.action == \"agent_submit\"")); + assert!(source.contains("provider = \"agent\", action = \"prompt\"")); + assert!(source.contains("id = \"agent-prompt\"")); + } + } } diff --git a/apps/experience/src/core_child_fds.rs b/apps/experience/src/core_child_fds.rs new file mode 100644 index 0000000..293f33e --- /dev/null +++ b/apps/experience/src/core_child_fds.rs @@ -0,0 +1,180 @@ +use std::io; +use std::mem::MaybeUninit; +use std::os::unix::process::CommandExt; +use std::process::Command; + +// Linux UAPI ; libc does not export this Linux 5.11+ +// constant on its Android target. +const CLOSE_RANGE_CLOEXEC: u32 = 1 << 2; + +const FIRST_NON_STANDARD_FD: libc::c_int = 3; + +fn close_range_cloexec_is_unsupported(error: &io::Error) -> bool { + matches!(error.raw_os_error(), Some(libc::EINVAL | libc::ENOSYS)) +} + +/// Marks every descriptor in `[first, end)` close-on-exec without closing it. +/// +/// The Rust standard library keeps a private pipe open until `execve` so the +/// child can report a launch error to its parent. Closing the whole range in a +/// `pre_exec` callback would also close that pipe and could turn an exec +/// failure into a false successful spawn. `fcntl(F_SETFD)` preserves the pipe +/// until exec while still applying the same fail-closed inheritance boundary. +fn mark_fd_range_cloexec(first: libc::c_int, end: libc::rlim_t) -> io::Result<()> { + let end = end.min(libc::c_int::MAX as libc::rlim_t); + for raw_fd in (first as libc::rlim_t)..end { + let fd = raw_fd as libc::c_int; + // SAFETY: `fcntl` accepts any integer descriptor. Closed descriptors + // return EBADF and are deliberately skipped. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EBADF) { + continue; + } + return Err(error); + } + // SAFETY: `fd` was observed open above and F_SETFD only updates its + // descriptor flags. Preserve any flags the kernel already returned. + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +fn mark_all_non_standard_fds_cloexec() -> io::Result<()> { + let mut limit = MaybeUninit::::uninit(); + // SAFETY: getrlimit initializes the provided rlimit on success. + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, limit.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: the successful getrlimit call initialized `limit`. + let limit = unsafe { limit.assume_init() }; + mark_fd_range_cloexec(FIRST_NON_STANDARD_FD, limit.rlim_cur) +} + +/// Installs the fail-closed, async-signal-safe boundary used immediately +/// before exec. `Command` has already duplicated the requested pipes onto +/// descriptors 0/1/2 when this callback runs. Linux 5.11+ atomically applies +/// CLOEXEC to the remaining range. Android's 5.10 kernel has `close_range` +/// but predates `CLOSE_RANGE_CLOEXEC`, so EINVAL/ENOSYS falls back to the +/// async-signal-safe `fcntl` loop above. Both paths preserve Rust's private +/// launch-error pipe until exec while preventing every inherited GPUI, +/// dma-buf, surface, input, device, and service descriptor from surviving it. +pub(crate) fn restrict_to_standard_fds(command: &mut Command) { + // SAFETY: the callback invokes only the close_range syscall and constructs + // an io::Error from errno. It does not allocate, lock, or inspect process + // state after fork. Failure aborts spawn instead of leaking descriptors. + unsafe { + command.pre_exec(|| { + let result = libc::syscall(libc::SYS_close_range, 3_u32, u32::MAX, CLOSE_RANGE_CLOEXEC); + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if close_range_cloexec_is_unsupported(&error) { + mark_all_non_standard_fds_cloexec() + } else { + Err(error) + } + }); + } +} + +#[cfg(test)] +mod tests { + use std::fs::OpenOptions; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::process::Stdio; + + use super::*; + + #[test] + fn android_5_10_close_range_flag_rejection_uses_the_safe_fallback() { + assert!(close_range_cloexec_is_unsupported( + &io::Error::from_raw_os_error(libc::EINVAL) + )); + assert!(close_range_cloexec_is_unsupported( + &io::Error::from_raw_os_error(libc::ENOSYS) + )); + assert!(!close_range_cloexec_is_unsupported( + &io::Error::from_raw_os_error(libc::EPERM) + )); + } + + #[test] + fn fallback_marks_an_open_descriptor_and_ignores_closed_descriptors() { + let inherited = OpenOptions::new().read(true).open("/dev/null").unwrap(); + let minimum = 256; + // SAFETY: F_DUPFD_CLOEXEC duplicates a live descriptor. The returned + // descriptor is owned by this test and closed below. + let duplicated = unsafe { libc::fcntl(inherited.as_raw_fd(), libc::F_DUPFD, minimum) }; + assert!(duplicated >= minimum); + // SAFETY: duplicated is live and owned by this test. + assert_eq!(unsafe { libc::fcntl(duplicated, libc::F_SETFD, 0) }, 0); + + mark_fd_range_cloexec(duplicated - 1, (duplicated + 2) as libc::rlim_t).unwrap(); + + // SAFETY: duplicated remains live until the explicit close below. + assert_ne!( + unsafe { libc::fcntl(duplicated, libc::F_GETFD) } & libc::FD_CLOEXEC, + 0 + ); + // SAFETY: this test uniquely owns duplicated. + assert_eq!(unsafe { libc::close(duplicated) }, 0); + } + + #[test] + fn child_keeps_only_pipe_backed_standard_streams() { + let leaked = tempfile::NamedTempFile::new().unwrap(); + let leaked_path = leaked.path().to_string_lossy().into_owned(); + let inherited = OpenOptions::new().read(true).open(leaked.path()).unwrap(); + assert!(inherited.as_raw_fd() >= 3); + // Deliberately model a graphics/device descriptor that lacks CLOEXEC. + // SAFETY: inherited is live and remains owned by this test. + assert_eq!( + unsafe { libc::fcntl(inherited.as_raw_fd(), libc::F_SETFD, 0) }, + 0 + ); + + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg( + "for descriptor in /proc/self/fd/*; do \ + target=$(readlink \"$descriptor\" 2>/dev/null || true); \ + [ \"$target\" != \"$SOS_TEST_LEAK\" ] || exit 90; \ + done; read value; printf '%s' \"$value\"", + ) + .env("SOS_TEST_LEAK", leaked_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + restrict_to_standard_fds(&mut command); + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"stdio-survives\n") + .unwrap(); + let output = child.wait_with_output().unwrap(); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"stdio-survives"); + } + + #[test] + fn cloexec_range_preserves_exec_failure_reporting() { + let mut command = Command::new("/sos-test-path-that-must-not-exist"); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + restrict_to_standard_fds(&mut command); + + assert_eq!(command.spawn().unwrap_err().kind(), io::ErrorKind::NotFound); + } +} diff --git a/apps/experience/src/core_credential.rs b/apps/experience/src/core_credential.rs index cc148ce..e26fc5c 100644 --- a/apps/experience/src/core_credential.rs +++ b/apps/experience/src/core_credential.rs @@ -2,11 +2,13 @@ use zeroize::Zeroize; pub const MIN_CREDENTIAL_BYTES: usize = 20; pub const MAX_CREDENTIAL_BYTES: usize = 512; +pub const OPENROUTER_KEY_PREFIX: &str = "sk-or-v1-"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CeremonySnapshot { pub visible: bool, pub masked: String, + pub suffix_count: usize, pub error: Option<&'static str>, } @@ -22,6 +24,8 @@ pub struct CredentialState { impl CredentialState { pub fn begin(&mut self) { self.clear_draft(); + self.draft + .extend_from_slice(OPENROUTER_KEY_PREFIX.as_bytes()); self.ceremony_visible = true; self.error = None; } @@ -50,7 +54,9 @@ impl CredentialState { } match text { "\u{8}" => { - self.draft.pop(); + if self.draft.len() > OPENROUTER_KEY_PREFIX.len() { + self.draft.pop(); + } self.error = None; true } @@ -70,7 +76,7 @@ impl CredentialState { pub fn save(&mut self) -> bool { if !valid_credential(&self.draft) { - self.error = Some("Enter 20–512 visible ASCII characters"); + self.error = Some("Enter 11–503 visible ASCII characters after sk-or-v1-"); return false; } self.active.zeroize(); @@ -94,6 +100,19 @@ impl CredentialState { true } + #[cfg(any(feature = "core-dev-credential", test))] + pub fn install_openrouter(&mut self, key: &[u8]) -> bool { + if !valid_credential(key) { + return false; + } + self.cancel(); + self.active.zeroize(); + self.active.clear(); + self.active.extend_from_slice(key); + self.openrouter_selected = true; + true + } + pub fn credential(&self) -> Option> { (self.openrouter_selected && valid_credential(&self.active)).then(|| self.active.clone()) } @@ -105,7 +124,14 @@ impl CredentialState { pub fn snapshot(&self) -> CeremonySnapshot { CeremonySnapshot { visible: self.ceremony_visible, - masked: "•".repeat(self.draft.len()), + masked: self + .ceremony_visible + .then(|| masked_credential(self.draft.len())) + .unwrap_or_default(), + suffix_count: self + .ceremony_visible + .then(|| self.draft.len().saturating_sub(OPENROUTER_KEY_PREFIX.len())) + .unwrap_or_default(), error: self.error, } } @@ -125,15 +151,28 @@ impl Drop for CredentialState { fn valid_credential(value: &[u8]) -> bool { (MIN_CREDENTIAL_BYTES..=MAX_CREDENTIAL_BYTES).contains(&value.len()) + && value.starts_with(OPENROUTER_KEY_PREFIX.as_bytes()) && value.iter().all(u8::is_ascii_graphic) } +fn masked_credential(length: usize) -> String { + let suffix = length.saturating_sub(OPENROUTER_KEY_PREFIX.len()); + let mut masked = String::from(OPENROUTER_KEY_PREFIX); + for index in 0..suffix { + if index > 0 && index % 4 == 0 { + masked.push(' '); + } + masked.push('•'); + } + masked +} + #[cfg(test)] mod tests { use super::*; - const FIRST: &str = "ABCDEFGHIJKLMNOPQRSTUVWX"; - const SECOND: &str = "zyxwvutsrqponmlkjihgfedc"; + const FIRST: &str = "sk-or-v1-0123456789abcdef01234567"; + const SECOND: &str = "sk-or-v1-fedcba9876543210fedcba98"; fn enter(state: &mut CredentialState, value: &str) { for byte in value.bytes() { @@ -141,17 +180,34 @@ mod tests { } } + fn enter_key(state: &mut CredentialState, value: &str) { + enter( + state, + value + .strip_prefix(OPENROUTER_KEY_PREFIX) + .expect("test key must use the fixed OpenRouter prefix"), + ); + } + #[test] fn ceremony_masks_input_and_has_fixed_cancel_semantics() { let mut state = CredentialState::default(); state.begin(); - enter(&mut state, FIRST); + enter_key(&mut state, FIRST); let snapshot = state.snapshot(); assert!(snapshot.visible); - assert_eq!(snapshot.masked.chars().count(), FIRST.len()); + assert!(snapshot.masked.starts_with(OPENROUTER_KEY_PREFIX)); + assert_eq!( + snapshot.suffix_count, + FIRST.len() - OPENROUTER_KEY_PREFIX.len() + ); + assert_eq!(snapshot.masked.matches(' ').count(), 5); assert!(!snapshot.masked.contains(FIRST)); state.cancel(); - assert!(!state.snapshot().visible); + let cancelled = state.snapshot(); + assert!(!cancelled.visible); + assert!(cancelled.masked.is_empty()); + assert_eq!(cancelled.suffix_count, 0); assert!(!state.configured()); assert!(state.credential().is_none()); } @@ -160,12 +216,12 @@ mod tests { fn save_replace_clear_and_refresh_are_provider_scoped() { let mut state = CredentialState::default(); state.begin(); - enter(&mut state, FIRST); + enter_key(&mut state, FIRST); assert!(state.save()); assert_eq!(state.credential().as_deref(), Some(FIRST.as_bytes())); state.begin(); - enter(&mut state, SECOND); + enter_key(&mut state, SECOND); assert!(state.save()); assert_eq!(state.credential().as_deref(), Some(SECOND.as_bytes())); assert!(!state.accept_refreshed("openai", FIRST.as_bytes())); @@ -192,9 +248,49 @@ mod tests { assert!(!state.apply_input(" ")); state.cancel(); state.begin(); - for _ in 0..MAX_CREDENTIAL_BYTES { + for _ in OPENROUTER_KEY_PREFIX.len()..MAX_CREDENTIAL_BYTES { assert!(state.apply_input("x")); } assert!(!state.apply_input("x")); } + + #[test] + fn ceremony_prefills_and_protects_the_exact_openrouter_prefix() { + let mut state = CredentialState::default(); + state.begin(); + assert_eq!(state.snapshot().masked, OPENROUTER_KEY_PREFIX); + assert_eq!(state.snapshot().suffix_count, 0); + for _ in 0..OPENROUTER_KEY_PREFIX.len() + 3 { + assert!(state.apply_input("\u{8}")); + } + assert_eq!(state.snapshot().masked, OPENROUTER_KEY_PREFIX); + enter(&mut state, "0123456789abcdef"); + assert_eq!(state.snapshot().suffix_count, 16); + assert_eq!(state.snapshot().masked, "sk-or-v1-•••• •••• •••• ••••"); + } + + #[test] + fn development_install_is_bounded_provider_scoped_and_clearable() { + let mut state = CredentialState::default(); + assert!(!state.install_openrouter(b"too-short")); + assert!(!state.install_openrouter(b"sk-or-v1-line\nbreak-is-rejected")); + assert!(!state.configured()); + assert!(state.install_openrouter(FIRST.as_bytes())); + assert_eq!(state.credential().as_deref(), Some(FIRST.as_bytes())); + state.clear(); + assert!(!state.configured()); + assert!(state.credential().is_none()); + } + + #[test] + fn failed_request_refresh_retains_memory_only_credential_until_explicit_clear() { + let mut state = CredentialState::default(); + assert!(state.install_openrouter(FIRST.as_bytes())); + assert!(!state.accept_refreshed("openrouter", b"invalid-request-refresh")); + assert!(state.configured()); + assert_eq!(state.credential().as_deref(), Some(FIRST.as_bytes())); + state.clear(); + assert!(!state.configured()); + assert!(state.credential().is_none()); + } } diff --git a/apps/experience/src/core_dev_credential.rs b/apps/experience/src/core_dev_credential.rs new file mode 100644 index 0000000..ceb3d5a --- /dev/null +++ b/apps/experience/src/core_dev_credential.rs @@ -0,0 +1,1040 @@ +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +use std::io::{Read, Write}; +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +use std::{ + mem::{size_of, zeroed}, + os::fd::{FromRawFd, OwnedFd}, + os::unix::net::{UnixListener, UnixStream}, + thread, + time::Duration, +}; + +use zeroize::{Zeroize, Zeroizing}; + +use crate::core_credential::{MAX_CREDENTIAL_BYTES, MIN_CREDENTIAL_BYTES, OPENROUTER_KEY_PREFIX}; +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +use crate::core_dev_product::{validate_dev_product, DevProductMarkers}; + +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +const SOCKET_NAME: &[u8] = b"sos_core_dev_credential_v1"; +#[allow(dead_code)] +mod v1 { + include!(concat!( + env!("OUT_DIR"), + "/core_dev_credential_protocol_v1.rs" + )); +} +const MAGIC: [u8; 4] = [ + v1::MAGIC_0 as u8, + v1::MAGIC_1 as u8, + v1::MAGIC_2 as u8, + v1::MAGIC_3 as u8, +]; +const VERSION: u8 = v1::VERSION as u8; +const OP_PROBE: u8 = v1::OP_PROBE as u8; +const OP_SET: u8 = v1::OP_SET as u8; +const OP_CLEAR: u8 = v1::OP_CLEAR as u8; +const OP_STATUS: u8 = v1::OP_STATUS as u8; +const OP_AGENT_SMOKE: u8 = v1::OP_AGENT_SMOKE as u8; +const STATUS_OK: u8 = v1::STATUS_OK as u8; +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +const STATUS_REJECTED: u8 = v1::STATUS_REJECTED as u8; +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +const STATUS_WRONG_PEER: u8 = v1::STATUS_WRONG_PEER as u8; +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +const STATUS_PROTOCOL_MISMATCH: u8 = v1::STATUS_PROTOCOL_MISMATCH as u8; +const STATUS_CONFIGURED: u8 = v1::STATUS_CONFIGURED as u8; +const STATUS_EMPTY: u8 = v1::STATUS_EMPTY as u8; +const HEADER_BYTES: usize = v1::REQUEST_HEADER_BYTES; +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +const ACK_BYTES: usize = v1::ACK_BYTES; +const MAX_FRAME_BYTES: usize = HEADER_BYTES + MAX_CREDENTIAL_BYTES; +const SHELL_UID: u32 = 2000; +const DEV_CLIENT_CONTEXT: &[u8] = b"u:r:sos_core_dev_credential:s0"; +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +const IO_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, PartialEq, Eq)] +enum Request { + Probe, + Set(Zeroizing>), + Clear, + Status, + AgentSmoke, +} + +fn decode_request(mut frame: Zeroizing>) -> Result { + if frame.len() < HEADER_BYTES { + return Err("short_io"); + } + if frame[..4] != MAGIC { + return Err("bad_magic"); + } + if frame[4] != VERSION { + return Err("bad_version"); + } + let operation = frame[5]; + let payload_length = u16::from_be_bytes([frame[6], frame[7]]) as usize; + if frame.len() != HEADER_BYTES + payload_length { + return Err("length"); + } + match operation { + OP_PROBE if payload_length == 0 => Ok(Request::Probe), + OP_SET => { + if !(MIN_CREDENTIAL_BYTES..=MAX_CREDENTIAL_BYTES).contains(&payload_length) { + return Err("credential_length"); + } + let mut key = Zeroizing::new(frame.split_off(HEADER_BYTES)); + frame.zeroize(); + if !key.starts_with(OPENROUTER_KEY_PREFIX.as_bytes()) + || !key.iter().all(u8::is_ascii_graphic) + { + key.zeroize(); + return Err("credential_format"); + } + Ok(Request::Set(key)) + } + OP_CLEAR if payload_length == 0 => Ok(Request::Clear), + OP_STATUS if payload_length == 0 => Ok(Request::Status), + OP_AGENT_SMOKE if payload_length == 0 => Ok(Request::AgentSmoke), + _ => Err("operation"), + } +} + +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +fn read_request(stream: &mut impl Read) -> Result { + let mut header = [0_u8; HEADER_BYTES]; + stream.read_exact(&mut header).map_err(|_| "short_io")?; + if header[..4] != MAGIC { + return Err("bad_magic"); + } + if header[4] != VERSION { + return Err("bad_version"); + } + let payload_length = u16::from_be_bytes([header[6], header[7]]) as usize; + if HEADER_BYTES + payload_length > MAX_FRAME_BYTES { + return Err("request_too_large"); + } + let mut frame = Zeroizing::new(Vec::with_capacity(HEADER_BYTES + payload_length)); + frame.extend_from_slice(&header); + frame.resize(HEADER_BYTES + payload_length, 0); + stream + .read_exact(&mut frame[HEADER_BYTES..]) + .map_err(|_| "short_io")?; + let mut trailing = [0_u8; 1]; + match stream.read(&mut trailing) { + Ok(0) => decode_request(frame), + Ok(_) => Err("trailing_data"), + Err(_) => Err("short_io"), + } +} + +fn authorized_peer(uid: u32, context: &[u8]) -> bool { + uid == SHELL_UID && context == DEV_CLIENT_CONTEXT +} + +fn apply_request( + request: Request, + mut set: impl FnMut(&[u8]) -> bool, + mut clear: impl FnMut(), + mut configured: impl FnMut() -> bool, + mut agent_smoke: impl FnMut() -> bool, +) -> u8 { + match request { + Request::Probe => STATUS_OK, + Request::Set(key) => { + if set(&key) { + STATUS_OK + } else { + STATUS_REJECTED + } + } + Request::Clear => { + clear(); + STATUS_OK + } + Request::Status => { + if configured() { + STATUS_CONFIGURED + } else { + STATUS_EMPTY + } + } + Request::AgentSmoke => { + if configured() && agent_smoke() { + STATUS_OK + } else { + STATUS_REJECTED + } + } + } +} + +#[cfg(target_os = "android")] +fn property(name: &str) -> String { + use std::ffi::{c_char, c_int, CStr, CString}; + unsafe extern "C" { + fn __system_property_get(name: *const c_char, value: *mut c_char) -> c_int; + } + let Ok(name) = CString::new(name) else { + return String::new(); + }; + let mut value = [0 as c_char; 92]; + // SAFETY: bionic writes at most PROP_VALUE_MAX bytes and both buffers are valid. + let length = unsafe { __system_property_get(name.as_ptr(), value.as_mut_ptr()) }; + if length <= 0 { + return String::new(); + } + unsafe { CStr::from_ptr(value.as_ptr()) } + .to_string_lossy() + .into_owned() +} + +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +struct OwnedProductMarkerFailure { + name: &'static str, + expected: &'static str, + actual: String, +} + +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +fn validate_running_dev_product() -> Result<(), OwnedProductMarkerFailure> { + let revision = property("ro.build.version.incremental"); + let build_variant = property("ro.sos.build_variant"); + let dev_credential = property("ro.sos.dev_credential"); + let build_type = property("ro.build.type"); + let debuggable = property("ro.debuggable"); + validate_dev_product(DevProductMarkers { + revision: &revision, + build_variant: &build_variant, + dev_credential: &dev_credential, + build_type: &build_type, + debuggable: &debuggable, + }) + .map_err(|failure| OwnedProductMarkerFailure { + name: failure.name, + expected: failure.expected, + actual: failure.actual.to_owned(), + }) +} + +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +fn bind_listener() -> std::io::Result { + // SAFETY: every raw descriptor is transferred to OwnedFd exactly once. + let raw = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) }; + if raw < 0 { + return Err(std::io::Error::last_os_error()); + } + let owned = unsafe { OwnedFd::from_raw_fd(raw) }; + let mut address = unsafe { zeroed::() }; + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + if SOCKET_NAME.len() + 1 > address.sun_path.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "socket name", + )); + } + for (index, byte) in SOCKET_NAME.iter().enumerate() { + address.sun_path[index + 1] = *byte as libc::c_char; + } + let address_length = + (size_of::() + 1 + SOCKET_NAME.len()) as libc::socklen_t; + if unsafe { + libc::bind( + raw, + &address as *const libc::sockaddr_un as *const libc::sockaddr, + address_length, + ) + } != 0 + || unsafe { libc::listen(raw, 1) } != 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(UnixListener::from(owned)) +} + +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +fn peer_identity(stream: &UnixStream) -> Result<(u32, Vec), &'static str> { + use std::os::fd::AsRawFd; + let mut credentials = unsafe { zeroed::() }; + let mut credentials_length = size_of::() as libc::socklen_t; + if unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut credentials as *mut libc::ucred as *mut libc::c_void, + &mut credentials_length, + ) + } != 0 + { + return Err("peer_credentials"); + } + let mut context = vec![0_u8; 128]; + let mut context_length = context.len() as libc::socklen_t; + if unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERSEC, + context.as_mut_ptr().cast(), + &mut context_length, + ) + } != 0 + { + return Err("peer_context"); + } + context.truncate(context_length as usize); + while context.last() == Some(&0) { + context.pop(); + } + Ok((credentials.uid, context)) +} + +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +fn serve_connection(mut stream: UnixStream) -> Result<(), &'static str> { + stream + .set_read_timeout(Some(IO_TIMEOUT)) + .map_err(|_| "timeout")?; + stream + .set_write_timeout(Some(IO_TIMEOUT)) + .map_err(|_| "timeout")?; + let (uid, context) = peer_identity(&stream)?; + if !authorized_peer(uid, &context) { + write_status(&mut stream, STATUS_WRONG_PEER)?; + return Err("peer_rejected"); + } + serve_protocol( + &mut stream, + crate::android::install_dev_openrouter_credential, + crate::android::clear_dev_openrouter_credential, + crate::android::dev_openrouter_credential_configured, + crate::android::request_dev_agent_smoke, + ) +} + +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +fn serve_protocol( + stream: &mut (impl Read + Write), + set: impl FnMut(&[u8]) -> bool, + clear: impl FnMut(), + configured: impl FnMut() -> bool, + agent_smoke: impl FnMut() -> bool, +) -> Result<(), &'static str> { + let request = match read_request(stream) { + Ok(request) => request, + Err(category) => { + write_status(stream, STATUS_PROTOCOL_MISMATCH)?; + return Err(category); + } + }; + let status = apply_request(request, set, clear, configured, agent_smoke); + write_status(stream, status) +} + +#[cfg(any( + all(target_os = "android", feature = "core-dev-credential"), + core_dev_credential_protocol_host_test +))] +fn write_status(stream: &mut impl Write, status: u8) -> Result<(), &'static str> { + let response = [MAGIC[0], MAGIC[1], MAGIC[2], MAGIC[3], VERSION, status]; + debug_assert_eq!(response.len(), ACK_BYTES); + stream.write_all(&response).map_err(|_| "response_io") +} + +#[cfg(all(target_os = "android", feature = "core-dev-credential"))] +pub fn start() -> Result<(), &'static str> { + if let Err(failure) = validate_running_dev_product() { + log::warn!( + "core_dev_credential state=unavailable marker={} expected={} actual={}", + failure.name, + failure.expected, + failure.actual + ); + return Err("build_gate"); + } + let listener = bind_listener().map_err(|_| "bind")?; + thread::Builder::new() + .name("sos-dev-credential".into()) + .spawn(move || { + log::info!( + "core_dev_credential state=ready transport=local peer=sos_core_dev_credential" + ); + for connection in listener.incoming() { + let result = connection.map_err(|_| "accept").and_then(serve_connection); + if let Err(category) = result { + log::warn!("core_dev_credential state=rejected category={category}"); + } + } + }) + .map_err(|_| "thread")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + #[cfg(core_dev_credential_protocol_host_test)] + use std::io::Cursor; + #[cfg(core_dev_credential_protocol_host_test)] + use std::{ + fs, + net::Shutdown, + os::unix::net::{UnixListener, UnixStream}, + path::PathBuf, + process::{Command, Output, Stdio}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + thread, + }; + + const SYNTHETIC_KEY: &[u8] = b"sk-or-v1-0123456789abcdef01234567"; + const PROBE_GOLDEN: &[u8] = b"SOSK\x01\x00\x00\x00"; + const CLEAR_GOLDEN: &[u8] = b"SOSK\x01\x02\x00\x00"; + const STATUS_GOLDEN: &[u8] = b"SOSK\x01\x03\x00\x00"; + const AGENT_SMOKE_GOLDEN: &[u8] = b"SOSK\x01\x04\x00\x00"; + const SET_GOLDEN: &[u8] = b"SOSK\x01\x01\x00\x21sk-or-v1-0123456789abcdef01234567"; + const OK_GOLDEN: &[u8] = b"SOSK\x01\x01"; + #[cfg(core_dev_credential_protocol_host_test)] + static SOCKET_SEQUENCE: AtomicUsize = AtomicUsize::new(0); + + #[cfg(core_dev_credential_protocol_host_test)] + struct OneByteIo(UnixStream); + + #[cfg(core_dev_credential_protocol_host_test)] + impl std::io::Read for OneByteIo { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let limit = buffer.len().min(1); + self.0.read(&mut buffer[..limit]) + } + } + + #[cfg(core_dev_credential_protocol_host_test)] + impl std::io::Write for OneByteIo { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.0.write(&buffer[..buffer.len().min(1)]) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.0.flush() + } + } + + #[cfg(core_dev_credential_protocol_host_test)] + struct FailingAck(Cursor>); + + #[cfg(core_dev_credential_protocol_host_test)] + impl std::io::Read for FailingAck { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.0.read(buffer) + } + } + + #[cfg(core_dev_credential_protocol_host_test)] + impl std::io::Write for FailingAck { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn frame(operation: u8, payload: &[u8]) -> Zeroizing> { + let mut frame = Zeroizing::new(Vec::new()); + frame.extend_from_slice(&MAGIC); + frame.push(VERSION); + frame.push(operation); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + frame.extend_from_slice(payload); + frame + } + + #[test] + fn exact_v1_frames_decode_without_exposing_payload() { + let key = SYNTHETIC_KEY; + assert_eq!(&*frame(OP_PROBE, b""), PROBE_GOLDEN); + assert_eq!(&*frame(OP_CLEAR, b""), CLEAR_GOLDEN); + assert_eq!(&*frame(OP_STATUS, b""), STATUS_GOLDEN); + assert_eq!(&*frame(OP_AGENT_SMOKE, b""), AGENT_SMOKE_GOLDEN); + assert_eq!(&*frame(OP_SET, key), SET_GOLDEN); + assert_eq!( + [MAGIC[0], MAGIC[1], MAGIC[2], MAGIC[3], VERSION, STATUS_OK], + OK_GOLDEN + ); + assert_eq!(MAX_CREDENTIAL_BYTES, v1::MAX_PAYLOAD_BYTES); + assert_eq!( + decode_request(frame(OP_SET, key)), + Ok(Request::Set(Zeroizing::new(key.to_vec()))) + ); + assert_eq!(decode_request(frame(OP_CLEAR, b"")), Ok(Request::Clear)); + assert_eq!(decode_request(frame(OP_PROBE, b"")), Ok(Request::Probe)); + assert_eq!(decode_request(frame(OP_STATUS, b"")), Ok(Request::Status)); + assert_eq!( + decode_request(frame(OP_AGENT_SMOKE, b"")), + Ok(Request::AgentSmoke) + ); + } + + #[test] + fn protocol_rejects_wrong_magic_version_length_operation_and_credentials() { + assert_eq!(MAX_FRAME_BYTES, HEADER_BYTES + MAX_CREDENTIAL_BYTES); + let key = SYNTHETIC_KEY; + let mut wrong_magic = frame(OP_SET, key); + wrong_magic[0] = b'X'; + assert_eq!(decode_request(wrong_magic), Err("bad_magic")); + let mut wrong_version = frame(OP_SET, key); + wrong_version[4] = 2; + assert_eq!(decode_request(wrong_version), Err("bad_version")); + let mut wrong_length = frame(OP_SET, key); + wrong_length[7] -= 1; + assert_eq!(decode_request(wrong_length), Err("length")); + assert_eq!(decode_request(frame(9, b"")), Err("operation")); + assert_eq!(decode_request(frame(OP_PROBE, b"x")), Err("operation")); + assert_eq!(decode_request(frame(OP_STATUS, b"x")), Err("operation")); + assert_eq!( + decode_request(frame(OP_AGENT_SMOKE, b"x")), + Err("operation") + ); + assert_eq!( + decode_request(frame(OP_SET, b"short")), + Err("credential_length") + ); + assert_eq!( + decode_request(frame(OP_SET, b"sk-or-v1-line\nbreak-is-rejected")), + Err("credential_format") + ); + assert_eq!( + decode_request(frame(OP_SET, &vec![b'x'; MAX_CREDENTIAL_BYTES + 1])), + Err("credential_length") + ); + } + + #[test] + fn only_the_exact_dedicated_client_peer_is_authorized() { + assert!(authorized_peer(SHELL_UID, DEV_CLIENT_CONTEXT)); + assert!(!authorized_peer(0, DEV_CLIENT_CONTEXT)); + assert!(!authorized_peer(SHELL_UID, b"u:r:shell:s0")); + } + + #[test] + fn probe_does_not_read_or_mutate_credential_state() { + let set_calls = Cell::new(0); + let clear_calls = Cell::new(0); + assert_eq!( + apply_request( + Request::Probe, + |_| { + set_calls.set(set_calls.get() + 1); + true + }, + || clear_calls.set(clear_calls.get() + 1), + || false, + || false, + ), + STATUS_OK + ); + assert_eq!((set_calls.get(), clear_calls.get()), (0, 0)); + } + + #[test] + fn clear_remains_available_after_a_rejected_set() { + let set_calls = Cell::new(0); + let clear_calls = Cell::new(0); + assert_eq!( + apply_request( + Request::Set(Zeroizing::new( + b"sk-or-v1-0123456789abcdef01234567".to_vec() + )), + |_| { + set_calls.set(set_calls.get() + 1); + false + }, + || clear_calls.set(clear_calls.get() + 1), + || false, + || false, + ), + STATUS_REJECTED + ); + assert_eq!( + apply_request( + Request::Clear, + |_| true, + || clear_calls.set(clear_calls.get() + 1), + || false, + || false, + ), + STATUS_OK + ); + assert_eq!((set_calls.get(), clear_calls.get()), (1, 1)); + } + + #[test] + fn status_is_secret_free_and_does_not_mutate_state() { + let set_calls = Cell::new(0); + let clear_calls = Cell::new(0); + let status_calls = Cell::new(0); + for (configured, expected) in [(false, STATUS_EMPTY), (true, STATUS_CONFIGURED)] { + assert_eq!( + apply_request( + Request::Status, + |_| { + set_calls.set(set_calls.get() + 1); + true + }, + || clear_calls.set(clear_calls.get() + 1), + || { + status_calls.set(status_calls.get() + 1); + configured + }, + || false, + ), + expected + ); + } + assert_eq!( + (set_calls.get(), clear_calls.get(), status_calls.get()), + (0, 0, 2) + ); + } + + #[test] + fn agent_smoke_requires_configured_state_and_queues_only_the_fixed_action() { + let smoke_calls = Cell::new(0); + assert_eq!( + apply_request( + Request::AgentSmoke, + |_| false, + || {}, + || false, + || { + smoke_calls.set(smoke_calls.get() + 1); + true + }, + ), + STATUS_REJECTED + ); + assert_eq!(smoke_calls.get(), 0); + assert_eq!( + apply_request( + Request::AgentSmoke, + |_| false, + || {}, + || true, + || { + smoke_calls.set(smoke_calls.get() + 1); + true + }, + ), + STATUS_OK + ); + assert_eq!(smoke_calls.get(), 1); + assert_eq!( + apply_request(Request::AgentSmoke, |_| false, || {}, || true, || false,), + STATUS_REJECTED + ); + } + + #[test] + fn rejected_agent_smoke_retains_the_configured_credential_state() { + let configured = Cell::new(true); + let clear_calls = Cell::new(0); + assert_eq!( + apply_request( + Request::AgentSmoke, + |_| false, + || { + clear_calls.set(clear_calls.get() + 1); + configured.set(false); + }, + || configured.get(), + || false, + ), + STATUS_REJECTED + ); + assert!(configured.get()); + assert_eq!(clear_calls.get(), 0); + assert_eq!( + apply_request( + Request::Status, + |_| false, + || clear_calls.set(clear_calls.get() + 1), + || configured.get(), + || false, + ), + STATUS_CONFIGURED + ); + assert_eq!(clear_calls.get(), 0); + } + + #[cfg(core_dev_credential_protocol_host_test)] + fn socket_path() -> PathBuf { + std::env::temp_dir().join(format!( + "sos-core-dev-protocol-{}-{}.sock", + std::process::id(), + SOCKET_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )) + } + + #[cfg(core_dev_credential_protocol_host_test)] + fn run_cpp_client(operation: &str, stdin: &[u8], serve: impl FnOnce(UnixStream)) -> Output { + let path = socket_path(); + let listener = UnixListener::bind(&path).unwrap(); + let mut child = Command::new(env!("CORE_DEV_CPP_CLIENT")) + .arg(&path) + .arg(operation) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(stdin).unwrap(); + let (stream, _) = listener.accept().unwrap(); + serve(stream); + let output = child.wait_with_output().unwrap(); + drop(listener); + fs::remove_file(path).unwrap(); + output + } + + #[test] + #[cfg(core_dev_credential_protocol_host_test)] + fn production_cpp_client_and_rust_endpoint_interoperate_without_payload_output() { + let set_calls = Cell::new(0); + let clear_calls = Cell::new(0); + let configured = Cell::new(false); + let smoke_calls = Cell::new(0); + + let probe = run_cpp_client("probe", b"", |mut stream| { + serve_protocol( + &mut stream, + |_| { + set_calls.set(set_calls.get() + 1); + true + }, + || clear_calls.set(clear_calls.get() + 1), + || configured.get(), + || false, + ) + .unwrap(); + }); + assert!(probe.status.success()); + assert_eq!(probe.stdout, b"core_dev_credential=READY\n"); + assert!(probe.stderr.is_empty()); + assert_eq!((set_calls.get(), clear_calls.get()), (0, 0)); + + let mut input = SYNTHETIC_KEY.to_vec(); + input.push(b'\n'); + let set = run_cpp_client("set", &input, |mut stream| { + serve_protocol( + &mut stream, + |key| { + assert_eq!(key, SYNTHETIC_KEY); + set_calls.set(set_calls.get() + 1); + configured.set(true); + true + }, + || clear_calls.set(clear_calls.get() + 1), + || configured.get(), + || false, + ) + .unwrap(); + }); + input.zeroize(); + assert!(set.status.success()); + assert_eq!(set.stdout, b"core_dev_credential=SET\n"); + assert!(set.stderr.is_empty()); + assert!(!set + .stdout + .windows(SYNTHETIC_KEY.len()) + .any(|w| w == SYNTHETIC_KEY)); + + let status = run_cpp_client("status", b"", |mut stream| { + serve_protocol(&mut stream, |_| false, || {}, || configured.get(), || false).unwrap(); + }); + assert!(status.status.success()); + assert_eq!(status.stdout, b"core_dev_credential=CONFIGURED\n"); + assert!(status.stderr.is_empty()); + + let smoke = run_cpp_client("agent-smoke", b"", |mut stream| { + serve_protocol( + &mut stream, + |_| false, + || {}, + || configured.get(), + || { + smoke_calls.set(smoke_calls.get() + 1); + true + }, + ) + .unwrap(); + }); + assert!(smoke.status.success()); + assert_eq!(smoke.stdout, b"core_dev_agent_smoke=SUBMITTED\n"); + assert!(smoke.stderr.is_empty()); + assert_eq!(smoke_calls.get(), 1); + + let clear = run_cpp_client("clear", b"", |mut stream| { + serve_protocol( + &mut stream, + |_| true, + || { + clear_calls.set(clear_calls.get() + 1); + configured.set(false); + }, + || configured.get(), + || false, + ) + .unwrap(); + }); + assert!(clear.status.success()); + assert_eq!(clear.stdout, b"core_dev_credential=CLEARED\n"); + assert!(clear.stderr.is_empty()); + let status = run_cpp_client("status", b"", |mut stream| { + serve_protocol(&mut stream, |_| false, || {}, || configured.get(), || false).unwrap(); + }); + assert!(status.status.success()); + assert_eq!(status.stdout, b"core_dev_credential=EMPTY\n"); + assert!(status.stderr.is_empty()); + assert_eq!((set_calls.get(), clear_calls.get()), (1, 1)); + } + + #[test] + #[cfg(core_dev_credential_protocol_host_test)] + fn cpp_client_emits_golden_frames_and_handles_fragmented_acks_and_statuses() { + for (operation, expected) in [ + ("probe", PROBE_GOLDEN), + ("clear", CLEAR_GOLDEN), + ("set", SET_GOLDEN), + ("agent-smoke", AGENT_SMOKE_GOLDEN), + ] { + let input = if operation == "set" { + [SYNTHETIC_KEY, b"\n"].concat() + } else { + Vec::new() + }; + let output = run_cpp_client(operation, &input, |mut stream| { + let mut actual = Vec::new(); + stream.read_to_end(&mut actual).unwrap(); + assert_eq!(actual, expected); + for byte in OK_GOLDEN { + stream.write_all(&[*byte]).unwrap(); + } + }); + assert!(output.status.success()); + } + + for (status, expected_output) in [ + ( + STATUS_CONFIGURED, + b"core_dev_credential=CONFIGURED\n".as_slice(), + ), + (STATUS_EMPTY, b"core_dev_credential=EMPTY\n".as_slice()), + ] { + let output = run_cpp_client("status", b"", |mut stream| { + let mut actual = Vec::new(); + stream.read_to_end(&mut actual).unwrap(); + assert_eq!(actual, STATUS_GOLDEN); + let mut ack = OK_GOLDEN.to_vec(); + ack[5] = status; + stream.write_all(&ack).unwrap(); + }); + assert!(output.status.success()); + assert_eq!(output.stdout, expected_output); + assert!(output.stderr.is_empty()); + } + + for status in [STATUS_CONFIGURED, STATUS_EMPTY] { + let output = run_cpp_client("probe", b"", |mut stream| { + let mut request = Vec::new(); + stream.read_to_end(&mut request).unwrap(); + let mut ack = OK_GOLDEN.to_vec(); + ack[5] = status; + stream.write_all(&ack).unwrap(); + }); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("unexpected_status")); + } + + for (status, category) in [ + (STATUS_REJECTED, "request_rejected"), + (STATUS_WRONG_PEER, "wrong_peer"), + (STATUS_PROTOCOL_MISMATCH, "protocol_mismatch_status"), + (0xff, "bad_status"), + ] { + let output = run_cpp_client("probe", b"", |mut stream| { + let mut request = Vec::new(); + stream.read_to_end(&mut request).unwrap(); + let mut ack = OK_GOLDEN.to_vec(); + ack[5] = status; + stream.write_all(&ack).unwrap(); + }); + assert!(!output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + format!("error: Core development credential request failed ({category})\n") + ); + } + } + + #[test] + #[cfg(core_dev_credential_protocol_host_test)] + fn endpoint_ack_write_failure_is_reported() { + let mut stream = FailingAck(Cursor::new(PROBE_GOLDEN.to_vec())); + assert_eq!( + serve_protocol(&mut stream, |_| true, || {}, || false, || false), + Err("response_io") + ); + } + + #[cfg(core_dev_credential_protocol_host_test)] + fn endpoint_ack_for_fragments(fragments: &[&[u8]]) -> ([u8; ACK_BYTES], &'static str) { + let (mut client, mut server) = UnixStream::pair().unwrap(); + let endpoint = thread::spawn(move || { + serve_protocol(&mut server, |_| true, || {}, || false, || false).unwrap_err() + }); + for fragment in fragments { + client.write_all(fragment).unwrap(); + } + client.shutdown(Shutdown::Write).unwrap(); + let mut ack = [0_u8; ACK_BYTES]; + client.read_exact(&mut ack).unwrap(); + (ack, endpoint.join().unwrap()) + } + + #[test] + #[cfg(core_dev_credential_protocol_host_test)] + fn rust_endpoint_handles_fragmentation_and_rejects_invalid_or_short_frames() { + let (mut client, server) = UnixStream::pair().unwrap(); + let endpoint = thread::spawn(move || { + let mut fragmented = OneByteIo(server); + serve_protocol( + &mut fragmented, + |key| key == SYNTHETIC_KEY, + || {}, + || false, + || false, + ) + .unwrap() + }); + for byte in SET_GOLDEN { + client.write_all(&[*byte]).unwrap(); + } + client.shutdown(Shutdown::Write).unwrap(); + let mut ack = [0_u8; ACK_BYTES]; + client.read_exact(&mut ack).unwrap(); + assert_eq!(ack, OK_GOLDEN); + endpoint.join().unwrap(); + + let mut wrong_version = PROBE_GOLDEN.to_vec(); + wrong_version[4] = 2; + let (ack, category) = endpoint_ack_for_fragments(&[&wrong_version]); + assert_eq!(ack[5], STATUS_PROTOCOL_MISMATCH); + assert_eq!(category, "bad_version"); + + let mut wrong_operation = PROBE_GOLDEN.to_vec(); + wrong_operation[5] = 9; + let (ack, category) = endpoint_ack_for_fragments(&[&wrong_operation]); + assert_eq!(ack[5], STATUS_PROTOCOL_MISMATCH); + assert_eq!(category, "operation"); + + let oversized = b"SOSK\x01\x01\x02\x01"; + let (ack, category) = endpoint_ack_for_fragments(&[oversized]); + assert_eq!(ack[5], STATUS_PROTOCOL_MISMATCH); + assert_eq!(category, "request_too_large"); + + let (ack, category) = endpoint_ack_for_fragments(&[b"SOS"]); + assert_eq!(ack[5], STATUS_PROTOCOL_MISMATCH); + assert_eq!(category, "short_io"); + } + + #[test] + #[cfg(core_dev_credential_protocol_host_test)] + fn concurrent_status_reads_are_non_mutating() { + let configured = Arc::new(AtomicBool::new(true)); + let mut requests = Vec::new(); + for _ in 0..8 { + let (mut client, mut server) = UnixStream::pair().unwrap(); + let state = Arc::clone(&configured); + let endpoint = thread::spawn(move || { + serve_protocol( + &mut server, + |_| false, + || state.store(false, Ordering::Release), + || state.load(Ordering::Acquire), + || false, + ) + .unwrap() + }); + let request = thread::spawn(move || { + client.write_all(STATUS_GOLDEN).unwrap(); + client.shutdown(Shutdown::Write).unwrap(); + let mut ack = [0_u8; ACK_BYTES]; + client.read_exact(&mut ack).unwrap(); + ack + }); + requests.push((endpoint, request)); + } + for (endpoint, request) in requests { + assert_eq!(request.join().unwrap()[5], STATUS_CONFIGURED); + endpoint.join().unwrap(); + } + assert!(configured.load(Ordering::Acquire)); + } + + #[test] + #[cfg(core_dev_credential_protocol_host_test)] + fn cpp_client_classifies_bad_ack_fields_and_disconnects_without_frame_output() { + for (ack, category) in [ + (b"XOSK\x01\x01".as_slice(), "bad_magic"), + (b"SOSK\x02\x01".as_slice(), "bad_version"), + (b"SOS".as_slice(), "short_io"), + (b"".as_slice(), "short_io"), + ] { + let output = run_cpp_client("probe", b"", |mut stream| { + let mut request = Vec::new(); + stream.read_to_end(&mut request).unwrap(); + stream.write_all(ack).unwrap(); + }); + assert!(!output.status.success()); + let diagnostic = String::from_utf8_lossy(&output.stderr); + assert_eq!( + diagnostic, + format!("error: Core development credential request failed ({category})\n") + ); + assert!(!diagnostic.contains("SOSK")); + } + + let output = run_cpp_client("probe-closed-stdout", b"", |mut stream| { + serve_protocol(&mut stream, |_| true, || {}, || false, || false).unwrap(); + }); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("stdout_io")); + } +} diff --git a/apps/experience/src/core_dev_product.rs b/apps/experience/src/core_dev_product.rs new file mode 100644 index 0000000..affa921 --- /dev/null +++ b/apps/experience/src/core_dev_product.rs @@ -0,0 +1,143 @@ +#[derive(Clone, Copy)] +pub(crate) struct DevProductMarkers<'a> { + pub(crate) revision: &'a str, + pub(crate) build_variant: &'a str, + pub(crate) dev_credential: &'a str, + pub(crate) build_type: &'a str, + pub(crate) debuggable: &'a str, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ProductMarkerFailure<'a> { + pub(crate) name: &'static str, + pub(crate) expected: &'static str, + pub(crate) actual: &'a str, +} + +fn core_dev_revision(revision: &str) -> bool { + let mut fields = revision.split('.'); + matches!(fields.next(), Some("sos")) + && matches!(fields.next(), Some("core1dev")) + && matches!(fields.next(), Some(hash) if lower_hex_digest(hash)) + && matches!(fields.next(), Some(hash) if lower_hex_digest(hash)) + && fields.next().is_none() +} + +fn lower_hex_digest(value: &str) -> bool { + value.len() == 12 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +pub(crate) fn validate_dev_product( + markers: DevProductMarkers<'_>, +) -> Result<(), ProductMarkerFailure<'_>> { + if !core_dev_revision(markers.revision) { + return Err(ProductMarkerFailure { + name: "ro.build.version.incremental", + expected: "sos.core1dev.<12-lower-hex>.<12-lower-hex>", + actual: markers.revision, + }); + } + for (name, expected, actual) in [ + ( + "ro.sos.build_variant", + "core1-dev-credential", + markers.build_variant, + ), + ("ro.sos.dev_credential", "1", markers.dev_credential), + ] { + if actual != expected { + return Err(ProductMarkerFailure { + name, + expected, + actual, + }); + } + } + if markers.build_type != "userdebug" { + return Err(ProductMarkerFailure { + name: "ro.build.type", + expected: "userdebug", + actual: markers.build_type, + }); + } + // Lineage intentionally keeps userdebug Core globally non-debuggable. This + // is a hardening assertion, never the switch that enables this endpoint. + if markers.debuggable != "0" { + return Err(ProductMarkerFailure { + name: "ro.debuggable", + expected: "0", + actual: markers.debuggable, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_observed_hardened_core_dev_product_is_accepted() { + assert!(validate_dev_product(DevProductMarkers { + revision: "sos.core1dev.0123456789ab.cdef01234567", + build_variant: "core1-dev-credential", + dev_credential: "1", + build_type: "userdebug", + debuggable: "0", + }) + .is_ok()); + } + + #[test] + fn wrong_revision_build_type_marker_or_debug_posture_is_rejected() { + let valid = DevProductMarkers { + revision: "sos.core1dev.0123456789ab.cdef01234567", + build_variant: "core1-dev-credential", + dev_credential: "1", + build_type: "userdebug", + debuggable: "0", + }; + for (markers, name) in [ + ( + DevProductMarkers { + revision: "sos.core1.0123456789ab.cdef01234567", + ..valid + }, + "ro.build.version.incremental", + ), + ( + DevProductMarkers { + build_variant: "core1-ordinary", + ..valid + }, + "ro.sos.build_variant", + ), + ( + DevProductMarkers { + dev_credential: "0", + ..valid + }, + "ro.sos.dev_credential", + ), + ( + DevProductMarkers { + build_type: "user", + ..valid + }, + "ro.build.type", + ), + ( + DevProductMarkers { + debuggable: "1", + ..valid + }, + "ro.debuggable", + ), + ] { + assert_eq!(validate_dev_product(markers).unwrap_err().name, name); + } + } +} diff --git a/apps/experience/src/lib.rs b/apps/experience/src/lib.rs index 0726488..22d0f42 100644 --- a/apps/experience/src/lib.rs +++ b/apps/experience/src/lib.rs @@ -14,7 +14,13 @@ mod assets; #[cfg(all(target_os = "linux", feature = "linux-host"))] mod compositor_fence; #[cfg(any(all(target_os = "android", feature = "core-native"), test))] +mod core_child_fds; +#[cfg(any(all(target_os = "android", feature = "core-native"), test))] mod core_credential; +#[cfg(any(all(target_os = "android", feature = "core-dev-credential"), test))] +mod core_dev_credential; +#[cfg(any(all(target_os = "android", feature = "core-dev-credential"), test))] +mod core_dev_product; #[cfg(all(target_os = "linux", feature = "linux-host"))] mod linux; #[cfg(all(target_os = "linux", feature = "linux-host"))] diff --git a/build-support/core_dev_credential_protocol.rs b/build-support/core_dev_credential_protocol.rs new file mode 100644 index 0000000..168b482 --- /dev/null +++ b/build-support/core_dev_credential_protocol.rs @@ -0,0 +1,100 @@ +use std::{ + collections::BTreeMap, + env, fs, + path::{Path, PathBuf}, +}; + +const NAMES: &[&str] = &[ + "SOS_CORE_DEV_V1_MAGIC_0", + "SOS_CORE_DEV_V1_MAGIC_1", + "SOS_CORE_DEV_V1_MAGIC_2", + "SOS_CORE_DEV_V1_MAGIC_3", + "SOS_CORE_DEV_V1_VERSION", + "SOS_CORE_DEV_V1_OP_PROBE", + "SOS_CORE_DEV_V1_OP_SET", + "SOS_CORE_DEV_V1_OP_CLEAR", + "SOS_CORE_DEV_V1_OP_STATUS", + "SOS_CORE_DEV_V1_OP_AGENT_SMOKE", + "SOS_CORE_DEV_V1_STATUS_OK", + "SOS_CORE_DEV_V1_STATUS_REJECTED", + "SOS_CORE_DEV_V1_STATUS_WRONG_PEER", + "SOS_CORE_DEV_V1_STATUS_PROTOCOL_MISMATCH", + "SOS_CORE_DEV_V1_STATUS_CONFIGURED", + "SOS_CORE_DEV_V1_STATUS_EMPTY", + "SOS_CORE_DEV_V1_REQUEST_HEADER_BYTES", + "SOS_CORE_DEV_V1_ACK_BYTES", + "SOS_CORE_DEV_V1_MAX_PAYLOAD_BYTES", +]; + +pub fn generate(workspace_root: &Path) { + println!("cargo:rustc-check-cfg=cfg(core_dev_credential_protocol_host_test)"); + let header = workspace_root.join("aosp/device/sos/a33x/core/dev_credential_protocol_v1.h"); + println!("cargo:rerun-if-changed={}", header.display()); + let source = fs::read_to_string(&header).expect("read canonical Core-dev v1 header"); + let mut values = BTreeMap::new(); + for line in source.lines() { + let mut fields = line.split_ascii_whitespace(); + if fields.next() != Some("#define") { + continue; + } + let Some(name) = fields.next() else { continue }; + if !NAMES.contains(&name) { + continue; + } + let value = fields.next().expect("protocol define value"); + assert!(fields.next().is_none(), "unexpected protocol define suffix"); + let parsed = if let Some(hex) = value.strip_prefix("0x") { + usize::from_str_radix(hex, 16) + } else { + value.parse() + } + .expect("numeric protocol define"); + assert!( + values.insert(name, parsed).is_none(), + "duplicate protocol define" + ); + } + for name in NAMES { + assert!(values.contains_key(name), "missing protocol define {name}"); + } + for name in &NAMES[..16] { + assert!( + values[name] <= u8::MAX as usize, + "byte define out of range {name}" + ); + } + assert_eq!( + [ + values["SOS_CORE_DEV_V1_MAGIC_0"], + values["SOS_CORE_DEV_V1_MAGIC_1"], + values["SOS_CORE_DEV_V1_MAGIC_2"], + values["SOS_CORE_DEV_V1_MAGIC_3"], + ], + [b'S' as usize, b'O' as usize, b'S' as usize, b'K' as usize], + "v1 magic changed" + ); + assert_eq!(values["SOS_CORE_DEV_V1_VERSION"], 1, "v1 version changed"); + assert_eq!( + values["SOS_CORE_DEV_V1_REQUEST_HEADER_BYTES"], 8, + "v1 request header changed" + ); + assert_eq!( + values["SOS_CORE_DEV_V1_ACK_BYTES"], 6, + "v1 acknowledgement changed" + ); + assert_eq!( + values["SOS_CORE_DEV_V1_MAX_PAYLOAD_BYTES"], 512, + "v1 maximum payload changed" + ); + let mut generated = String::from("// Generated from dev_credential_protocol_v1.h.\n"); + for name in NAMES { + let rust_name = name.trim_start_matches("SOS_CORE_DEV_V1_"); + generated.push_str(&format!( + "pub(super) const {}: usize = {};\n", + rust_name, values[name] + )); + } + let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")) + .join("core_dev_credential_protocol_v1.rs"); + fs::write(output, generated).expect("write generated Core-dev v1 Rust constants"); +} diff --git a/crates/core-agent-contract-test/Cargo.toml b/crates/core-agent-contract-test/Cargo.toml new file mode 100644 index 0000000..368c8bd --- /dev/null +++ b/crates/core-agent-contract-test/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "core-agent-contract-test" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[features] +core-native = [] +core-dev-credential = [] + +[dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/core-agent-contract-test/src/lib.rs b/crates/core-agent-contract-test/src/lib.rs new file mode 100644 index 0000000..e4896b4 --- /dev/null +++ b/crates/core-agent-contract-test/src/lib.rs @@ -0,0 +1,156 @@ +// Exercise the production pre-exec implementation without linking the desktop +// GPUI stack required by the full experience crate. +mod core_child_fds { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/experience/src/core_child_fds.rs" + )); +} + +mod android_agent_contract { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/experience/src/android_agent_contract.rs" + )); +} + +#[cfg(test)] +mod product_launch_contract { + use std::fs; + use std::path::{Path, PathBuf}; + + use super::android_agent_contract::{CoreChildLaunchContract, CORE_CHILD_LAUNCH}; + + fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn read(relative: &str) -> String { + fs::read_to_string(root().join(relative)).unwrap() + } + + fn soong_block<'a>(source: &'a str, kind: &str, name: &str) -> &'a str { + let header = format!("{kind} {{\n name: \"{name}\","); + let start = source.find(&header).unwrap(); + let remainder = &source[start..]; + let end = remainder.find("\n}\n").unwrap() + 3; + &remainder[..end] + } + + #[test] + fn installed_products_native_launch_policy_and_node_entrypoints_are_one_contract() { + let blueprint = read("aosp/device/sos/a33x/Android.bp"); + let ordinary_module = soong_block(&blueprint, "prebuilt_etc", "sos-agent-runner"); + let dev_module = soong_block(&blueprint, "prebuilt_etc", "sos-agent-runner-core-dev"); + assert!(ordinary_module.contains("src: \"prebuilts/sos-agent/agent-runner.cjs\"")); + assert!(ordinary_module.contains("filename: \"agent-runner.cjs\"")); + assert!(!ordinary_module.contains("core-dev")); + assert!(dev_module.contains("src: \"prebuilts/sos-agent/agent-runner-core-dev.cjs\"")); + assert!(dev_module.contains("filename: \"agent-runner-core-dev.cjs\"")); + assert_eq!( + blueprint.matches("filename: \"agent-runner.cjs\"").count(), + 1 + ); + assert_eq!( + blueprint + .matches("filename: \"agent-runner-core-dev.cjs\"") + .count(), + 1 + ); + + let ordinary_product = read("aosp/device/sos/a33x/lineage_sos_core1_a33x.mk"); + let dev_product = read("aosp/device/sos/a33x/lineage_sos_core1_dev_a33x.mk"); + assert!(ordinary_product + .lines() + .any(|line| line == " sos-agent-runner")); + for forbidden in [ + "sos-node-core-dev", + "sos-agent-runner-core-dev", + "sos-core-dev-credential", + "ro.sos.dev_credential", + ] { + assert!(!ordinary_product.contains(forbidden)); + } + for required in [ + " sos-node-core-dev \\", + " sos-agent-runner-core-dev", + "device/sos/a33x/sepolicy/core_dev_private", + ] { + assert!(dev_product.contains(required)); + } + + let ordinary_policy = + read("aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_agent.te"); + let ordinary_contexts = + read("aosp/device/sos/a33x/sepolicy/system_ext/private/file_contexts"); + assert!(ordinary_policy + .contains("domain_auto_trans(sos_core_host, sos_node_exec, sos_core_agent)")); + assert!(ordinary_policy.contains("allow netd sos_core_agent:fd use;")); + assert!(ordinary_policy.contains( + "allow netd sos_core_agent:tcp_socket { read write getattr setattr getopt setopt };" + )); + assert!(ordinary_contexts + .contains("/system_ext/bin/sos-node u:object_r:sos_node_exec:s0")); + assert!(!ordinary_policy.contains("sos_core_dev")); + assert!(!ordinary_contexts.contains("core-dev")); + + let dev_policy = + read("aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_agent.te"); + let dev_contexts = read("aosp/device/sos/a33x/sepolicy/core_dev_private/file_contexts"); + assert!(dev_policy.contains( + "domain_auto_trans(sos_core_host, sos_node_core_dev_exec, sos_core_dev_agent)" + )); + assert!(dev_policy.contains("allow netd sos_core_dev_agent:fd use;")); + assert!(dev_policy.contains( + "allow netd sos_core_dev_agent:tcp_socket { read write getattr setattr getopt setopt };" + )); + assert!(dev_contexts.contains( + "/system_ext/bin/sos-node-core-dev u:object_r:sos_node_core_dev_exec:s0" + )); + + let ordinary_entrypoint = read("services/sos-agent/src/runner.ts"); + let dev_entrypoint = read("services/sos-agent/src/runner-core-dev.ts"); + let package = read("services/sos-agent/package.json"); + assert!(ordinary_entrypoint.contains("runStdio({")); + assert!(!ordinary_entrypoint.contains("core-dev-proxy")); + assert!(dev_entrypoint.contains("CORE_DEV_PROXY_HOOKS")); + assert!(dev_entrypoint.contains("process.argv[2] !== \"stdio\"")); + assert_eq!( + package.matches("--outfile=dist/agent-runner.cjs").count(), + 1 + ); + assert_eq!( + package + .matches("--outfile=dist/agent-runner-core-dev.cjs") + .count(), + 1 + ); + + let native_cpp = read("aosp/device/sos/a33x/core/host.cpp"); + assert!(!native_cpp.contains("agent-runner")); + assert!(!native_cpp.contains("sos-node")); + + #[cfg(not(feature = "core-dev-credential"))] + assert_eq!( + CORE_CHILD_LAUNCH, + CoreChildLaunchContract { + node_path: "/system_ext/bin/sos-node", + runner_path: "/system_ext/etc/sos-agent/agent-runner.cjs", + node_identity: "ordinary_node", + runner_identity: "ordinary_runner", + expected_domain: "sos_core_agent", + } + ); + #[cfg(feature = "core-dev-credential")] + assert_eq!( + CORE_CHILD_LAUNCH, + CoreChildLaunchContract { + node_path: "/system_ext/bin/sos-node-core-dev", + runner_path: "/system_ext/etc/sos-agent/agent-runner-core-dev.cjs", + node_identity: "core_dev_node", + runner_identity: "core_dev_runner", + expected_domain: "sos_core_dev_agent", + } + ); + } +} diff --git a/crates/core-dev-credential-protocol-test/Cargo.toml b/crates/core-dev-credential-protocol-test/Cargo.toml new file mode 100644 index 0000000..e41f9df --- /dev/null +++ b/crates/core-dev-credential-protocol-test/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "core-dev-credential-protocol-test" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[features] +core-dev-credential = [] + +[dependencies] +zeroize.workspace = true diff --git a/crates/core-dev-credential-protocol-test/build.rs b/crates/core-dev-credential-protocol-test/build.rs new file mode 100644 index 0000000..a207967 --- /dev/null +++ b/crates/core-dev-credential-protocol-test/build.rs @@ -0,0 +1,30 @@ +#[path = "../../build-support/core_dev_credential_protocol.rs"] +mod core_dev_credential_protocol; + +fn main() { + use std::{env, path::Path, process::Command}; + + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + core_dev_credential_protocol::generate(workspace.as_path()); + println!("cargo:rustc-cfg=core_dev_credential_protocol_host_test"); + let harness = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cpp_client_harness.cpp"); + println!("cargo:rerun-if-changed={}", harness.display()); + println!( + "cargo:rerun-if-changed={}", + workspace + .join("aosp/device/sos/a33x/core/dev_credential_client.cpp") + .display() + ); + let output = + Path::new(&env::var_os("OUT_DIR").expect("OUT_DIR")).join("core-dev-credential-cpp-client"); + let compiler = env::var_os("CXX").unwrap_or_else(|| "c++".into()); + let status = Command::new(compiler) + .args(["-std=c++20", "-Wall", "-Werror", "-Wextra", "-O2"]) + .arg(&harness) + .arg("-o") + .arg(&output) + .status() + .expect("run host C++ compiler"); + assert!(status.success(), "compile production C++ client harness"); + println!("cargo:rustc-env=CORE_DEV_CPP_CLIENT={}", output.display()); +} diff --git a/crates/core-dev-credential-protocol-test/src/lib.rs b/crates/core-dev-credential-protocol-test/src/lib.rs new file mode 100644 index 0000000..edb2e27 --- /dev/null +++ b/crates/core-dev-credential-protocol-test/src/lib.rs @@ -0,0 +1,17 @@ +#![allow(dead_code)] + +mod core_credential { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/experience/src/core_credential.rs" + )); +} + +// Exercise the production wire decoder and state-dispatch tests without +// linking the desktop GPUI stack required by the full experience crate. +mod core_dev_credential { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/experience/src/core_dev_credential.rs" + )); +} diff --git a/crates/core-dev-credential-protocol-test/tests/cpp_client_harness.cpp b/crates/core-dev-credential-protocol-test/tests/cpp_client_harness.cpp new file mode 100644 index 0000000..e31e2a6 --- /dev/null +++ b/crates/core-dev-credential-protocol-test/tests/cpp_client_harness.cpp @@ -0,0 +1,57 @@ +#define SOS_CORE_DEV_CREDENTIAL_NO_MAIN +#define SOS_CORE_DEV_CREDENTIAL_TEST_MAX_IO_BYTES 1 +#include +#include "../../../aosp/device/sos/a33x/core/dev_credential_client.cpp" + +namespace { + +const char *gSocketPath = nullptr; + +int connectTestEndpoint() { + const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -1; + sockaddr_un address{}; + address.sun_family = AF_UNIX; + const size_t pathBytes = strlen(gSocketPath); + if (pathBytes == 0 || pathBytes >= sizeof(address.sun_path)) { + close(fd); + return -1; + } + memcpy(address.sun_path, gSocketPath, pathBytes + 1); + const socklen_t addressLength = + offsetof(sockaddr_un, sun_path) + pathBytes + 1; + if (connect(fd, reinterpret_cast(&address), + addressLength) != 0) { + close(fd); + return -1; + } + return fd; +} + +ExchangeResult exchangeTest(uint8_t operation, const SecretBuffer &secret) { + const int fd = connectTestEndpoint(); + if (fd < 0) + return ExchangeResult::kEndpointUnavailable; + const ExchangeResult result = exchangeOnFd(fd, operation, secret); + close(fd); + return result; +} + +} // namespace + +int main(int argc, char **argv) { + if (argc != 3) + return 64; + gSocketPath = argv[1]; + const bool closeStdout = strcmp(argv[2], "probe-closed-stdout") == 0; + char probe[] = "probe"; + char *clientArgv[] = {argv[0], closeStdout ? probe : argv[2]}; + if (closeStdout) { + const int readOnly = open("/dev/null", O_RDONLY | O_CLOEXEC); + if (readOnly < 0 || dup2(readOnly, STDOUT_FILENO) < 0) + return 65; + close(readOnly); + } + return runClient(2, clientArgv, exchangeTest); +} diff --git a/docs/progress.md b/docs/progress.md index 7a19df9..8fe6f38 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -8997,3 +8997,2167 @@ SHA-256 run its separate one-sideload Core 1 no-Zygote readiness, exact Pi authority, credential-clear, leak/crash/AVC, manifest, and soak gate. No Core hardware claim is made here. + +## 2026-08-18 — Harden Core Node startup and reduce credential transcription risk + +**Goal / terminal Core r1 result:** Diagnose and remediate the failed exact +Core 1 live-agent request without granting executable-memory authority or +introducing remote secret injection. The installed artifact was +`/home/carlid/sos-agent-e2e-artifacts-20260818-r4/core.ota.zip`, revision +`sos.core1.0212677d7038.26ce1cb8445d`, 1,022,145,556 bytes, SHA-256 +`b14e4579ec12aa60673b63c225f7bc2ad3031c667aac0ece6ad54a2671de6b21`. +Sideload, Core no-Zygote readiness, and credential cleanup passed, but the +exact live request did not. Request handling started at `09:15:34.679`; the +Node child as `sos_core_host` was denied reading `/proc/meminfo` at `.783`, +then denied `{ execmem }` on its own process at `.787`, and the old generic +`stage=protocol category=request_failed` marker followed at `.845`. That is a +measured 0.166 seconds from request start to failure marker, not provider +latency. The finalized evidence root is +`/home/carlid/sos-agent-e2e-device-core-20260818-r1`; `manifest.tsv` has +SHA-256 +`cb562456f7d2b40368d48c46b655bf15a7943f720b83ddf3a8aea9b64c2ab0c2`. +The external verifier `/tmp/sos-core-manifest-independent-20260818-r1.txt` +recorded SHA-256 +`f9a3c9047a4b0a205305aec48a541dc6a9c655023f6e989458e83b3bf9e7b6d8`. +This remains a terminal Core request failure, not a hardware or provider PASS. + +**Changed / decision:** The immutable AArch64 `sos-node` prebuilt contains +the supported `--jitless` option and V8's `wasm_jitless` path. Core now places +`--jitless` before the fixed runner script in a tested argument contract and +emits a sanitized `hardening=jitless` launch marker. This avoids V8's JIT and +WebAssembly executable-code path rather than allowing `execmem`. Node also +reads the system memory total during fixed startup, so Core policy adds only +`open read` on `proc_meminfo`; it adds no map, ioctl, write, append, watch, or +executable-memory permission. A new source check and built-artifact inspector +require the argv ordering, prove the packaged Node supports `--jitless`, +require the hardened runtime marker and exact meminfo rule, and reject any +compiled Core-host process `execmem` allow. A dedicated child domain was +rejected for this milestone: the fixed signed Node/runner already inherits the +intended Core host boundary, and jitless removes the exceptional authority +that caused startup to fail. Hardware evidence must still prove this inherited +domain is sufficient without another relevant AVC. + +Core child handling now maps launch, timeout, request I/O, response I/O, +observation I/O, unsuccessful numeric exit, signal, empty successful response, +and invalid response to distinct machine-readable safe categories. Exit and +signal reports contain only numeric `child_exit` or `signal` metadata. A valid +sanitized runner error still preserves its allowlisted provider category and +bounded HTTP status. Child stderr remains discarded, and no stderr, provider +body, request or response body, key, payload, argv secret, or environment +secret is logged or returned. + +The trusted Core OpenRouter ceremony remains memory-only, non-persistent, +masked, and zeroized on cancel/exit. It now prefills and protects the fixed +`sk-or-v1-` prefix, groups suffix mask characters in fours, and displays the +suffix character count, reducing long-key transcription and deletion errors +without reveal, clipboard, history, network, ADB/USB injection, or any change +to generic text fields or Compat's IME. Broad `execmem`, remote secret entry, +temporary plaintext reveal, and the separate Compat InputMethodService +milestone were explicitly rejected. + +**Bounded host evidence:** `cargo check -p sos-experience --tests` passed. +Direct isolated Rust test binaries passed all six agent-contract tests and all +four credential-ceremony tests, including exact argv order, distinct safe +failure mappings, prefix protection, grouped counting, replacement, cancel, +clear, and zeroized lifecycle behavior. ARM64 Android +`cargo check -p sos-experience --target aarch64-linux-android +--no-default-features --features core-native` passed with the pinned NDK 29 +Clang/LLVM tools supplied through target-specific compiler, archiver, and +linker variables. `bash -n tools/a33xctl`, `./tools/a33xctl +check-core-agent-hardening`, `cargo fmt --all -- --check`, and `git diff +--check` passed. The ordinary host `cargo test -p sos-experience --lib` +compiled the changed crate but could not link because this workstation lacks +`libxkbcommon` and `libxkbcommon-x11`; the focused standalone tests above ran +the changed contracts instead. No Soong/product build, device operation, +credential use, or live provider call occurred. + +**Remaining risk / next gate:** Rebuild and inspect Core 1 only: the runtime, +Core credential UI, and Core SELinux policy changed, while all additions to +shared Rust contracts are gated to `core-native` and no Compat packaged input +changed. Run `./tools/a33xctl check-core-agent-hardening`, then +`./tools/a33xctl build-core1` and `./tools/a33xctl inspect-core1`; preserve the +new OTA and record its exact revision, byte size, and SHA-256 in a finalized, +independently verified host evidence root. Do not reuse the r4 Core artifact. +After a new artifact/serial authorization envelope, run one Core sideload and +inherent reboot, require exact Core no-Zygote readiness, enter a real key only +through the physical trusted ceremony, and issue one exact +`deepseek/deepseek-v4-flash-0731` request. Require the jitless launch marker, +successful child/response/action/validation/stage/authority-commit evidence, +no relevant AVC (especially `execmem` or `proc_meminfo`), credential clearing, +no child leak, crash scrutiny, deterministic manifest verification, and the +specified soak. This implementation does not claim that hardware PASS. + +## 2026-08-18 — Core r2 DNS remediation and development-only credential injection + +**Goal / terminal r2 evidence:** The exact Core artifact +`/home/carlid/sos-agent-e2e-artifacts-20260818-r2/core.ota.zip`, revision +`sos.core1.e05f91bb6f0b.f73060a1ee5a`, 1,022,153,785 bytes, SHA-256 +`c2bb7b842c4bfde49205fb474e272dc25d164f7a61d79c4af24a29aacc0ee96e`, +passed its one-sideload transport and Core no-Zygote readiness gate. The +sideload transfer took 77.093500 seconds, return-to-ADB took 87.813800 seconds, +and readiness inspection took 0.828757 seconds. The exact +`deepseek/deepseek-v4-flash-0731` request then failed from request start at +`10:32:36.127` to the sanitized failure marker at `10:32:36.693`, a measured +0.566 seconds. No required action, validation, stage, authority-commit, or UI +marker followed and no child remained. The old `execmem` and `proc_meminfo` +denials were absent, but the Node `MainThread` in `sos_core_host` was denied +`{ read }` on `net_dns_prop` at `10:32:36.659`. The runner incorrectly surfaced +that terminal connection startup failure as `stage=protocol +category=tool_sequence`. + +The finalized evidence root is +`/home/carlid/sos-agent-e2e-device-core-20260818-r2`. +`post-request-audit-corrected.txt` is 4,813 bytes with SHA-256 +`f27a546508832acd3f93bfcbea0ca3462b69a9670f39f1d330045c1a1c3b2480`; +`failure-followup-audit.txt` is 3,868 bytes with SHA-256 +`c163a8488156f91716c3af751bd8843295d3566f9dd390ebaba8759ba5fcf03e`. +The credential entered for r2 remains only in the running old Core process and +still requires cleanup by the device owner; no host-side credential or device +operation was performed during this remediation. + +**Changed / decision:** Core SELinux now uses only +`get_prop(sos_core_host, net_dns_prop)` for Bionic/Node resolver startup. It +adds no DNS-property write, broader property family, data/network authority, +or executable-memory permission. The shared Pi runner now inspects Pi's +terminal assistant error before deciding whether the authoring tool sequence +is structurally missing and performs a fixed OpenRouter hostname lookup before +provider execution so resolver startup has an unambiguous category. +DNS-resolution and connection failures are separate +sanitized `transport/dns_failure` and `transport/connection_failure` +categories; HTTP/provider failures remain provider categories, Rust-owned +process launch/I/O/exit/signal failures remain child categories, malformed +wire responses remain protocol categories, and `tool_sequence` is used only +when there is no prior terminal transport error and the actual bounded tool +order is invalid or incomplete. Raw error text, provider body, stderr, +request/response body, payload, and credential remain excluded from logs and +UI errors. + +Long manual entry of a disposable OpenRouter key is no longer required for +development artifacts. `build-core1-dev` enables a dedicated Rust feature and +the precise `SOS_ENABLE_CORE_DEV_CREDENTIAL_BUILD=1` product switch; the +product makefile rejects that switch outside `userdebug`/`eng`. It alone +packages `sos-core-dev-credential` and `ro.sos.dev_credential=1`. The endpoint +also requires both that property and `ro.debuggable=1`, binds one dedicated +abstract local socket, authenticates exact ADB shell uid 2000 plus +`SO_PEERSEC=u:r:shell:s0`, accepts only a versioned set/clear frame of at most +512 credential bytes with two-second I/O timeouts, serializes updates through +the existing `CredentialState` mutex, sets the existing changed marker, and +zeroizes bounded key buffers. Clear does not depend on request success and is +available while the Core experience remains running. + +`core1-dev-set-openrouter-key` validates the active Core 1 revision/product and +development gates, selects exactly one ready ADB device unless `--serial` is +given, disables terminal echo, accepts one line through stdin, and streams it +to the dedicated client. The key is never placed in process argv, shell +history, environment, properties, files, clipboard, screenshots, or normal +logs. Set/clear return fixed secret-free acknowledgements only. +`core1-dev-clear-openrouter-key` uses the same fail-closed product and peer +boundary. Ordinary `build-core1` omits the Rust endpoint feature; ordinary +inspection rejects the endpoint marker, client binary, and enabling property, +while the development inspector requires all three. SELinux grants shell only +`connectto` in userdebug/eng and carries an explicit user-build neverallow. +`adb shell input text`, temporary/persistent files, clipboard transport, +system properties containing the key, build-time embedded credentials, and a +general-purpose debug control plane were rejected. + +**Bounded host evidence:** `npm test` rebuilt the shared agent bundle and +passed all 14 tests, including secret-safe DNS/connection classification. +`cargo check -p sos-experience --tests` passed. The exact ARM64 Android feature +combination passed `cargo ndk -t arm64-v8a -P 31 check -p sos-experience +--no-default-features --features core-native,core-dev-credential`. The pinned +AOSP Clang `-std=c++20 -Wall -Werror -Wextra -fsyntax-only` check passed for +the dedicated client. `cargo fmt --all -- --check`, `bash -n` for the CLI and +host fixtures, and `git diff --check` passed. A focused host `cargo test` +compiled the changed Rust tests but could not link because the workstation +lacks unversioned `libxkbcommon` and `libxkbcommon-x11`; no test assertion +failed. No Soong/full product build, device operation, or provider call was +performed, so no hardware PASS is claimed. + +**Remaining risk / next gate:** A Core-only rebuild is sufficient: only the +Core runtime, Core product composition, its dedicated client, shared runner, +and Core policy changed. First run the focused host checks and host CLI mock, +then `./tools/a33xctl build-core1-dev` and +`./tools/a33xctl inspect-core1-dev`; preserve the OTA with exact revision, +size, and SHA-256 in a finalized independently verified evidence root. Also +build/inspect ordinary Core 1 once to prove the client, property, and endpoint +are absent. After a new exact artifact/serial authorization envelope, perform +one sideload and inherent reboot, require exact Core no-Zygote readiness, then +use the hidden one-paste set command and one exact live request. Require the +secret-free set marker, no credential bytes in argv/properties/files/logs, +successful child/response/action/validation/stage/commit/UI evidence, no +relevant AVC (especially `net_dns_prop` or `execmem`), no child residue, then +run the dedicated clear command even if the request fails and require only its +secret-free clear marker. Finish crash/leak scrutiny, deterministic manifest +verification, and the authorized soak. + +## 2026-08-18 — Confine Core agent DNS and reject obsolete net.dns policy + +**Goal / failed host gate:** Make the Core DNS remediation compatible with +Android's platform SELinux invariants without weakening enforcement or +granting network authority to the trusted render/compile/activation host. The +first ordinary Core product build stopped at the platform neverallow before +producing an OTA; no artifact or device operation followed. Evidence is under +`artifacts/host-gates/core-dev-20260818-01`. `build-core1.txt` is 530,178 +bytes, SHA-256 +`75278daed28fd61517bf457134501354772354f0dd8f06b1ed67290b17bd6abb`, +and records a measured 211.570464128-second build. Lines 344–370 show +`system/sepolicy/private/domain.te:2037` rejecting the expanded +`allow sos_core_host net_dns_prop:file { read getattr map open }` in both +Recovery and system_ext policy compilation. `process-preflight.txt` is 4,063 +bytes with SHA-256 +`6f8f3a2d2d6813f343db0eb8470e06067a8927e4b9be0d8fa9fd6e38c9360cca`. +The finalized `host-evidence-manifest.tsv` was independently verified. + +**Architecture / decision:** The platform rule says that `net.dns*` is being +removed and permits file reads only to init, system_server, vendor_init, and +dumpstate. `sos_core_host` was already a `coredomain`, so adding or moving the +same property rule to another ordinary coredomain cannot satisfy that +contract. Core now transitions only the immutable `sos-node` entrypoint from +`sos_core_host` into the dedicated `sos_core_agent` coredomain. The coredomain +attribute is required for a system_ext entrypoint; it is not used as a DNS +exception. The child receives only its inherited bounded stdin/stdout pipes, +the exact `proc_meminfo { open read }` startup grant, Android's supported +dnsproxyd and fwmarkd Unix-socket paths, a TCP client socket, and name-connect +to an SOS-labelled TCP 443 port. It does not receive `net_domain`, raw/UDP +networking, bind authority, arbitrary destination ports, host data, Binder, +graphics, input, platform-adapter, authority, or development credential +socket permissions. The trusted UI host loses `net_domain`, direct Node +execution, and the obsolete TCP provider/revision grants. Policy neverallows +structurally prohibit IP sockets in the host, `net_dns_prop` access in either +domain, and `execmem` in both domains. + +Granting the obsolete property to a coredomain, transitioning Node while +retaining that grant, leaving `net_domain` in the host, inheriting the host +domain with `execute_no_trans`, patching the platform exception list, using a +permissive domain, or hiding the denial with a custom `dontaudit` were +rejected. The standard `domain_auto_trans` boundary retains Android's normal +secure-exec behavior; no SOS source policy adds a permissive or dontaudit rule. +The existing `--jitless`, no-`execmem`, stdin-only credential, no persistence, +peer authentication, Core-dev-only packaging/runtime gates, ordinary-Core +exclusion, and transport-before-tool-sequence error ordering are unchanged. + +**Bounded host evidence:** `bash -n tools/a33xctl`, +`./tools/a33xctl check-core-agent-hardening`, +`./tools/a33xctl check-core-dev-credential`, +`bash tests/a33xctl-host-test.sh`, and `git diff --check` passed. The hardening +check now requires the coredomain transition, inherited-pipe and exact meminfo +rules, supported DNS/fwmark proxy path, TCP-443 label, and child cleanup; it +rejects host network sockets, Node `execute_no_trans`, any SOS +`net_dns_prop` allow/get/set, permissive/dontaudit source, and Core host/agent +`execmem`. The built-artifact inspector requires the compiled transition, +entrypoint, exact meminfo and DNS-proxy allows and rejects compiled host IP, +net.dns, or executable-memory authority. A focused in-memory expansion of the +changed system_ext rules passed the tree's `secilc -v -m -M true -G -c 30` +against the existing platform CIL in 4.8 seconds; no lunch, Soong, product +build, device operation, or live provider call was run. Rust, TypeScript, and +ARM64 application sources did not change in this bounded remediation, so the +prior milestone's passing focused application/ARM64 checks remain applicable. + +**Remaining risk / next gate:** The focused CIL replay proves syntax and the +platform neverallow relationship but is not an ordinary product build. Both +ordinary Core and Core-dev must be rebuilt because they share this SELinux +transition; preserve and inspect each output separately. Run the source gates, +then `./tools/a33xctl build-core1` and `./tools/a33xctl inspect-core1`, followed +by `./tools/a33xctl build-core1-dev` and +`./tools/a33xctl inspect-core1-dev`. Require both builds to pass Recovery and +system_ext policy compilation, require the compiled agent transition/DNS +proxy/TCP-443 confinement, and re-prove ordinary exclusion plus development +inclusion. Finalize independently verified host manifests containing each +artifact's path, revision, byte size, and SHA-256. No hardware PASS is claimed; +only after a fresh artifact/serial authorization envelope should the device +gate test exact Core no-Zygote readiness, the development set/request/clear +lifecycle, successful live DNS/TLS/request/action/commit/UI evidence, no +relevant enforcing AVC or crash, no leaked child, and the specified soak. + +## 2026-08-18 — Compile the Core-dev credential client against Bionic + +**Goal / failed host gate:** Complete the paired ordinary/Core-dev host build +gate without weakening bounded secret handling. The finalized evidence root is +`artifacts/host-gates/core-dev-20260818-02`. Its 529,822-byte +`build-core1-dev.txt`, SHA-256 +`69c07b68df2c678da05e3099b21d818826e1323c0c1adc767bc1e7f8456cc0ac`, +records a 203.183324584-second failed development build. Lines 399–405 are the +first actionable failure: Android clang, using `-nostdlibinc`, the Bionic +headers, and target `aarch64-linux-android10000`, rejects undeclared +`explicit_bzero` in the `SecretBuffer` destructor. The later lines 920–923 +`ninja: Missing restat` observation (`Image` older than `.config`) occurred +after that compile failure while other work was still draining and is +secondary fallout, not an independently established root cause. + +Ordinary Core built and inspected successfully. The preserved exact artifact +is +`artifacts/host-gates/core-dev-20260818-02/artifact-set/sos.core1.e05f91bb6f0b.294faad24721-ordinary-ota.zip`, +revision `sos.core1.e05f91bb6f0b.294faad24721`, 1,022,136,198 bytes, SHA-256 +`baf89a7f8a5bc1c4fac3f67116d87684eca9a8580a6db0d613f8dbcc8bc6991`. +No development OTA was produced. The host manifests were independently +verified; no device operation or hardware PASS followed. + +**Changed / decision:** The Android client now calls Bionic's +`memset_explicit`, declared by `` since API 34, over the complete +fixed-capacity secret array in its destructor. Unlike ordinary `memset` or +`std::fill`, that API guarantees the erasure is not optimized away; the current +platform module targets API 10000, where the declaration and libc symbol are +available. The fixed 513-byte allocation, maximum 512-byte credential, +stdin-only read, short-lived stack ownership, argv/property/build gates, +secret-free output, and absence of file or log transport are unchanged. + +`check-core-dev-credential` now resolves Soong's selected clang version from +the configured Lineage checkout and compiles the exact client source as an +AArch64 Android platform target with `-nostdlibinc`, platform libc++, Bionic, +libbase, the module's C++20/no-exceptions/no-RTTI configuration, and warnings +as errors. This catches missing or API-incompatible Android declarations that +a desktop-header syntax check cannot. No SELinux or product-composition file +changed in this remediation: the dedicated `sos_core_agent` DNS proxy/TCP-443 +confinement, no-`execmem` invariant, ordinary production exclusion, and +explicit development injection contracts remain intact. + +**Bounded host evidence:** `./tools/a33xctl check-core-dev-credential` passed +the strengthened exact-source Android/Bionic compile, and +`./tools/a33xctl check-core-agent-hardening` passed. The mock-only +`bash tests/a33xctl-host-test.sh`, `bash -n` over that test and `tools/a33xctl`, +the pinned AOSP clang-format dry run over the client, and `git diff --check` +passed. No lunch, Soong, complete product build, device operation, or provider +call was run in this remediation. + +**Remaining risk / next gate:** The focused Android compile establishes the +correct declaration and frontend/module compatibility but does not produce or +link a product artifact. Rebuild and inspect both ordinary Core 1 and Core-dev +for the fresh artifact set: although the fixed client is packaged only in the +development image, the revision identity hashes the complete shared staged +device tree, so the preserved pre-fix ordinary artifact must not be paired +with the new development artifact as one post-fix source set. Run the source +gates, then `./tools/a33xctl build-core1` plus `inspect-core1`, followed by +`build-core1-dev` plus `inspect-core1-dev`; require ordinary exclusion, +development inclusion, the shared compiled policy invariants, and separately +finalized, independently verified artifact identities and manifests before a +new hardware authorization envelope. + +## 2026-08-18 — Give Core-dev an immutable product identity + +**Goal / terminal r3 host evidence:** Diagnose the reported Core-dev packaging +failure without accepting or installing an ambiguous artifact. The finalized +host root is `artifacts/host-gates/core-dev-20260818-03`; its 2,028-byte +`host-evidence-manifest.tsv` has SHA-256 +`7738021863c33b97f375fdf7df9e6b64a9145578aaa04c56f073aedaf5c94a2c`. +The independently verified 350-byte artifact manifest has SHA-256 +`40c3c4a921f41e068d5b01116ab6219d47ada10e772da6f6895b9c22c9e3c027`. +Ordinary Core built in 381.147699930 seconds and inspected in 18.361229205 +seconds. Its accepted artifact is +`artifacts/host-gates/core-dev-20260818-03/artifact-set/sos.core1.e05f91bb6f0b.85ca93719b2b-ordinary-ota.zip`, +revision `sos.core1.e05f91bb6f0b.85ca93719b2b`, 1,022,166,846 bytes, SHA-256 +`86d046d024075fa4f375d34accdbd6cfee09dd23be62b09565083d28b5bbc8eb`. +Core-dev built in 350.978178612 seconds and its inspection failed in +18.041304196 seconds at line 337 with the message that the client was absent. +The rejected artifact remains preserved only as +`artifacts/host-gates/core-dev-20260818-03/artifact-set/sos.core1.e05f91bb6f0b.45c3e250577d-dev-ota-rejected.zip`, +revision `sos.core1.e05f91bb6f0b.45c3e250577d`, 1,022,152,827 bytes, SHA-256 +`fc30518bfca5eaea44db47195696439167c9ae4fdbde8de6d5f665b8af7b1d27`. +No device operation or hardware PASS followed. + +**Root cause / decision:** The failure text was a false absence diagnosis, not +proof that Soong omitted the module: `build-core1-dev.txt` line 11342 records +`SYSTEM_EXT/bin/sos-core-dev-credential` being added to target-files. The +inspector used host `-x` on an unpacked target-files entry; those files do not +preserve the final image execute bit as their host mode. The authoritative +`META/system_ext_filesystem_config.txt` records the installed mode. The +inspector now requires a regular file at the exact system_ext path and exact +`0 2000 755 capabilities=0x0` image metadata, while ordinary inspection +requires both the file and metadata entry to be absent. + +The old design still had a real reproducibility defect: ordinary and dev used +the same `lineage_sos_core1_a33x` product, target-files directory and OTA name, +with an ad hoc shell environment switch and the same `sos.core1` revision +namespace. It could overwrite one variant with the other and made product +identity depend on caller intent. Core-dev is now the registered, non-shipping +`lineage_sos_core1_dev_a33x-userdebug` product. Its makefile unconditionally +selects the dedicated client and `ro.sos.dev_credential=1`, rejects `user`, and +sets `ro.sos.build_variant=core1-dev-credential`; ordinary Core sets +`core1-ordinary` and never references the client. The CLI selects the distinct +product and `sos.core1dev..` revision namespace in the same +branch that enables the `core-dev-credential` Rust feature. Target-files, +package names and AVB product fingerprints are consequently disjoint. The +Samsung no-Zygote selection now explicitly includes both product names. +Keeping the shared product plus a late environment variable, trusting host +mode bits, or mutating a completed image were rejected. + +**Inspector and bounded host evidence:** Core inspection derives the variant +from target-files contents: exact `ro.sos.build_variant`, revision namespace, +and AVB product fingerprint, then checks the runtime feature markers, client, +filesystem metadata, development property, and the existing compiled policy +confinement. Device readiness likewise requires a consistent ordinary or dev +revision/property tuple, and credential commands accept only `sos.core1dev` +with the exact dev property plus `ro.debuggable=1`. Direct GNU Make expansion +passed for both product files, proved ordinary exclusion and exact dev +package/properties, and rejected a `user` dev expansion. The incremental +Samsung source patch applies cleanly over the existing no-Zygote patch. +`./tools/a33xctl check-product-graph`, `check-core-dev-credential` (including +the exact AArch64 Bionic compile), `check-core-agent-hardening`, the mock host +test, Bash syntax checks, `cargo check -p sos-experience --tests`, `cargo fmt +--all -- --check`, and the exact ARM64 Android +`core-native,core-dev-credential` Cargo check passed. No lunch, Soong/full +product build, device operation, credential, or provider call was run in this +remediation. + +**Remaining risk / next gate:** Both images require a fresh rebuild: product +composition, image identity, inspector rules, and the staged device-tree hash +changed. Do not reuse or rename either r3 artifact, especially the rejected +dev OTA. Run the source gates, then `build-core1` plus `inspect-core1`, followed +by `build-core1-dev` plus `inspect-core1-dev`. Preserve the two distinct OTAs +and target-files roots; require ordinary exclusion, dev inclusion and 0755 +filesystem metadata, exact ordinary/dev property and revision namespaces, AVB +product identity, compiled no-Zygote/policy invariants, and independently +verified manifests with path, byte size and SHA-256. Only a fresh accepted +Core-dev artifact may define a later device authorization envelope; no +hardware claim is made here. + +## 2026-08-18 — Make the a33x source patch bootstrap series-aware + +**Goal / terminal r4 host evidence:** Preserve the immutable ordinary/Core-dev +product split while repairing the source bootstrap failure before another full +build. The finalized and independently verified failed-gate root is +`artifacts/host-gates/core-dev-20260818-04`. Its 1,740-byte +`host-evidence-manifest.tsv` has SHA-256 +`227ce91c6f8e9fa8f428ef3174673029aa9f6de2d93dc3c44518a370a7190492`; +the 217-byte artifact manifest has SHA-256 +`574da9b08c3cce0523423c974e6b5149613ea7d2fb0e30880fbab65d9b1660`. +The source revision was `e05f91bb6f0b0a9299b914138d6cd0966b9c82d5`. +Product-graph, Core hardening, development-credential/Bionic compile, host +mock, Bash syntax and diff checks all passed in respectively 0.020677657, +0.029229189, 0.595108485, 0.303495013, 0.004776570 and 0.009277818 +seconds. Ordinary Core built in 349.654816592 seconds and inspected in +18.077148404 seconds. Its accepted r4 artifact is +`artifacts/host-gates/core-dev-20260818-04/artifact-set/sos.core1.e05f91bb6f0b.036045ca4e01-ordinary-ota.zip`, +revision `sos.core1.e05f91bb6f0b.036045ca4e01`, 1,022,158,595 bytes, +SHA-256 +`4994eaae194175a6e34610606cca9c880d39218adf6bbc15960c0ed5b29905a9`. +Core-dev then stopped before artifact creation in 0.418578584 seconds when +patch 0005 failed at `common.mk:15`. No device access or hardware claim +occurred. + +**Root cause / decision:** Patches 0005 and 0008 are a valid dependent ordered +sequence: 0005 introduces the ordinary Core no-Zygote conditional and 0008 +extends that exact line to the distinct Core-dev product. After the ordinary +build had applied the complete sequence, bootstrap tested 0005 alone. Neither +its forward form nor its reverse form matches the post-0008 file, so the old +per-patch reverse-check misclassified the correct final tree as a conflict. +Patch fuzz, rejects, skipped failures, ad hoc checkout edits, and order changes +were rejected. + +Bootstrap now pins all three affected Lineage project revisions and all eight +patch SHA-256 values, constructs each complete per-project result in a +temporary Git index from `HEAD`, and compares only patch-owned paths with the +pinned baseline and canonical final result. A clean project receives patches +in the exact global 0001–0008 order; a complete project is an idempotent no-op; +any mixed or foreign state fails before mutation. A second preflight requires +the complete result after application. Repository product composition remains +single-owned by the checksum/delete staging overlay under +`aosp/device/sos/a33x` (`AndroidProducts.mk`, the two Core product files and +`sos_core1_common.mk`); the ordered patches own only their external Samsung, +vendor and framework checkout paths. Ordinary and development invocations +therefore share one canonical source bootstrap without overwriting or +conditionally creating product files. + +Patch 0008 now records its explicit dependency on 0005; its diff is unchanged +and its new provenance SHA-256 is +`842d80ad1d2e18c2199f253f9d645cb1b92685f6633d1c2cc299278369b03c0a`. +Because build identity hashes the ordered patch inputs, both ordinary and dev +must receive fresh revisions and artifacts; the otherwise accepted r4 +ordinary OTA cannot be reused as post-remediation evidence. + +**Focused evidence:** `./tools/a33xctl check-patch-series` passed against the +real complete checkout and reported all eight exact ordered hashes. +`tests/a33xctl-patch-series-test.sh` sparse-cloned the pinned baseline paths +into disposable worktrees, applied the complete stack, applied it a second +time, and proved a byte-identical diff plus final canonical state. Two direct +`./tools/a33xctl apply-patches` calls also passed as no-ops on the real final +tree. `./tools/a33xctl check-product-graph`, `check-core-agent-hardening`, +`check-core-dev-credential`, the existing host mock, Bash syntax checks and +`git diff --check` passed. No lunch, Soong/full build, device command, +credential use or provider call ran in this remediation. + +**Remaining risk / next gate:** The temporary-index and sparse-worktree gates +prove applicability and idempotence, not image production. Start a fresh host +evidence root and run `check-patch-series`, the patch-series test, +`check-product-graph`, `check-core-agent-hardening`, +`check-core-dev-credential`, the host mock and shell/diff checks. Then rebuild +and inspect ordinary Core with `build-core1` / `inspect-core1`, followed by +Core-dev with `build-core1-dev` / `inspect-core1-dev`. Require new distinct +`sos.core1` and `sos.core1dev` revisions, ordinary development exclusion, dev +inclusion and 0755 metadata, exact product fingerprints/properties, compiled +no-Zygote and policy invariants, and finalized independently verified +manifests before defining any hardware authorization envelope. + +## 2026-08-18 — Bind Core-dev capability to its hardened product contract + +**Goal / terminal r6 device evidence:** Remediate the Core-dev readiness-only +failure without enabling broad Android debugging. On serial `RFCT50EGFCN`, the +exact OTA +`artifacts/host-gates/core-dev-20260818-06/artifact-set/sos.core1dev.e05f91bb6f0b.de1c4db550bb-dev-ota.zip`, +revision `sos.core1dev.e05f91bb6f0b.de1c4db550bb`, was 1,022,156,750 +bytes with SHA-256 +`99f63dfac0c20b4dfd772d8522aaa9d7facd1139d81388fd1691bae7dcaaaea`. +The exact sideload passed with exit 0 and `Total xfer: 1.00x` in +85.145654875 seconds; the inherent return to the device transport passed in +79.335597623 seconds. Readiness then failed in 0.467502212 seconds only at the +Core-dev product tuple in `tools/a33xctl` lines 2208–2211. No credential set, +clear, or provider request was attempted. + +The independently verified read-only diagnostic root is +`artifacts/device-gates/core-dev-20260818-06-rfct50egfcn-diag-01`. +Its 458-byte `diagnostic-manifest.tsv` has SHA-256 +`2b9d9bd915bffa84f3300ce042439cb088b3ebe7218e1936842b7d9027d2dae6`. +The 4,145-byte `device-snapshot.txt` (SHA-256 +`40ce97f603fda00742a42fe2eac32a5303d33cce64ad180d9c34830eef32bcc8`) +records the exact safe values: correct `sos.core1dev` revision, +`ro.sos.build_variant=core1-dev-credential`, +`ro.sos.dev_credential=1`, `ro.build.type=userdebug`, and +`ro.debuggable=0`. It also proves the client existed as executable +`0755 root:shell`, although the old image still gave it the generic +`u:object_r:system_file:s0` label. The 2,908-byte +`readiness-source-context.txt` has SHA-256 +`114d20cad182a7aaaef31122cd1ae029b77d995ca8ce735973877ff9231fd246`. + +**Root cause / decision:** The global value is deliberate, not a malformed +Core product. Pinned Lineage `vendor/lineage/config/common.mk` sets +`PRODUCT_NOT_DEBUGGABLE_IN_USERDEBUG := true`; the platform +`gen_build_prop.py` consequently emits `ro.debuggable=0`, disabling broad +target debugging and adb root while retaining the `userdebug` build type. +The Rust endpoint, installed client, host set/clear validation, and readiness +had incorrectly treated `ro.debuggable=1` as the development capability. +Setting that global property to 1, weakening verified boot or SELinux, +granting adb root, broadening Android debugging, or trusting caller flags and +runtime-writable properties were rejected. + +Core-dev now has one exact product contract shared by the endpoint semantics +and mirrored at its C++/host boundaries: a strict +`sos.core1dev.<12-lower-hex>.<12-lower-hex>` revision, immutable +`ro.sos.build_variant=core1-dev-credential`, immutable +`ro.sos.dev_credential=1`, the registered `ro.build.type=userdebug`, and the intentional +`ro.debuggable=0` hardening assertion. Global debuggability is no longer an +enable switch. The endpoint remains compile-time gated by the distinct +`core-dev-credential` Rust feature; only the registered distinct product +packages the stdin-only client. The client now carries the dedicated +`sos_core_dev_credential_exec` image label, and readiness plus set/clear +validate its fixed path, `0755 root:shell` metadata and exact label before any +request. Ordinary `sos.core1` continues to exclude the package, feature and +property and fails every dev command. + +Every product mismatch now reports only a safe marker name plus expected and +sanitized actual value, localizing revision, build type, immutable marker, +debugging posture, client presence, mode, owner, group or label failures +without printing a secret. The hidden terminal prompt, stdin-only transfer, +bounded memory-only set/clear, zeroization, peer uid plus `SO_PEERSEC`, +production policy exclusion, dedicated `sos_core_agent` DNS proxy/TCP-443 +path, no host networking or `execmem`, and transport-before-tool-sequence +classification are unchanged. + +**Focused host evidence:** `./tools/a33xctl check-patch-series`, +`check-product-graph`, `check-core-agent-hardening`, and +`check-core-dev-credential` passed; the last includes the exact AArch64 +Bionic client compile and source proof for Lineage's deliberate debugging +posture. `tests/a33xctl-host-test.sh` passed the observed +`userdebug + ro.debuggable=0` readiness, pseudo-terminal hidden set, and clear +paths, ordinary exclusion, and safe diagnostic rejection for a wrong +revision, wrong build type, wrong marker, and missing client. A standalone +Rust product-contract binary passed 2/2 focused tests. `cargo check -p +sos-experience --tests` and the pinned NDK 29 AArch64 Android check with +`--no-default-features --features core-native,core-dev-credential` passed. +`cargo fmt --all -- --check`, Bash syntax checks, and the mock host test +passed. The ordinary `cargo test -p sos-experience --lib +core_dev_credential::tests` compiled the changed crate but could not link +because the workstation lacks `libxkbcommon` and `libxkbcommon-x11`; the +link-free check and standalone focused contract tests cover the changed Rust +logic. No product build, device command, credential, or provider call occurred +in this remediation. + +**Remaining risk / next gate:** Rebuild and inspect both ordinary Core and +Core-dev. The shared system-ext SELinux type/context inputs changed even +though ordinary Core still excludes the client, and the development runtime +and client contract changed; neither the r6 OTA nor any earlier artifact can +be reused. Run `check-patch-series`, the patch-series test, +`check-product-graph`, `check-core-agent-hardening`, +`check-core-dev-credential`, the host mock, Bash/format/diff checks, then +`build-core1` plus `inspect-core1`, followed by `build-core1-dev` plus +`inspect-core1-dev`. Preserve fresh distinct OTAs and target-files evidence +with exact revisions, byte sizes, SHA-256 values and independently verified +manifests. Require ordinary exclusion and `ro.debuggable=0`; require Core-dev +feature/client inclusion, `0755 root:shell` plus the dedicated label, the +exact immutable markers and `ro.debuggable=0`. Only then authorize one fresh +Core-dev sideload/inherent reboot/readiness transaction and proceed through +set, request, clear, crash/AVC scrutiny, manifest verification and soak. This +implementation does not claim a hardware PASS. + +## 2026-08-18 — Repair Core target-files SYSTEM-property inspection + +**Goal / failed r7 host gate:** Preserve the hardened `ro.debuggable=0` +artifact assertion for both ordinary Core 1 and Core-dev while making the +inspector read the property from the actual staged SYSTEM partition data. The +r7 source gates and ordinary build passed, with `build-core1` exiting 0 in +299.410686949 seconds. `inspect-core1` then exited 1 after 17.447966941 +seconds at `tools/a33xctl` line 1864 under `set -u`: +`system_properties: unbound variable`. Core-dev build and inspection did not +run, no artifact was accepted, and the device was untouched. + +The finalized evidence root is +`artifacts/host-gates/core-dev-20260818-07`. Its independently verified +1,212-byte `host-evidence-manifest.tsv` has SHA-256 +`2624b105401e801c2db9ba419207fc893ee165be70ccfab7fb7d60fb79033c91`. +The 23,454-byte `inspect-core1.txt` has SHA-256 +`4a3563f0967207a63225d0eb05aca032fb9e4c9ddb3525291753396f797707cc`; +the 521-byte `transaction-result.txt` has SHA-256 +`4c45018c99a9a1d1cf3307aac842097de04df1579171fd2bcb626f4c987acb30`. +The failed inspection identified the unaccepted ordinary output as revision +`sos.core1.e05f91bb6f0b.a33768b9e2f9`, 1,022,163,055 bytes, SHA-256 +`eac70a0543dba71e8072223339e20249ff34621483967abf56a5a91587fdcd58`. + +**Root cause / decision:** `inspect_core_stage` added a grep against +`$system_properties` but never initialized that local, so nounset stopped the +inspection before it could evaluate the image. The shared validator now +resolves exactly `SYSTEM/build.prop` below the selected target-files root, +rejects a missing or empty file with a fixed secret-safe diagnostic, copies +that staged image input into a disposable inspection directory, initializes +the checked path before use, and always removes the directory. It requires +exactly `ro.debuggable=0` for both ordinary and development variants and also +retains Core-dev's exact `ro.build.type=userdebug` assertion. It never falls +back to `SYSTEM_EXT/etc/build.prop` or a source-tree property file. The large +inspector and focused host harness source the same narrow validator. + +**Focused evidence:** `tests/a33xctl-host-test.sh` passed under +`set -euo pipefail`. Its matrix exercised ordinary and Core-dev against +missing, empty, exact `ro.debuggable=0`, and weakened `ro.debuggable=1` +SYSTEM data, using target and temporary paths containing spaces and proving +the Core-dev wrong-build-type rejection and the disposable directory empty +after every pass and rejection. A direct call +of the same validator passed against r7's generated ordinary target-files. +`./tools/a33xctl check-product-graph`, `check-core-agent-hardening`, and +`check-core-dev-credential` passed. `bash -n` over the CLI, host test, and ADB +fixture, `cargo fmt --all -- --check`, and `git diff --check` passed. No full +build, complete package inspection, device operation, credential, or provider +call ran during this repair. + +**Remaining risk / next gate:** This inspector-only change does not alter the +staged device tree, patch series, packaged binaries, or content-derived +revision inputs. The already-generated r7 ordinary source output can therefore +be re-inspected to prove the immediate regression without rebuilding and must +retain the same revision. It was not accepted or copied into the r7 artifact +set, however, and the paired host-gate policy may still require a fresh full +ordinary/Core-dev transaction. The runner should execute the source gates, +then `inspect-core1` against the existing ordinary output if permitted; +otherwise run `build-core1` then `inspect-core1`. Follow with +`build-core1-dev` and `inspect-core1-dev`, requiring both inspectors to prove +actual SYSTEM `ro.debuggable=0`, all ordinary exclusion and Core-dev inclusion +contracts, distinct finalized artifacts, and independently verified +manifests. This implementation does not claim an artifact or hardware PASS. + +## 2026-08-18 — Give the Core-dev credential client an executable capability boundary + +**Goal / terminal r8 evidence:** Repair the false Core-dev readiness failure +without broadening ADB shell or weakening the hardened product. The accepted +r8 sideload artifact was revision +`sos.core1dev.e05f91bb6f0b.98fc6094998a`, 1,022,167,071 bytes, SHA-256 +`86194432936d6aa34cd344062a578549b560d403e0b4c77ad4532ed0ae7abf88`. +The transport transaction in +`artifacts/device-gates/core-dev-20260818-08-rfct50egfcn` passed, but +readiness stopped before any credential or provider request with the safe +marker `client.presence expected=present actual=missing`. + +The independently verified, read-only diagnostic root is +`artifacts/device-gates/core-dev-20260818-08-rfct50egfcn-diag-01`. It proves +the exact client does exist on dm-4 at inode 29 and is byte-identical to the +host target: 51,344 bytes, SHA-256 +`2c09ca31a722e51fe7a7f62b778318bf9dfa95ceabe18dc3188879f11995b5c8`. +Enforcing SELinux correctly denied shell `getattr`/`test -e` against the +dedicated `sos_core_dev_credential_exec` type. The same evidence retained the +intended global `ro.debuggable=0` and exact immutable Core-dev markers. No key +was entered, no set/clear request ran, and no provider request ran. + +**Root cause / rejected approaches / decision:** Readiness incorrectly used a +shell-domain file-stat as a presence test after the image had gained its +dedicated executable label. Granting shell broad system-ext/file access, +relabelling the binary as `shell_exec`, executing the client in shell, +`permissive`/`dontaudit`, or weakening production neverallows would turn the +diagnostic around by destroying the intended boundary and were rejected. + +The immutable entrypoint now performs a `userdebug_or_eng` automatic +transition from shell into the dedicated `sos_core_dev_credential` domain. +That uid-2000 domain receives only its entrypoint, inherited ADB stdio, its own +Unix stream socket, and `connectto` for the Core host. It receives no explicit +network, filesystem, property, log, Binder, service-manager, or capability +authority, and shell is unconditionally forbidden from connecting directly. +The endpoint now accepts only uid 2000 plus exact +`u:r:sos_core_dev_credential:s0`; the former shell-context allow and peer +contract are gone. The client no longer links libbase or reads properties. +The feature-gated endpoint remains the owner of the immutable product check, +so ordinary Core excludes the binary, endpoint, property, and any effective +transition while user policy omits the transition itself. + +Protocol v1 gained a zero-payload `probe` operation. It authenticates the +transitioned peer, decodes a bounded frame, reaches the product-gated +endpoint, returns only `core_dev_credential=READY`, and does not call either +credential mutation path. Core-dev readiness and both set/clear commands now +run this executable handshake; ordinary readiness relies on immutable runtime +markers while the target-files inspector proves actual binary/endpoint/ +property exclusion. Safe host diagnostics separately classify missing +executable, SELinux execution denial, endpoint unavailable, rejected +peer/product, and protocol mismatch. Set still reads one hidden host-stdin +line only after a successful probe, set/clear payloads remain bounded and +memory-only, and both C++ and Rust buffers retain explicit/RAII zeroization. + +**Focused host evidence:** `./tools/a33xctl check-core-dev-credential` passed, +including user/userdebug macro expansion, a focused matching CIL compile with +the pinned `secilc`, policy-negative checks, exact product expansion, and the +Bionic AArch64 C++ compile. `tests/a33xctl-host-test.sh` passed readiness, +preflighted hidden-stdin set and clear, ordinary denial, and all five safe +probe-error categories. The link-independent protocol crate ran 5/5 tests, +including probe non-mutation, clear after rejected set, set/clear decoding, +exact uid/domain peer acceptance, and malformed/bounded-frame rejection. Host Rust test compilation +passed, and the pinned NDK 29 AArch64 check with +`core-native,core-dev-credential` passed after supplying the target LLVM +archiver. Bash syntax, `cargo fmt --all -- --check`, and `git diff --check` +passed. The full desktop experience test binary still cannot link because +this workstation lacks unversioned `libxkbcommon` and +`libxkbcommon-x11`; the focused executable tests and host compile cover the +changed protocol without that unrelated GUI dependency. No full build or +device operation ran for this implementation, and it does not claim hardware +PASS. + +**Remaining risk / next gate:** Both ordinary Core and Core-dev require fresh +builds because the shared system-ext policy changes; Core-dev also changes the +client and endpoint payload. Run `check-patch-series`, +`tests/a33xctl-patch-series-test.sh`, `check-product-graph`, +`check-core-agent-hardening`, `check-core-dev-credential`, the protocol test, +host mock, syntax/format/diff checks, then `build-core1` plus `inspect-core1` +and `build-core1-dev` plus `inspect-core1-dev`. Require ordinary target-files +to exclude the binary, endpoint and property so no transition can be effective, +and require Core-dev target-files to contain the exact 0755 root:shell +entrypoint label, dedicated domain transition, no shell socket allow, and +probe strings. Record +fresh artifact revisions, sizes, SHA-256 values and independently verified +manifests. Only after those host gates pass should a newly authorized +Core-dev sideload/inherent reboot/readiness transaction prove +`core_dev_credential=READY` with crash/AVC scrutiny before any set/request/ +clear/soak sequence. + +## 2026-08-18 — Make the Core-dev v1 credential protocol one cross-language contract + +**Goal / terminal r9 evidence:** Remediate the first device execution of the +dedicated Core-dev client without relaxing protocol or peer validation. The +paired r9 host gate at `artifacts/host-gates/core-dev-20260818-09` passed and +preserved the exact Core-dev OTA +`artifact-set/sos.core1dev.e05f91bb6f0b.cd7b7f965926-dev-ota.zip`, revision +`sos.core1dev.e05f91bb6f0b.cd7b7f965926`, 1,022,171,787 bytes, SHA-256 +`d163849f5f314403b6d55e7ba0954d820e88a639728e2847e30d401dd7639f48`. +On serial `RFCT50EGFCN`, Recovery entry passed in 2.241820492 seconds, the one +authorized sideload passed with exit 0 and `Total xfer: 1.00x` in +78.761798839 seconds, and the inherent wait for the device passed in +79.951946469 seconds. Core-dev readiness then exited 1 after 0.559423817 +seconds at the zero-payload client handshake with the safe aggregate +`endpoint.protocol expected=v1 actual=mismatch`. No credential was entered; +set, provider request and clear did not run. The finalized device root is +`artifacts/device-gates/core-dev-20260818-09-rfct50egfcn`, whose independently +verified 997-byte `device-evidence-manifest.tsv` has SHA-256 +`0f98c7fe9cb213d6f76c018d337a82f203502e40a549dd326a64690c65d2d128`. + +The independently verified read-only follow-up is +`artifacts/device-gates/core-dev-20260818-09-rfct50egfcn-diag-01`. Its +289-byte `device-diagnostic-manifest.tsv` has SHA-256 +`3128aaac41c8fe99452c73529575ff99de8863440ea7721d5500917a8940f93f`. +The 626-byte `device-protocol-snapshot.txt` (SHA-256 +`afc9eb4d8b72a1ba4dc0faf99dcfec60f9a1de2298001f1e1f68e5b54104f0e2`) +confirmed the expected Core-dev product, `userdebug`, `ro.debuggable=0`, +no-Zygote lifecycle, enforcing SELinux and required native process domains in +1.403296775 seconds. It found no endpoint/protocol/peer/socket/AVC rejection +marker and intentionally did not rerun the probe or mutate credential state. +Thus the preserved r9 evidence proves that execution reached the protocol +boundary, but cannot identify whether the aggregate came from request +rejection, bad acknowledgement magic/version/status, or short I/O. + +**Root-cause boundary / rejected relaxation / decision:** A byte audit found +the checked-in C++ and Rust v1 happy-path values nominally equal—`SOSK`, +version 1, operation byte, big-endian unsigned 16-bit payload length, and a +six-byte `SOSK/version/status` acknowledgement—but found that those values and +layouts were independently declared. The Rust server also treated all bytes +through peer EOF as one frame, while the client constructed the frame through +independent writes, and the Rust-only include test never compiled or executed +the production C++ exchange. Finally, the client collapsed bad acknowledgement +magic, version, protocol-rejection status and short I/O into two aggregate +outcomes. This combination allowed the r9 boundary failure to escape host +testing and left no preserved branch-level evidence. The unavailable r9 +branch is not guessed here. Accepting version 2, accepting unknown +magic/opcodes/status, changing status meanings, retrying, weakening the exact +uid/SELinux peer check, or merely relabelling the old aggregate was rejected. + +`dev_credential_protocol_v1.h` is now the sole numeric wire definition and +documents explicit golden request and acknowledgement vectors. Both the +production C++ client and generated Rust constants consume it; either Cargo +package fails its build if a required definition is missing, duplicated or +non-numeric. Compile-time assertions bind the C++ array/layout sizes and +maximum payload. The client constructs and explicitly erases one bounded +contiguous request frame, writes it with partial-write handling, half-closes +its write side, reads exactly one acknowledgement with partial-read handling, +and safely distinguishes unavailable connect, short I/O, bad magic, bad +version, protocol-rejection status, unknown status, wrong peer and rejected +request. It never prints a frame or payload. + +The Rust endpoint now reads an exact eight-byte header, validates magic and +version, decodes the big-endian length, bounds allocation before reading an +exact payload, requires EOF with no trailing byte, and maps malformed or +short input to the canonical protocol-mismatch acknowledgement. Probe remains +zero-payload and invokes neither set nor clear. Set remains a bounded, +zeroized, visible-ASCII `sk-or-v1-` value received only from the dedicated +client's hidden stdin; clear remains payload-free. The feature/product gate, +uid 2000 plus exact `u:r:sos_core_dev_credential:s0` peer check, dedicated +domain, no-network policy and ordinary production exclusion are unchanged. + +**Focused host evidence:** `cargo test --locked -p +core-dev-credential-protocol-test --lib -- --test-threads=1` compiled the +actual production C++ client logic and ran it against the actual Rust parser +and request dispatcher over Unix streams; all 9 tests passed. They cover exact +probe/set/clear golden bytes, a synthetic non-secret set fixture with no +payload output, probe non-mutation, rejected set followed by clear, +fragmented request and acknowledgement writes, zero payload, bad +magic/version/opcode/status, oversized and short frames, every defined status +and disconnects. `./tools/a33xctl check-core-dev-credential` passed the same +cross-language suite, pinned Bionic AArch64 C++ compile, policy compile and +source/product exclusions. `tests/a33xctl-host-test.sh` passed the expanded +safe diagnostic matrix. `cargo check --locked -p sos-experience --tests` and +the pinned NDK 29 `cargo ndk -t arm64-v8a -P 31 check -p sos-experience +--locked --no-default-features --features +core-native,core-dev-credential` passed. Cargo format, Bash syntax and +`git diff --check` passed. No product build, device command, credential or +provider operation ran in this remediation, and it does not claim hardware +PASS. + +**Remaining risk / next gate:** The r9 branch was not retained, so only a +fresh device run can prove the remediated binaries and, on failure, preserve +the new exact secret-free category. Rebuild and inspect both ordinary Core 1 +and Core-dev because the shared Rust build input and development client/runtime +payload changed. Run `check-patch-series`, the patch-series test, +`check-product-graph`, `check-core-agent-hardening`, +`check-core-dev-credential`, the cross-language protocol test, host mock, +format/syntax/diff checks, then `build-core1`/`inspect-core1` followed by +`build-core1-dev`/`inspect-core1-dev`. Require fresh distinct revisions, +ordinary endpoint/client/property exclusion, exact Core-dev package/feature/ +policy inclusion, and independently verified manifests. Only after that +paired host gate passes should a newly authorized transaction for serial +`RFCT50EGFCN` perform one exact Core-dev sideload, inherent reboot, no-Zygote +readiness and zero-payload probe. If probe passes, continue once through +hidden synthetic/disposable set, provider request, mandatory clear, crash/AVC +scrutiny, deterministic manifest verification and soak; if it fails, stop +before any credential and preserve the exact new client category plus the +server's safe rejection category. No r9 artifact may be reused. + +## 2026-08-18 — Accept the exact framed Core-dev status line without rebuilding r10 + +**Goal / r10 gate evidence:** Determine whether the r10 readiness aggregate +was a product failure or a host acceptance failure, then repair only the +failing layer. The exact installed OTA is +`sos.core1dev.e05f91bb6f0b.45b6c9467985`, 1,022,178,758 bytes, SHA-256 +`286d6ab695b1ed2871b7dbbea12d9982d14dcf4e075569fcf0cfc162c6ac0b4f`. +The finalized root +`artifacts/device-gates/core-dev-20260818-10-rfct50egfcn` records Recovery +entry in 2.359517643 seconds, one sideload with exit 0 and `Total xfer: 1.00x` +in 78.760980319 seconds, and inherent device return in 81.807627777 seconds. +Readiness stopped in 0.528203630 seconds at +`endpoint.protocol expected=v1 actual=mismatch`; no credential, provider +request, clear, extra reboot, or second sideload ran. Its independently +verified 810-byte manifest has SHA-256 +`c159c07697006ab8a01e42a254f6135bf70b03f0f2205a2cac07323e5415f09c`. + +The independently verified read-only root +`artifacts/device-gates/core-dev-20260818-10-rfct50egfcn-diag-01` has a +484-byte manifest with SHA-256 +`0099a3c9aa8887a4f5b7edc0c2a46980c5c56740c004399d3a50cb2df4d7f72e`. +Its 1.922600322-second snapshot proves the exact revision, `userdebug` with +`ro.debuggable=0`, enforcing SELinux, expected native domains, and the +installed client and experience library byte-identical to the r10 targets: +the root:shell 0755 client labeled +`u:object_r:sos_core_dev_credential_exec:s0` is 51,296 bytes with SHA-256 +`d7956d941bbb1d9c4a5e5dabff9a0c590a1933302e17bb392518512fdbbb44b9`; +the root:root 0644 library is 14,710,536 bytes with SHA-256 +`c4465419d6dfae0c9b6d96b513d73eef44c719be312aea1ef6018fa7b4680a93`. +Exactly one direct zero-payload production probe returned the canonical +newline-terminated `core_dev_credential=READY`, exit 0, in 0.042109757 +seconds. Its 111-byte record has SHA-256 +`89fc15792bbfdbf3887c89bd1dc1553015896073e649d653de9d57576cc9f270`. +That probe is a PASS for the client/endpoint handshake only, not completion of +the remaining hardware gate. + +**Root cause / change:** `tools/a33xctl` converted client output to a shell +scalar and compared it with an unframed token. That implicit trailing-newline +stripping could neither validate the protocol-required terminator nor safely +distinguish the canonical deployed reply from CRLF, missing termination, +extra lines, or binary bytes; r10 therefore became a false negative at the +host harness. One shared parser now captures stdout and stderr separately, +checks the raw stdout byte count, and accepts exactly one ASCII status token +terminated by LF or CRLF. It rejects a missing terminator, additional newline +or output, prefix/suffix/whitespace changes, NUL/binary data, wrong status, +and a nonzero client exit without printing captured output. Probe, readiness, +hidden-stdin set, and clear all use that parser. Probe failures retain the +existing secret-safe execution, presence, availability, peer, magic, version, +status, I/O, and aggregate categories. + +**Host evidence / decision / next gate:** The byte-level ADB mock reproduces +r10's exact LF reply and passes it through full readiness, also accepts the +intentional CRLF equivalent, and rejects missing/double newline, prefix, +suffix, whitespace, extra output, NUL, wrong status and nonzero-exit cases. +It additionally passes CRLF probe preflight plus set/clear acknowledgements +and rejects extra clear output. `tests/a33xctl-host-test.sh`, the product, +hardening, Core-dev and cross-language protocol checks, Bash syntax, format, +and diff hygiene pass; ShellCheck remains unavailable on this host. Only host +tooling, mocks, tests and this ledger changed, so the image payload, OTA bytes +and content-derived revision are unchanged and no rebuild or sideload is +required. Reuse the currently installed exact r10 image: a runner should run +`./tools/a33xctl inspect-core1-readiness --serial RFCT50EGFCN +--expected-revision sos.core1dev.e05f91bb6f0b.45b6c9467985`, preserve and +manifest its output, and require the complete no-Zygote readiness predicates +plus the zero-payload probe. The remaining live gate is the separately +authorized hidden disposable set, provider request, mandatory clear, +crash/AVC scrutiny, deterministic manifest verification and soak; this host +repair does not claim that gate passed. + +## 2026-08-18 — Move the Core-dev client to a no-PTY ADB shell transport + +**Goal / continuation and diagnostic evidence:** Reuse the installed exact r10 +image while determining why the corrected strict status parser still received +no acknowledgement. The independently verified continuation root +`artifacts/device-gates/core-dev-20260818-10-rfct50egfcn-cont-01` retried the +exact readiness command against revision +`sos.core1dev.e05f91bb6f0b.45b6c9467985`; it again stopped at +`endpoint.protocol expected=v1 actual=mismatch`, before any credential or +provider request. Its 449-byte manifest has SHA-256 +`7a0aeb23991ccd4e4375fc78d0e2ca3eb522aca75ab97e2abe7ec9edd86e5c40`. +The continuation recipe exited under `set -e` before appending its monotonic +timing fields, so this ledger does not infer a readiness duration from the +evidence timestamps. + +The independently verified diagnostic root +`artifacts/device-gates/core-dev-20260818-10-rfct50egfcn-diag-02` has a +616-byte manifest with SHA-256 +`b517786c7e5beb6c5eb95827cab339818761c61d13a699cedca1ae3b31c619a7`. +The exact command `adb -s RFCT50EGFCN exec-out +/system_ext/bin/sos-core-dev-credential probe `, invokes the existing fixed +smoke opcode, waits up to the existing 240-second provider budget plus cleanup +margin for one sanitized UI terminal, captures only allowlisted lifecycle lines +and a framebuffer, rechecks `CONFIGURED`, removes the reverse, stops the relay, +and deterministically creates and verifies its evidence manifest. EXIT and signal +traps remove the reverse and process; neither success, failure nor signal clears +the credential. + +**Bounded host evidence / rejected approaches:** `npm test` rebuilt the 1.8 MiB +ordinary and 2.8 MiB Core-dev single-file runners and passed 20 tests in 725 ms, +including exact product exclusion, proxy/model decoding, target-host rejection +and content-free observations. The initial +Undici 7.16 pin was rejected after `npm audit` identified current advisories; the +exact 7.29.0 pin reports zero production vulnerabilities. The Python relay suite +passed three tests in 1.512 seconds for loopback bind scope, method/authority/ +framing rejection, opaque bidirectional relay and empty stdout/stderr. +`tests/a33xctl-host-test.sh` passed in 3.2 seconds, including reverse setup/removal, +safe terminal/framebuffer evidence, deterministic manifest verification, +credential retention and cleanup on success, rejected submit and signal. The +production C++/Rust protocol suite passed 19 tests in 0.03 seconds; the inherited +FD/policy suite passed 16 in 0.01 seconds; both source gates passed. Pinned NDK 29 +API-31 ARM64 checks passed for both `core-native` in 0.80 seconds and +`core-dev-credential` in 0.59 seconds. Bash syntax, Cargo formatting and diff +hygiene passed. A generic HTTP proxy, arbitrary CONNECT target, key-bearing host +proxy, TLS termination, persistent property/environment switch, ordinary-agent +port grant and automatic credential clear were rejected because each would widen +the development envelope or expose provider material. + +**Decision / remaining risk / next gate:** This bridge is a development-only way +to validate the already shared runtime; it does not implement or validate native +Wi-Fi provisioning and it does not claim a build, provider call, device gate or +hardware PASS. The untested risks are Soong packaging of the product-exclusive +policy, ADB reverse reachability from `sos_core_dev_agent`, Android Node/Undici +CONNECT behavior, end-to-end provider TLS, and the live action/authority result. +Next build and inspect ordinary Core with `build-core1`/`inspect-core1`, then +Core-dev with `build-core1-dev`/`inspect-core1-dev`, preserving each exact revision, +size and SHA-256 and proving ordinary absence plus development inclusion. After +explicit authorization for that exact Core-dev OTA and serial, require complete +no-Zygote readiness and `CONFIGURED`, then run exactly +`./tools/a33xctl core1-dev-run-agent-smoke --serial RFCT50EGFCN`. Require one safe +terminal, all downstream action/validation/authority markers on success, a +framebuffer, no relevant crash or enforcing AVC, no residual reverse/bridge/child, +an independently verified manifest, and a final independent `CONFIGURED` status; +do not clear the credential automatically on either result. + +## 2026-08-18 — Give ordinary and Core-dev runners unique Soong install identities + +**Goal / r14 failure evidence:** Unblock the first paired build after the +Core-dev CONNECT milestone without weakening the development boundary or +changing Compat/Linux behavior. The ordinary `./tools/a33xctl build-core1` +transaction stopped in ckati after a measured 137.424486486 seconds, before an +OTA existed. Its finalized stderr at +`artifacts/host-gates/core-dev-20260818-14/raw/build-core1.stderr` is 527,070 +bytes with SHA-256 +`8339f4ffd4537e7f54334d6deab2da601874b77895866cf45b5c6129fa3d4ed7`. +The 8,601-byte source preflight at +`artifacts/host-gates/core-dev-20260818-14/raw/preflight-source.txt` has SHA-256 +`beda70c7de00c3ae0dc7799605c9b80fc69baa4f56e760a2e64ddf37a6d394bf`; +the independently verified manifest has SHA-256 +`8a8b97940e0a1cb49daa7d5d989336845e73abbe2aaded0df5b6063eb457f08e`. +The retained device remains on r13 with its retained key; no device command or +mutation was performed for this failure or fix. + +**Root cause / rejected approach:** `sos-agent-runner` and +`sos-agent-runner-core-dev` were separate `prebuilt_etc` modules but both set +`filename: "agent-runner.cjs"`. Soong emitted both install recipes into the +ordinary product's generated installs makefile before product override +filtering; lines 279249 and 279253 therefore produced the same +`system_ext/etc/sos-agent/agent-runner.cjs` target and ckati rejected the second +recipe. A module `overrides` relationship cannot make globally emitted Make +targets unique, so same-destination conditional replacement was rejected. + +**Fix / invariants:** The Core-dev module now has distinct source, module and +installed-file identities ending in `agent-runner-core-dev.cjs`; the +feature-gated Core runtime selects that exact immutable path while ordinary +Core keeps `agent-runner.cjs`. Runner selection moved out of the shared product +composition: every ordinary SOS A33x product explicitly selects only +`sos-agent-runner`, while the Core-dev product selects only its development +runner plus the existing credential client and distinct Node executable. The +credential executable type, file context and client domain policy also moved +into the existing product-exclusive Core-dev policy directory, leaving the +ordinary shared policy free of credential, smoke-proxy and development Node +symbols. Deterministic source/product/Soong audits now require one filename per +runner module, forbid the obsolete override, verify every ordinary product's +explicit selection and development exclusion, verify the Core-dev selection, +and exercise the runtime contract under both feature sets. The only model +remains `deepseek/deepseek-v4-flash-0731`; proxy authority, TLS certificate and +hostname validation, credential protocol and SELinux network permissions are +unchanged. + +**Bounded host evidence:** Ordinary and Core-dev contract builds each passed 16 +tests in 0.14 seconds (`cargo test --locked -q -p core-agent-contract-test +--lib`, with and without `--features core-dev-credential`). +`./tools/a33xctl check-core-dev-credential` passed the unique-install/product +audit, Android client compile, policy expansion and 19-test cross-language +protocol suite in 0.45 seconds. `bash tests/a33xctl-host-test.sh` passed in 2.78 +seconds. Bash syntax and `cargo fmt --all -- --check` passed together in 0.48 +seconds. No full Soong build, OTA inspection or hardware gate is claimed. + +**Decision / remaining risk / next paired gate:** The source-level collision is +removed, but only fresh full builds can prove the generated install graph and +target-files contents. The host runner must execute, in order, +`./tools/a33xctl build-core1`, `./tools/a33xctl inspect-core1`, +`./tools/a33xctl build-core1-dev`, and `./tools/a33xctl inspect-core1-dev`. +Record each finalized OTA revision, byte size and SHA-256 and require exactly +the ordinary runner in ordinary Core, exactly the distinct development runner +and intended credential/proxy controls in Core-dev, and no development +executable, module property or policy in ordinary Core. Do not touch the device +until a new exact Core-dev artifact, serial and transaction envelope receive +explicit authorization. + +## 2026-08-18 — Restore the sanitized accepted-request inspection marker + +**Goal / r15 failure evidence:** Resume the paired ordinary/Core-dev host gate +without weakening the shared UI attempt lifecycle. The ordinary +`./tools/a33xctl build-core1` command succeeded in a measured 376.847042027 +seconds, but `./tools/a33xctl inspect-core1` failed after 16.981760195 seconds +because the GPUI runtime did not contain the required literal prefix +`android_agent_request_accepted provider=`. The finalized 320-byte stderr at +`artifacts/host-gates/core-dev-20260818-15/raw/inspect-core1.stderr` has SHA-256 +`af03d9cfcb55d26db6f53fcf63137c201557dab900ed0114740db078a5fec4a3`; +the independently verified 754-byte evidence manifest at +`artifacts/host-gates/core-dev-20260818-15/host-evidence-manifest.tsv` has +SHA-256 +`02377fa09ff44b914d8aede1ef66d6ceac03611936a3996ecb39e9f9abaa9167`. +The ordinary OTA in shared build output was not accepted or preserved after +inspection failed. Core-dev build/inspection did not run and no device was +touched. + +**Root cause / fix / invariants:** The accepted-request log had been placed on +the correct successful agent-thread path, after the shared UI/Luau dispatch, +but inserted the numeric attempt before `provider`; the binary therefore +omitted the inspection contract's stable prefix. Acceptance also was not a +one-way state in the attempt contract, leaving exactly-once behavior implicit. +The attempt now advances from `DispatchStarted` to `Accepted` while constructing +the marker. Construction is rejected before dispatch, after an early terminal, +and on a second call. The production path logs that returned marker once only +after thread creation has accepted the request. Its fields are limited to the +allowlisted provider/model, pinned-policy label, numeric attempt and fixed +correlation label; prompt, key, response and arbitrary provider text cannot +enter it. This remains distinct from the earlier `core_ui_attempt +event=dispatch_started` event. Terminal taxonomy, fixed smoke prompt, retained +credential, development proxy tunnel, ordinary-product exclusion, +Compat/Linux paths and exact model `deepseek/deepseek-v4-flash-0731` are +unchanged. + +**Bounded host evidence / next gate:** `cargo test --locked -q -p +core-agent-contract-test --lib` passed 18 tests in 0.01 seconds both without and +with `--features core-dev-credential`. New cases prove received → dispatch → +accepted ordering, exactly one accepted marker, no accepted marker for an early +credential rejection, and sanitization of injected provider/prompt/key/response +content. Targeted Cargo formatting and the complete +`./tools/a33xctl check-core-agent-hardening` source/policy/contract gate passed; +`./tools/a33xctl check-core-dev-credential` also passed its exact-model, +cross-language protocol, Android compile, peer-policy and production-exclusion +checks with 19 tests in 0.02 seconds. The broader `cargo test -p sos-experience +android_agent_contract::tests --lib` could not start these tests because +unrelated in-progress `core_dev_credential.rs` code lacks `STATUS_REJECTED` in +five places; no change to that work was made. Fresh artifacts are still +required: rerun the complete paired sequence `./tools/a33xctl build-core1`, +`./tools/a33xctl inspect-core1`, `./tools/a33xctl build-core1-dev`, and +`./tools/a33xctl inspect-core1-dev`, preserving exact accepted artifact +revision, byte size and SHA-256 plus finalized independently verified evidence. +No hardware PASS is claimed. + +## 2026-08-18 — Restore exactly-once sanitized request-terminal evidence + +**Goal / r16 failure evidence:** Continue the paired ordinary/Core-dev host +gate without weakening the shared attempt state machine. The ordinary +`./tools/a33xctl build-core1` command passed in a measured 219.495510590 +seconds, then `./tools/a33xctl inspect-core1` failed after 17.146491019 seconds +because the AArch64 GPUI runtime omitted the required stable prefix +`android_agent_request_terminal stage=`. The finalized 317-byte stderr at +`artifacts/host-gates/core-dev-20260818-16/raw/inspect-core1.stderr` has SHA-256 +`250f7f1a9ee8e269cb0085597a0060f4f6a605087d2f2b35488df12b27b3b20d`. +The independently verified 754-byte manifest at +`artifacts/host-gates/core-dev-20260818-16/host-evidence-manifest.tsv` has +SHA-256 +`be11d59ae9cf49b92310aa4b132e0a7e398d654a586b86bc4261cb30218c7728`. +The unaccepted ordinary OTA remained only in shared build output at +`/home/carlid/dev/lineage-a33x/out/target/product/a33x/lineage-23.0-20260818-UNOFFICIAL-sos_core1_a33x.zip`: +revision `sos.core1.e05f91bb6f0b.b6271158153b`, 1,022,158,073 bytes, SHA-256 +`afad4cb139d62dd9a4b2dc14965c207b07ffe1f4e2e3e637071df44f5bc78c22`. +Core-dev build/inspection did not run, no OTA was accepted, and no device was +touched. + +**Root cause / fix / lifecycle audit:** The attempt state machine still made a +single one-way `Terminal` transition for preflight rejection, dispatch/runtime +failure and provider success/failure, but the centralized emitter rendered +only `core_ui_attempt event=terminal`; the request-terminal prefix required by +both `inspect-core1` and `inspect-core1-dev` had been dropped. The same audit +found the next required stable prefix, `android_agent_ui_terminal status=`, was +also absent. Both are now derived from the one terminal event, so neither can +be constructed for received/dispatch events and a second terminal transition +remains rejected. Request evidence begins with `stage` and UI evidence with +`status` exactly as the inspectors require. Metadata is limited to allowlisted +stage/category/provider/model, fixed pinned-model policy and correlation +labels, and the numeric attempt ID; no key, prompt, response, arbitrary error +or provider content is accepted. The source hardening audit now checks the +exact field-order prefixes for every native lifecycle marker consumed by the +two inspectors. The remaining inspected lifecycle/evidence prefixes already +matched their emitters. Predispatch attempt receipt, accepted-before-terminal +ordering and exactly-once acceptance, early-exit taxonomy, credential +retention, Core-dev fixed smoke/proxy path, ordinary exclusions, Compat/Linux +behavior and exact-only `deepseek/deepseek-v4-flash-0731` policy are unchanged. + +**Bounded host evidence / failure / next paired gate:** `cargo test --locked -q +-p core-agent-contract-test --lib` passed 19 tests in 0.01 seconds both without +and with `--features core-dev-credential`. The added semantic coverage proves +exactly one request terminal for success, post-dispatch failure and early +rejection; accepted/terminal order; both stable prefixes; rejection of terminal +marker construction from nonterminal events; and sanitization of injected +provider, prompt, key, response and error text. `./tools/a33xctl +check-core-agent-hardening` passed its exact-prefix source audit, policy +expansion and contract suite in 0.25 seconds; Bash syntax, Cargo formatting and +diff checks passed. The broader `cargo test --locked -q -p sos-experience +android_agent_contract::tests --lib` still cannot compile because unrelated +in-progress `core_dev_credential.rs` code lacks `STATUS_REJECTED` in five +places; that work was not changed. No new product build or hardware PASS is +claimed. The runner must execute the paired gate in order: +`./tools/a33xctl build-core1`, `./tools/a33xctl inspect-core1`, +`./tools/a33xctl build-core1-dev`, then `./tools/a33xctl inspect-core1-dev`. +Preserve each distinct accepted OTA revision, byte size and SHA-256 plus a +finalized independently verified evidence manifest; do not touch hardware +without a new exact artifact/serial authorization envelope. + +## 2026-08-18 — Carry the authenticated Core-dev tunnel through the complete preflight + +**Goal / terminal r17 evidence:** Run the exact fixed Core-dev smoke once on +`RFCT50EGFCN` through the already established loopback CONNECT bridge and ADB +reverse. It stopped before child or provider dispatch at +`stage=preflight category=network_unavailable` in 1.149496247 seconds. Bridge +cleanup passed, post-failure Core no-Zygote readiness passed, the memory-only +credential remained `CONFIGURED`, and no retry occurred. The finalized evidence +root is +`/home/carlid/dev/sos/artifacts/device-gates/core-dev-20260818-17-rfct50egfcn-cont-01`; +its independently verified manifest has SHA-256 +`e240f753804191fca1c01ff8bac3f557922414266accef182756b37d6eccdad4`. +The framebuffer at +`/home/carlid/dev/sos/artifacts/device-gates/core-dev-smoke-20260818T184949Z-rfct50egfcn/framebuffer.png` +is 205,090 bytes with SHA-256 +`29b89df9c191f7189920f0d1c3453d141f6c250a90aadddcffd12e5c90bff374`. +This is a terminal failed smoke, not a provider, latency, or hardware PASS. + +**Root cause / fix / invariants:** Receipt-time preflight consumed the armed +tunnel bit and allowed the offline fixed request, but `start_agent_prompt` +performed a second preflight using only the physical connectivity snapshot. It +therefore discarded the attempt-local tunnel decision after the shared Luau +dispatch and emitted the observed false `network_unavailable` terminal. The two +independent atomics also allowed an ordinary UI submission arriving between arm +and fixed-event delivery to steal the tunnel bit. + +The Core-dev-only authenticated callback now arms one mutex-protected, +process-memory authorization. The render thread consumes it once while creating +only the exact fixed prompt and moves an explicit `CoreDevFixedTunnel` transport +mode into the attempt snapshot; ordinary submissions always use +`ValidatedNetwork` and cannot consume the authorization. Consumption clears the +pending state before every preflight, queue, dispatch, or terminal path, and a +process abort discards the memory-only state. The fixed constructor rejects a missing/consumed authorization, wrong +transport, non-OpenRouter provider, or changed prompt, and the later preflight +revalidates both the prompt and the attempt transport. Credential, busy and +provider identity are still rechecked. The sole model remains +`deepseek/deepseek-v4-flash-0731`; the serialized endpoint remains exactly +`http://127.0.0.1:37173`, the Node decoder and host bridge retain exact +`openrouter.ai:443`, and end-to-end TLS hostname/certificate validation is +unchanged. The authorization, prompt, proxy, development Node path and tunnel +log label are all compiled out of ordinary Core; Compat, Linux and ordinary UI +requests retain validated-network preflight. + +A separate readiness handshake was rejected for this fix: only the confined +Core-dev child domain has authority to connect to TCP 37173, so probing it from +the UI host would widen SELinux authority, while launching an extra child would +expand the single fixed request protocol. Actual loopback, reverse, CONNECT and +upstream failures continue through the existing secret-free transport taxonomy +(`connect_refused`, `connect_reset`, `connect_timeout`, `network_unreachable`, +or TLS failure) rather than being mislabeled as physical-network preflight. + +**Bounded host evidence:** The contract suite passed 21 tests both without and +with `core-dev-credential`, covering offline fixed-tunnel dispatch, ordinary +offline rejection, missing/forged/consumed state, one-use consumption before +early and terminal exits, exact received → dispatch → accepted → terminal +ordering, and sanitized markers. The cross-language credential protocol suite +passed 20 tests, including failed-smoke credential retention. Node rebuilt both +runners and passed 20 tests, including exact proxy/model/host/TLS policy and +content-free transport classification. The loopback CONNECT bridge passed three +tests. Pinned NDK 29 API-31 ARM64 checks and debug builds passed for ordinary +`core-native` and `core-dev-credential`; byte inspection found none of the fixed +tunnel prompt, proxy, Node path, or transport label in ordinary Core and found +all four in Core-dev. Cargo formatting, Bash syntax and diff hygiene passed. No +full product build, provider request, device operation, or hardware PASS occurred +in this implementation phase. + +**Remaining risk / next paired host and device gate:** Rebuild and inspect fresh +ordinary Core and Core-dev artifacts in order, preserve each accepted OTA with +exact revision, byte size and SHA-256, and finalize and independently verify the +host manifest. Ordinary inspection must prove the development prompt, proxy, +Node path, credential endpoint and policy are absent; Core-dev inspection must +prove the fixed counterparts are present. Only after a new exact artifact, +serial and authorization envelope, require Core no-Zygote readiness and initial +`CONFIGURED`, then run one fixed smoke through the bridge. Require received → +dispatch → accepted → child → response/action/validation/activation → exactly +one terminal ordering on success, or a category-aligned transport terminal on +actual tunnel failure; also require credential retention, framebuffer, bridge/ +reverse/child cleanup, scoped crash and enforcing-AVC scrutiny, deterministic +manifest verification, post-run readiness and the authorized soak. Do not retry +or clear the credential automatically. + +## 2026-08-18 — Bind Core child launch identity and expose allowlisted spawn causes + +**Goal / terminal r18 evidence:** Diagnose the first authenticated Core-dev +fixed-tunnel attempt that passed offline preflight but stopped before a child or +provider existed. On `RFCT50EGFCN`, exact revision +`sos.core1dev.e05f91bb6f0b.134e0611333f` (1,050,603,811 bytes, SHA-256 +`597623eac15b177c835f2c9f612702a0356c96f2185ee0dc4413da00c527f376`) +emitted one queued → received → dispatch → accepted → start → terminal sequence +and failed after 1.178944128 seconds at `stage=child +category=launch_failure`. No provider or authority commit followed, reverse and +bridge cleanup passed, Core no-Zygote readiness stayed healthy, and the +memory-only credential remained `CONFIGURED`. The finalized continuation root +is +`/home/carlid/dev/sos/artifacts/device-gates/core-dev-20260818-18-rfct50egfcn-cont-01`; +its 613-byte independently verified manifest has SHA-256 +`cf5aba5186ef8c16448fe6591fc226878cef50fd4cc6d246d1e2e3a6d14dabf8`. +The 190,540-byte framebuffer at +`artifacts/device-gates/core-dev-smoke-20260818T192516Z-rfct50egfcn/framebuffer.png` +has SHA-256 +`7765fc280b63f866e88f16d3f3d92effe33a6ecc45eb1e8608e529ea85f87d5d`. +This is a clean device failure, not a provider or hardware PASS. + +**Diagnosis / rejected hypothesis:** `launch_failure` is selected only when +Rust `Command::spawn` fails, before Node can open its script argument. The r18 +runtime bytes contain both exact launch paths +`/system_ext/bin/sos-node-core-dev` and +`/system_ext/etc/sos-agent/agent-runner-core-dev.cjs`; inspected target-files +contain the 0755 development Node and the 0644 development runner at those +paths, with the exact `sos_node_core_dev_exec` file context and compiled +`sos_core_host` → `sos_core_dev_agent` transition. Therefore the r14 runner +rename did not leave the Rust launcher pointing at the ordinary filename and +cannot explain this pre-Node terminal. The actual kernel spawn cause is not +recoverable from finalized r18 evidence because the launcher collapsed every +`io::ErrorKind` into one safe error and logged no path identity. Guessing a +fallback path, scanning the image, widening SELinux, or reverting to the +ordinary executable/domain was rejected because each would weaken the +product-exclusive tunnel boundary without evidence. + +**Implementation / invariants:** Ordinary and development executable path, +runner path, safe identity and expected domain now live in one feature-selected +Rust `CoreChildLaunchContract`, used by both `Command` and its fixed argument +array. This also corrects the successful Core-dev marker from the ordinary +`sos_core_agent` label to `sos_core_dev_agent`. A failed spawn now emits one +content-free marker with only the contract identities and an allowlisted cause: +`path_missing`, `permission_denied`, `resource_exhausted`, `unsupported`, or +`other`; no raw errno string, path supplied at runtime, key, prompt, response or +provider body can enter it. The device smoke collector retains that marker. +The lifecycle terminal remains `child/launch_failure`, so credential retention +and exactly-once attempt semantics are unchanged. + +A cross-product contract test now ties each Soong module source and unique +installed filename to its product package selection, compiled Rust executable +and runner constants, SELinux executable context and domain transition, and the +ordinary or proxy-enabled Node entrypoint. It also proves the C++ native host +does not own a second child-launch path. Ordinary Core continues to select only +the ordinary runner and contains no development package, proxy symbol or +payload; Core-dev continues to select only its distinct proxy runner plus its +dedicated executable/domain. The OpenRouter-only authority, end-to-end TLS, +exact `deepseek/deepseek-v4-flash-0731` model, single-use peer-checked tunnel, +credential retention, Compat and Linux paths are unchanged. + +**Bounded host evidence / remaining risk / next gate:** +`core-agent-contract-test` passed 23 tests both without and with +`core-dev-credential`; `check-core-agent-hardening`, +`check-core-dev-credential`, the 20-test cross-language protocol suite, the +host CLI mock and Bash syntax passed. `npm test` rebuilt both bundles and passed +20 tests. Pinned NDK 29 API-31 ARM64 checks passed for `core-native` and +`core-native,core-dev-credential` in 1.02 and 0.86 seconds. Cargo formatting +passed. No full product build, device access, or provider request occurred. + +The source graph is internally consistent, but this telemetry milestone does +not claim the underlying device spawn now succeeds. The runner must build and +inspect fresh ordinary and Core-dev artifacts, preserving revision, byte size, +SHA-256 and an independently verified manifest. Ordinary inspection must prove +every development path, identity, proxy byte and policy symbol absent; Core-dev +must prove the exact dedicated executable, runner, domain transition, proxy and +new launch-failure marker present. After explicit authorization for that exact +Core-dev artifact and `RFCT50EGFCN`, run exactly one fixed smoke with the same +cleanup, readiness, credential-retention, screenshot, crash/AVC, soak and +manifest gates. On another launch failure, the new allowlisted cause and fixed +identities are the terminal evidence for a fresh implementation; do not add a +fallback, retry, or clear the credential automatically. + +## 2026-08-18 — Support the Core device's Linux 5.10 FD boundary and install r20 + +**Goal / terminal r19 evidence:** Resolve the remaining pre-provider child +launch failure without removing the fail-closed inherited-descriptor boundary. +The one authorized fixed smoke on `RFCT50EGFCN`, revision +`sos.core1dev.e05f91bb6f0b.b08da7ae929f`, reached request acceptance and then +terminated after 1.179517887 seconds at `stage=child +category=launch_failure cause=other expected_domain=sos_core_dev_agent`. +Bridge/reverse cleanup, post-failure readiness and credential retention passed; +no child, provider or authority commit existed and no retry ran. The finalized +evidence root is +`/home/carlid/dev/sos/artifacts/device-gates/core-dev-20260818-19-rfct50egfcn-cont-01`; +its independently verified manifest has SHA-256 +`a0cc7875b49caee683a1a6abfac2b384541938716084ed82c6df139ae9c3f4ca`. + +**Root cause / rejected approaches:** `Command::spawn` runs the Core FD boundary +in a `pre_exec` callback before `execve`. That boundary called +`close_range(3, UINT_MAX, CLOSE_RANGE_CLOEXEC)`. The live device reports Linux +`5.10.239-android12-9`, an open-file soft limit of 32,768, and no launch-time +SOS AVC. The pinned Samsung kernel source confirms the cause directly: +`kernel/samsung/s5e8825/include/uapi/linux/close_range.h` defines only +`CLOSE_RANGE_UNSHARE`, while `fs/file.c::__close_range` rejects every other flag +with `-EINVAL`. Rust maps this callback error to `ErrorKind::Other`, exactly +matching r19. The packaged development Node is a valid AArch64 Android-31 ELF +and matches the staged source byte-for-byte at SHA-256 +`e1e6cf7de807baea6fa1d2a81bd6da29d777ab08149645431ebbe283bda33607`; +the runner path, executable label and domain transition also match. This +disproves missing-path, invalid-ELF and SELinux-transition hypotheses. + +Removing the FD boundary or falling back to `close_range(..., 0)` was rejected. +The latter would close Rust's private exec-error pipe before `execve`, allowing +an exec failure to look like a successful spawn. A launcher/path fallback was +also rejected because it would duplicate the already consistent product and +SELinux contract. + +**Implementation / invariants:** `core_child_fds.rs` now keeps the atomic Linux +5.11+ CLOEXEC path. Only `EINVAL` or `ENOSYS` selects an Android-5.10-safe +fallback: `getrlimit(RLIMIT_NOFILE)` followed by async-signal-safe +`fcntl(F_GETFD/F_SETFD, FD_CLOEXEC)` across descriptors 3 through the soft +limit. Closed descriptors are skipped only on `EBADF`; every other failure +aborts spawn. This marks Rust's launch-error pipe close-on-exec without closing +it early, and every inherited GPUI, dma-buf, surface, input, service and device +descriptor is still removed by successful exec. There is no network, SELinux, +TLS, endpoint, model, credential, prompt or lifecycle relaxation. Ordinary +Core and Core-dev use the same hardened helper; development proxy code remains +absent from ordinary Core. The sole provider model remains +`deepseek/deepseek-v4-flash-0731`. + +**Bounded tests and r20 host acceptance:** Both ordinary and +`core-dev-credential` contract variants passed 25 tests. New coverage proves +the exact `EINVAL`/`ENOSYS` fallback decision, rejects `EPERM`, marks a real open +descriptor CLOEXEC, ignores adjacent closed descriptors, preserves the +pipe-backed standard streams and preserves exec-failure reporting. The Core +hardening gate passed 25 tests and the development credential/product gate +passed 20 protocol tests, including production exclusion. + +Full ordinary build/inspection passed in 230.37/18.73 seconds; the final +preservation rebuild/inspection passed in 236.27/18.63 seconds. The preserved +ordinary OTA is +`artifacts/host-gates/core-dev-20260818-20/artifact-set/sos.core1.e05f91bb6f0b.abf3316c338b-ordinary-ota.zip`, +revision `sos.core1.e05f91bb6f0b.abf3316c338b`, 1,022,193,395 bytes, SHA-256 +`6018e63e60a12e387e34fb8cf5ebf8db297899b69243d325099e6600bfd1e79d`. +Core-dev build/inspection passed in 239.96/20.64 seconds. Its preserved OTA is +`artifacts/host-gates/core-dev-20260818-20/artifact-set/sos.core1dev.e05f91bb6f0b.ed5b8200ec30-dev-ota.zip`, +revision `sos.core1dev.e05f91bb6f0b.ed5b8200ec30`, 1,050,592,959 bytes, +SHA-256 +`6dc93cb0d09e5163796fe77c16bb9ccd7086a40103620bdebd6460597483db85`. +The independently verified artifact manifest has SHA-256 +`b8a1c805c7f9c12acb73111ea988c59a3704cfe9280fb9d185265819cf941c00`; +the independently verified complete host manifest has SHA-256 +`00c4e981e5cbb983b1ff10ad624c77030019dbc99799ffc85bb0155d82292054`. + +**r20 install evidence / next gate:** The exact Core-dev OTA was installed on +`RFCT50EGFCN` with `sideload-auto-reboot`, `wait-for-sideload` and exactly one +`adb sideload`; the steps completed in 2.44, 19.59 and 83.44 seconds and the +transfer reported `Total xfer: 1.00x`. Its inherent reboot returned ADB in +71.63 seconds. Exact no-Zygote readiness passed in 1.03 seconds, followed by +eleven successful readiness samples over a measured 312.02-second soak. The +memory-only credential is correctly `EMPTY`. The 182,351-byte framebuffer has +SHA-256 +`b56ca69550dde22f99ab7403b70430936250557851adac8b91cac3eddb490f64`. +The finalized device-install evidence root is +`artifacts/device-gates/core-dev-20260818-20-rfct50egfcn`; its independently +verified manifest has SHA-256 +`ca598ff38b1733081c01ff9b88919926e41417dd45fcd9c233a150c83fd25796`. +Boot retains the previously known no-Zygote `odrefresh` abort and early +`sos_core_host` denial while probing Android data; neither affected the exact +native surface/process/adapter readiness predicates during the soak. + +The remaining gate is one hidden in-memory credential SET followed by one +fixed autonomous tunnel smoke. Require child start in `sos_core_dev_agent`, +CONNECT/TLS/provider completion, exact action validation and authority commit, +one sanitized lifecycle terminal, bridge/reverse/child cleanup, framebuffer, +credential retention, scoped crash/AVC review and a finalized independently +verified continuation manifest. Do not retry or clear on failure. + +## 2026-08-18 — Restore Android's fwmark FD handoff before the Core network gate + +**Goal / decisive r20 evidence:** Determine whether the r20 Core-dev terminal at +`stage=provider category=unknown` was an OpenRouter/provider failure or a broken +device network path before making another provider-level change. The first r20 +fixed smoke proved the Linux-5.10 child-launch repair: Node started as +`sos_core_dev_agent`, accepted the exact +`deepseek/deepseek-v4-flash-0731` request, received its stdin frame and returned +one protocol-v2 failure while the in-memory credential remained `CONFIGURED`. +It did not prove CONNECT or TLS. A content-free bridge event log was therefore +added for connection acceptance, fixed-authority validation, upstream connect, +first encrypted transfer in each direction and terminal byte counts. It never +records CONNECT headers, TLS bytes, credentials, prompts or provider bodies. +Three bridge tests prove malformed/non-allowlisted rejection, bidirectional +relay and absence of synthetic payload bytes from output; the host CLI mock +also passes. + +The single instrumented retry on exact r20 revision +`sos.core1dev.e05f91bb6f0b.ed5b8200ec30` failed after child request write with +the same safe `provider/unknown` terminal, but its bridge event file is exactly +zero bytes: the device never opened TCP 37173. The finalized smoke root is +`artifacts/device-gates/core-dev-smoke-20260818T213450Z-rfct50egfcn`; its +independently verified 10-file manifest has SHA-256 +`da6f3ec13168d8ce05bc08d8ca0b82b30596ecfbadcf70a21597e92c6c721fcb`. +The framebuffer is 180,944 bytes with SHA-256 +`ffa5a7dcee44a6c71b44be04d50fefc63e0dfcd39f97247cfbb3f27cefddb991`, +reverse/bridge cleanup passed, and the key remained `CONFIGURED`. Separately, +host lookup and TCP connect to `openrouter.ai` succeeded, `curl +https://openrouter.ai/api/v1/models` returned HTTP 200 with certificate +verification enabled, and `openssl s_client -verify_return_error` completed a +TLS 1.3 handshake with `Verification: OK`. The bundled Pi catalog also reports +the exact campaign model available. OpenRouter, host internet, model lookup and +post-CONNECT TLS are therefore rejected as causes of this zero-connection +failure. + +**Root cause / fix / boundary:** The live enforcing audit at the same child +attempt records `netd` denied `{ use }` on an fd owned by +`sos_core_dev_agent`. Bionic connects to Android's `fwmarkd`; that service +passes the caller's already-created TCP fd to netd for socket marking before +the kernel connect. The custom agent domains deliberately do not carry the +broad `netdomain` attribute, so they did not inherit platform policy's +reciprocal `allow netd netdomain:fd use`. The denial occurs before a SYN or ADB +reverse connection and exactly explains the empty bridge trace. The separate +`net_dns_prop` read denial is the structurally rejected obsolete fallback and +is not required by the fixed numeric loopback proxy. + +Both ordinary and development Core agent policies now grant only `allow netd +sos_core_agent:fd use` or `allow netd sos_core_dev_agent:fd use`. This lets +netd act on a socket the confined child already owns; it does not let the child +use netd fds, add `netdomain`, read DNS properties, create useful UDP traffic, +connect to another port, or expand the ordinary/dev destination allowlists. +Ordinary Core remains limited to labeled TCP 443, while Core-dev adds only the +fixed labeled loopback TCP 37173. The focused CIL fixture and cross-product +source contract require both reciprocal rules. The hardening gate passed 25 +tests, the Core-dev credential/product gate passed its 20 protocol tests, the +host CLI mock passed, and the bridge suite passed three tests. + +**r21 host acceptance:** Fresh paired ordinary and Core-dev builds and full +inspectors passed. Ordinary build/inspection took 238.58/18.61 seconds; its +preserved OTA is +`artifacts/host-gates/core-dev-20260818-21/artifact-set/sos.core1.e05f91bb6f0b.02bb8ff9a883-ordinary-ota.zip`, +revision `sos.core1.e05f91bb6f0b.02bb8ff9a883`, 1,022,188,681 bytes, SHA-256 +`d2c595bc62eafc44ce873d66611e329608b81785846d5b868573d408db5fe51c`. +Core-dev build/inspection took 246.49/19.68 seconds; its preserved OTA is +`artifacts/host-gates/core-dev-20260818-21/artifact-set/sos.core1dev.e05f91bb6f0b.b36b60685b37-dev-ota.zip`, +revision `sos.core1dev.e05f91bb6f0b.b36b60685b37`, 1,050,593,972 bytes, +SHA-256 +`01627cc3165bd949836065e41e1465a9964b86846298a4794e048d414fe12a40`. +Both inspectors passed packaging, AVB, recovery, runtime/model and product +separation gates; the development target CIL contains the exact new netd fd +rule. The independently verified artifact manifest has SHA-256 +`f58e0f62284342ebb2c0e0c081c07b8a8de7ad9af41a8267912e51985e956315`; +the independently verified 15-file host evidence manifest has SHA-256 +`52a7b253571a3e61cdae9eeab6d6f5c75af2746d0dab4ffe3ff587ccfabbfdb3`. + +**r21 install / next gate:** The exact development OTA was installed once on +`RFCT50EGFCN` using `sideload-auto-reboot`; recovery became ready in 29.43 +seconds after the 2.39-second reboot command, sideload completed in 82.47 +seconds with `Total xfer: 1.00x`, and the inherent reboot returned ADB in 78.55 +seconds. Exact r21 no-Zygote readiness passed in 1.04 seconds with the `SOS Core +Experience` surface, supervisor/experience, authority and platform adapter, +current native lifecycle, and no relevant crash or enforcing AVC. The +memory-only credential is correctly `EMPTY`. Install evidence is accumulating +under `artifacts/device-gates/core-dev-20260818-21-rfct50egfcn` and remains open +until its final framebuffer and manifest are captured. + +The next and only provider gate is to inject the hidden memory-only key once, +then run one instrumented fixed smoke. Require a bridge connection, exact +CONNECT acceptance, upstream connection and encrypted bytes in both +directions before interpreting any provider terminal. On success also require +the complete Pi action/validation/activation sequence and authority commit; +on failure preserve the safe terminal plus bridge phase. In both cases retain +the credential, clean bridge/reverse/child state, recheck exact readiness and +scoped AVCs, capture the framebuffer, then finalize and independently verify +all evidence. Do not clear or retry automatically. + +## 2026-08-19 — Complete the Core OpenRouter E2E with a shared JITless transport + +**Goal / r21-r22 network evidence:** Finish the fixed Core development smoke on +`RFCT50EGFCN` without weakening the no-Zygote child, TLS, credential, model or +destination boundaries. The first r21 image with reciprocal `netd` fd-use +permission progressed past the original fwmark handoff, but the live audit then +showed `netd` denied `{ read write }` on the TCP socket owned by +`sos_core_dev_agent`. r22 therefore adopted the complete narrow passed-socket +permission set used by Android's platform network domains: `netd` may use the +agent fd and read, write, get/set attributes, and get/set socket options on that +agent-owned TCP socket. It does not add `netdomain`, DNS-property access, UDP, +another destination, another port, or any agent access to netd-owned fds. The +same reciprocal rule is present for the ordinary Core agent domain. + +Fresh r22 Core-dev build and inspection passed in 243.51 and 19.16 seconds. +The preserved artifact is +`artifacts/host-gates/core-dev-20260818-22/artifact-set/sos.core1dev.e05f91bb6f0b.c8d621d5d06e-dev-ota.zip`, +revision `sos.core1dev.e05f91bb6f0b.c8d621d5d06e`, 1,050,607,629 bytes, +SHA-256 +`83a25e8a1a95d07756d05eac0370b09c27dedb680782af7e5d05d8ec52d7aa44`. +Its independently verified artifact and host manifests have SHA-256 +`15802840bd95217c3b8d87d8dc46cc0b73be1c2182e4afcd2a6a51b9a2df29fa` +and +`d819b5b7ed8c20153e99fbd480bc8f9bf2200cf3cbe447e56b1cf906106a71ed`. +The exact OTA installed once with autonomous sideload and reached exact native +readiness; its finalized 25-file install manifest at +`artifacts/device-gates/core-dev-20260818-22-rfct50egfcn/manifest.tsv` is 2,320 +bytes with SHA-256 +`6c45e6fb23f5bc21b807ceedef6124d287a2c9846d31ab5fbe7100620979d952`. + +The r22 instrumented smoke disproved another network-policy failure. The bridge +accepted the ADB-reverse connection but then recorded +`phase=read_connect category=connection`: the child closed the socket before +sending any CONNECT bytes. There was no new relevant socket AVC, the sanitized +runner terminal remained `provider/unknown`, reverse cleanup passed and the +memory-only key remained `CONFIGURED`. The finalized evidence root is +`artifacts/device-gates/core-dev-smoke-20260818T221428Z-rfct50egfcn`; its +manifest has SHA-256 +`2373af93bb1936196766a7a42185e76e401c2fa7f8420b8ba23e4adeee931bd5` +and its 181,308-byte framebuffer has SHA-256 +`0f4798e9722f23321042f7ec6d8543fac584ab17564d346980fbf4991f5050ea`. + +**Root cause / rejected approaches:** A real host reproduction using the exact +bundled transport showed that `undici.ProxyAgent` under `node --jitless` opens +and closes the proxy connection without writing CONNECT. Direct Undici fetch +under `--jitless` progresses through TLS and then fails because Undici 7's +`lazyllhttp` parser requires WebAssembly, while JITless disables WebAssembly. +The same test without `--jitless` sends CONNECT and TLS. This exactly explains +the r22 bridge trace and rejects OpenRouter availability, the key, DNS, the +fixed port, ADB reverse and SELinux as causes. Disabling JITless, enabling +WebAssembly/JIT, allowing plaintext provider traffic, broadening the child +network domain, or weakening the fixed authority validator were rejected. A +separate real-client test found that a standards-compliant CONNECT request may +send `Host: openrouter.ai` when port 443 is implicit; the bridge now accepts +only that form or `Host: openrouter.ai:443`, while still requiring the exact +CONNECT target `openrouter.ai:443`. + +**Shared implementation:** The provider transport used by Linux, Compat and +Core now defaults to `node-fetch` 3.3.2, which uses Node's native HTTP parser +and works under JITless. A small shared adapter preserves Request method, +headers, signal and body and converts the Node response stream to a Web +`Response`/`ReadableStream`, matching Pi/OpenAI SDK streaming expectations. +Core-dev alone supplies `HttpsProxyAgent` 7.0.6 and fail-closed redirect policy +for the exact `http://127.0.0.1:37173` development proxy; its adapter still +rejects every URL except HTTPS `openrouter.ai` on implicit or explicit port +443. Ordinary Core contains neither the fixed proxy value nor proxy agent. The +runner remains `--jitless`, only +`deepseek/deepseek-v4-flash-0731` is accepted, TLS remains end to end from the +confined child to OpenRouter, and no request, response, prompt or credential +content is exposed to the host bridge or evidence. + +Host acceptance rebuilt both runner bundles and passed 21 Node tests, including +Request/body preservation, Web-stream conversion, Core-dev-only proxy bytes, +and absence of a WebAssembly marker. A real `node --jitless` integration test +proved native HTTP sends the exact CONNECT request and starts TLS through the +content-free bridge. The four-test bridge suite, 25-test Core hardening gate, +20-test credential/protocol gate, Bash syntax and CLI mock all passed. npm's +locked 102-package graph reported zero vulnerabilities. + +**r23 artifact / install:** Fresh Core-dev build and full inspection passed in +242.04 and 19.44 seconds. The frozen OTA is +`artifacts/host-gates/core-dev-20260819-23/artifact-set/sos.core1dev.e05f91bb6f0b.0be956df8e63-dev-ota.zip`, +revision `sos.core1dev.e05f91bb6f0b.0be956df8e63`, 1,050,419,185 bytes, +SHA-256 +`52eef9501d455587839f15f647c22408c19a8767e6f66716952b4d1f44379c56`. +The independently verified artifact manifest is 127 bytes with SHA-256 +`33a744e9ce4a9196c6f0d31b6b8826fa6401de0f82660fb3f08394a3144500e5`; +the complete eight-file host manifest has SHA-256 +`5ee93a6ad948f25c3b5f5e83a9d9b34275823c76d8d38e30340752d66f5aca01`. + +The exact r23 OTA was installed once on `RFCT50EGFCN` with +`sideload-auto-reboot`. The reboot command, recovery readiness, sole sideload, +inherent-reboot ADB return and exact readiness took 2.27, 29.43, 80.33, 87.80 +and 3.33 seconds; sideload reported `Total xfer: 1.00x`. Readiness proved the +exact revision, `SOS Core Experience`, supervisor/experience child, authority, +platform adapter and current native lifecycle with no relevant crash or +enforcing AVC. The expected post-reboot credential state was `EMPTY`. The +181,916-byte framebuffer has SHA-256 +`6928c416a53dbebad274c4f268d636f943eba829433988b2c5e83bf06f0403cc`. +The independently verified 26-file install manifest at +`artifacts/device-gates/core-dev-20260819-23-rfct50egfcn/manifest.tsv` is 2,417 +bytes with SHA-256 +`ef50d29aed3c96e79e0b0dd3ad47a165088c2e36b8c8ac52e6345b8d23c765fc`. + +**Live E2E PASS:** After one hidden in-memory key set, exactly one fixed smoke +completed. The confined `sos_core_dev_agent` child used the pinned model and +exited 0 with protocol-v2 `prompt_complete`. Pi produced and the native host +verified exactly `get_experience_context`, `validate_experience`, and +`submit_experience`. Four fixed-authority bridge connections each recorded +CONNECT acceptance, upstream connection, encrypted relay in both directions +and content-free terminal counts: device/upstream byte pairs were +52,442/8,264, 69,432/39,776, 87,185/39,100 and 104,851/13,825. Candidate +validation, authority staging and the final +`android_agent_activation_commit ... phase=committed authority=system` all +exist; the resulting `Blue smoke check` card is visible on the device. The key +remains `CONFIGURED`, no reverse mapping remains, and exact readiness again +passes without a relevant crash or enforcing AVC. + +The primary finalized smoke root is +`artifacts/device-gates/core-dev-smoke-20260818T223442Z-rfct50egfcn`; its +10-file, 858-byte manifest has SHA-256 +`7ed5677e72d99141f147eab039b02db0ecdc9536028cc91dc228384c29feb426` +and its 183,391-byte framebuffer has SHA-256 +`7f4d5694ac63147a758f4157a6733f1d315f42afbf23bb922ba1ffe57a7df78b`. +The collector initially stopped at the successful UI terminal just before the +asynchronous authority commit log. The read-only continuation at +`artifacts/device-gates/core-dev-smoke-20260818T223442Z-rfct50egfcn-cont-01` +captures that exact commit, retained key, empty reverse list, post-commit +readiness and framebuffer. Its independently verified six-file, 528-byte +manifest has SHA-256 +`892190da0257bb9e5d551702f14834bd7ac21a9a76e3605642ad299cb37a2b63`. +The smoke collector now reports `COMPLETED` only after both the exact successful +UI terminal and system authority commit; its host mock asserts the commit and +all focused gates pass after the correction. + +**Decision / remaining gate:** The Core development OpenRouter E2E gate passes, +and the shared JITless transport is selected for all three runtime hosts. The +credential remains deliberately memory-only and retained across failures or +successful attempts, but still clears on reboot. The latest adapter has direct +host coverage and Core hardware evidence; the prior Compat hardware E2E passed +the same pinned Pi/action/authority contract before this transport replacement. +Before declaring the transport replacement hardware-accepted across every +product, rebuild/inspect Compat and repeat its fixed device E2E on this exact +shared adapter. Also rebuild/inspect ordinary Core to close its packaging +exclusion gate; neither step should change the proven Core-dev image. The +keyboard product milestone can then replace LatinIME as Compat's default with +the protected SOS IME while retaining explicit third-party Android IME opt-in. diff --git a/services/sos-agent/package-lock.json b/services/sos-agent/package-lock.json index f92e31b..e0e4f2e 100644 --- a/services/sos-agent/package-lock.json +++ b/services/sos-agent/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "dependencies": { "@earendil-works/pi-agent-core": "0.84.1", - "@earendil-works/pi-ai": "0.84.1" + "@earendil-works/pi-ai": "0.84.1", + "https-proxy-agent": "7.0.6", + "node-fetch": "3.3.2" }, "devDependencies": { "@types/node": "24.12.4", diff --git a/services/sos-agent/package.json b/services/sos-agent/package.json index 983f63a..f07789c 100644 --- a/services/sos-agent/package.json +++ b/services/sos-agent/package.json @@ -8,13 +8,15 @@ }, "scripts": { "clean": "node scripts/clean-dist.mjs", - "build": "npm run clean && tsc -p tsconfig.json && esbuild dist/src/runner.js --bundle --platform=node --format=cjs --target=node22 --outfile=dist/agent-runner.cjs", + "build": "npm run clean && tsc -p tsconfig.json && esbuild dist/src/runner.js --bundle --platform=node --format=cjs --target=node22 --outfile=dist/agent-runner.cjs && esbuild dist/src/runner-core-dev.js --bundle --platform=node --format=cjs --target=node22 --outfile=dist/agent-runner-core-dev.cjs", "check": "tsc -p tsconfig.json --noEmit", "test": "npm run build && node --test dist/test/*.test.js" }, "dependencies": { "@earendil-works/pi-agent-core": "0.84.1", - "@earendil-works/pi-ai": "0.84.1" + "@earendil-works/pi-ai": "0.84.1", + "https-proxy-agent": "7.0.6", + "node-fetch": "3.3.2" }, "devDependencies": { "@types/node": "24.12.4", diff --git a/services/sos-agent/src/core-dev-proxy.ts b/services/sos-agent/src/core-dev-proxy.ts new file mode 100644 index 0000000..511491f --- /dev/null +++ b/services/sos-agent/src/core-dev-proxy.ts @@ -0,0 +1,40 @@ +import { HttpsProxyAgent } from "https-proxy-agent"; +import type { CoreDevProxyHooks } from "./stdio-runner.js"; +import { + nodeProviderFetchWithOptions, + type NodeFetchBackend, +} from "./provider-fetch.js"; + +export const CORE_DEV_OPENROUTER_PROXY = "http://127.0.0.1:37173"; + +export function fixedCoreDevProxyFetch( + input: Parameters[0], + init: Parameters[1], + fetchImplementation?: NodeFetchBackend, +): Promise { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if ( + url.protocol !== "https:" || + url.hostname !== "openrouter.ai" || + (url.port !== "" && url.port !== "443") + ) { + throw new Error("invalid Pi runner request"); + } + const agent = new HttpsProxyAgent(CORE_DEV_OPENROUTER_PROXY); + return nodeProviderFetchWithOptions( + input, + init, + { agent, redirect: "error" }, + fetchImplementation, + ); +} + +export const CORE_DEV_PROXY_HOOKS: CoreDevProxyHooks = { + accepts(value): value is string { + return value === CORE_DEV_OPENROUTER_PROXY; + }, + fetch(proxy) { + if (proxy !== CORE_DEV_OPENROUTER_PROXY) throw new Error("invalid Pi runner request"); + return (input, init) => fixedCoreDevProxyFetch(input, init); + }, +}; diff --git a/services/sos-agent/src/provider-fetch.ts b/services/sos-agent/src/provider-fetch.ts new file mode 100644 index 0000000..ec98b20 --- /dev/null +++ b/services/sos-agent/src/provider-fetch.ts @@ -0,0 +1,64 @@ +import { Readable } from "node:stream"; +import nodeFetch, { + type RequestInit as NodeFetchRequestInit, + type Response as NodeFetchResponse, +} from "node-fetch"; + +export type NodeFetchBackend = ( + url: string, + init?: NodeFetchRequestInit, +) => Promise; + +async function normalizeRequest( + input: Parameters[0], + init: Parameters[1], +): Promise<{ url: string; init: NodeFetchRequestInit }> { + const request = input instanceof Request ? input : undefined; + const url = request ? request.url : input.toString(); + const inheritedBody = + request && request.method !== "GET" && request.method !== "HEAD" + ? Buffer.from(await request.clone().arrayBuffer()) + : undefined; + const normalized = { + ...(request + ? { + method: request.method, + headers: request.headers as unknown as NodeFetchRequestInit["headers"], + signal: request.signal, + ...(inheritedBody ? { body: inheritedBody } : {}), + } + : {}), + ...(init as unknown as NodeFetchRequestInit), + } as unknown as NodeFetchRequestInit; + return { url, init: normalized }; +} + +function toWebResponse(response: NodeFetchResponse): Response { + const body = response.body + ? (Readable.toWeb(response.body as Readable) as ReadableStream) + : null; + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + }); +} + +export async function nodeProviderFetchWithOptions( + input: Parameters[0], + init: Parameters[1], + options: NodeFetchRequestInit, + fetchImplementation: NodeFetchBackend = nodeFetch, +): Promise { + const request = await normalizeRequest(input, init); + return toWebResponse( + await fetchImplementation(request.url, { ...request.init, ...options }), + ); +} + +export function nodeProviderFetch( + input: Parameters[0], + init?: Parameters[1], +): Promise { + return nodeProviderFetchWithOptions(input, init, {}); +} diff --git a/services/sos-agent/src/runner-core-dev.ts b/services/sos-agent/src/runner-core-dev.ts new file mode 100644 index 0000000..b0d8a8f --- /dev/null +++ b/services/sos-agent/src/runner-core-dev.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import process from "node:process"; +import { CORE_DEV_PROXY_HOOKS } from "./core-dev-proxy.js"; +import { reportStdioFailure, runStdio } from "./stdio-runner.js"; + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index < 0 ? undefined : process.argv[index + 1]; +} + +function required(name: string): string { + const value = option(name); + if (!value) throw new Error(`missing required option ${name}`); + return value; +} + +if (process.argv[2] !== "stdio") { + throw new Error("Core-dev runner accepts only the fixed stdio mode"); +} +runStdio( + { + apiPath: required("--api-doc"), + examples: [required("--example"), required("--example-secondary")], + }, + CORE_DEV_PROXY_HOOKS, +).catch(reportStdioFailure); diff --git a/services/sos-agent/src/runtime.ts b/services/sos-agent/src/runtime.ts index ad36008..66a92b4 100644 --- a/services/sos-agent/src/runtime.ts +++ b/services/sos-agent/src/runtime.ts @@ -17,6 +17,7 @@ import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter"; import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core"; import { createAuthoringTools, type AuthoringBackend } from "./authoring.js"; +import { nodeProviderFetch } from "./provider-fetch.js"; export type SupportedProvider = "openai" | "anthropic" | "openai-codex" | "openrouter"; @@ -28,6 +29,7 @@ export interface AgentRuntimeOptions { apiKey?: string; credentials?: CredentialStore; messages?: AgentMessage[]; + fetch?: typeof globalThis.fetch; } export function createAgentRuntime(options: AgentRuntimeOptions): Agent { @@ -51,6 +53,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): Agent { models.streamSimple(requestModel, context, { ...streamOptions, ...(options.apiKey ? { apiKey: options.apiKey } : {}), + fetch: options.fetch ?? nodeProviderFetch, }), toolExecution: "sequential", }); diff --git a/services/sos-agent/src/stdio-runner.ts b/services/sos-agent/src/stdio-runner.ts index 907a203..31581c9 100644 --- a/services/sos-agent/src/stdio-runner.ts +++ b/services/sos-agent/src/stdio-runner.ts @@ -1,4 +1,5 @@ import process from "node:process"; +import { lookup } from "node:dns/promises"; import { InMemoryCredentialStore, contentText, @@ -22,13 +23,20 @@ import { MAX_SOURCE_BYTES, MAX_STDIO_REQUEST_BYTES, } from "./contract.js"; +import { nodeProviderFetch } from "./provider-fetch.js"; const MAX_SUMMARY_BYTES = 2048; +const STDIO_PROTOCOL_VERSION = 2; export const PINNED_OPENROUTER_MODEL = "deepseek/deepseek-v4-flash-0731"; +export interface CoreDevProxyHooks { + accepts(value: unknown): value is string; + fetch(proxy: string): typeof globalThis.fetch; +} + export interface SanitizedRunnerFailure { type: "error"; - stage: "request" | "credential" | "provider" | "protocol" | "validation"; + stage: "request" | "credential" | "transport" | "provider" | "protocol" | "validation"; category: | "invalid_request" | "credential_rejected" @@ -36,9 +44,18 @@ export interface SanitizedRunnerFailure { | "rate_limited" | "provider_unavailable" | "provider_error" + | "dns_resolution" + | "dns_timeout" + | "dns_proxy_unavailable" + | "connect_timeout" + | "connect_refused" + | "connect_reset" + | "network_unreachable" + | "tls_failure" | "tool_sequence" | "invalid_candidate" - | "protocol_error"; + | "protocol_error" + | "unknown"; error: string; model?: string | undefined; status?: number; @@ -68,9 +85,26 @@ function numericHttpStatus(error: unknown): number | undefined { return undefined; } +function safeErrorCodes(error: unknown): string[] { + const codes: string[] = []; + let current: unknown = error; + for (let depth = 0; depth < 4 && current !== undefined; depth += 1) { + if (!current || typeof current !== "object") break; + const candidate = current as Record; + for (const value of [candidate.code, candidate.errno]) { + if (typeof value === "string" && /^[A-Z0-9_]{2,48}$/.test(value)) { + codes.push(value.toUpperCase()); + } + } + current = candidate.cause; + } + return codes; +} + export function sanitizeRunnerFailure( error: unknown, model?: string, + context: "provider" | "dns" = "provider", ): SanitizedRunnerFailure { if (error instanceof RunnerFailure) return error.failure; const knownMessage = error instanceof Error ? error.message : ""; @@ -128,19 +162,156 @@ export function sanitizeRunnerFailure( status, }; } + const codes = safeErrorCodes(error); + if (context === "dns" && codes.some((code) => code === "ETIMEDOUT")) { + return { + type: "error", + stage: "transport", + category: "dns_timeout", + error: "Provider DNS resolution timed out.", + model, + }; + } + if ( + context === "dns" && + codes.some( + (code) => + code === "EACCES" || + code === "EPERM" || + code === "ECONNREFUSED" || + code === "ENOENT", + ) + ) { + return { + type: "error", + stage: "transport", + category: "dns_proxy_unavailable", + error: "The Android DNS proxy was unavailable.", + model, + }; + } + if ( + codes.some( + (code) => + code === "ENOTFOUND" || + code.startsWith("EAI_"), + ) + ) { + return { + type: "error", + stage: "transport", + category: "dns_resolution", + error: "The provider hostname could not be resolved.", + model, + }; + } + if ( + codes.some( + (code) => + code.startsWith("ERR_TLS_") || + code.startsWith("CERT_") || + code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || + code === "DEPTH_ZERO_SELF_SIGNED_CERT" || + code === "SELF_SIGNED_CERT_IN_CHAIN" || + code === "ERR_SSL_WRONG_VERSION_NUMBER", + ) + ) { + return { + type: "error", + stage: "transport", + category: "tls_failure", + error: "The provider TLS handshake or certificate validation failed.", + model, + }; + } + if (codes.some((code) => code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT")) { + return { + type: "error", + stage: "transport", + category: "connect_timeout", + error: "The provider connection timed out.", + model, + }; + } + if (codes.some((code) => code === "ECONNREFUSED")) { + return { + type: "error", + stage: "transport", + category: "connect_refused", + error: "The provider connection was refused.", + model, + }; + } + if (codes.some((code) => code === "ECONNRESET")) { + return { + type: "error", + stage: "transport", + category: "connect_reset", + error: "The provider connection was reset.", + model, + }; + } + if (codes.some((code) => code === "ENETUNREACH" || code === "EHOSTUNREACH")) { + return { + type: "error", + stage: "transport", + category: "network_unreachable", + error: "The provider network was unreachable.", + model, + }; + } return { type: "error", stage: "provider", - category: "provider_error", - error: "The provider request failed.", + category: "unknown", + error: "The provider failure category was unknown.", model, }; } +interface SafeTransportObservation { + failure?: SanitizedRunnerFailure; +} + +export function observeProviderFetch( + model: string, + observation: SafeTransportObservation, + fetchImplementation: typeof globalThis.fetch = nodeProviderFetch, +): typeof globalThis.fetch { + return async (input, init) => { + try { + const response = await fetchImplementation(input, init); + if (!response.ok) { + observation.failure = sanitizeRunnerFailure({ status: response.status }, model); + } + return response; + } catch (error) { + observation.failure = sanitizeRunnerFailure(error, model); + throw error; + } + }; +} + function fail(failure: Omit): never { throw new RunnerFailure({ type: "error", ...failure }); } +export async function preflightProviderDns( + provider: SupportedProvider, + model: string, + resolver: (hostname: string) => Promise = lookup, +): Promise { + if (provider !== "openrouter") return undefined; + try { + await resolver("openrouter.ai"); + return undefined; + } catch (error) { + // The resolver call is the only source of this failure. Preserve only its + // structured code; never classify from exception/provider text. + return sanitizeRunnerFailure(error, model, "dns"); + } +} + export function promptResponseModel( request: { provider: "faux" } | { provider: SupportedProvider; model: string }, ): string { @@ -167,6 +338,7 @@ interface LivePromptRequest { credential: Credential; prompt: string; currentSource: string; + coreDevProxy?: string; } interface FauxPromptRequest { @@ -205,7 +377,7 @@ function isSupportedProvider(value: unknown): value is SupportedProvider { ); } -export function decodeRequest(raw: string): RunnerRequest { +export function decodeRequest(raw: string, coreDevProxy?: CoreDevProxyHooks): RunnerRequest { if (Buffer.byteLength(raw) > MAX_STDIO_REQUEST_BYTES) throw new Error("request is too large"); const decoded = JSON.parse(raw) as Record; if (decoded.action === "catalog") return { action: "catalog" }; @@ -240,6 +412,14 @@ export function decodeRequest(raw: string): RunnerRequest { ) { throw new Error("invalid Pi runner request"); } + if ( + decoded.coreDevProxy !== undefined && + (!coreDevProxy || + decoded.provider !== "openrouter" || + !coreDevProxy.accepts(decoded.coreDevProxy)) + ) { + throw new Error("invalid Pi runner request"); + } return { action: "prompt", provider: decoded.provider, @@ -247,17 +427,20 @@ export function decodeRequest(raw: string): RunnerRequest { credential: decoded.credential, prompt: decoded.prompt, currentSource: decoded.currentSource, + ...(coreDevProxy?.accepts(decoded.coreDevProxy) + ? { coreDevProxy: decoded.coreDevProxy } + : {}), }; } -async function readRequest(): Promise { +async function readRequest(coreDevProxy?: CoreDevProxyHooks): Promise { process.stdin.setEncoding("utf8"); let raw = ""; for await (const chunk of process.stdin) { raw += chunk; if (Buffer.byteLength(raw) > MAX_STDIO_REQUEST_BYTES) throw new Error("request is too large"); } - return decodeRequest(raw); + return decodeRequest(raw, coreDevProxy); } async function catalog(): Promise { @@ -345,9 +528,14 @@ function lastAssistantSummary(messages: AgentMessage[]): string { return "Pi proposed a complete replacement experience for trusted validation."; } -async function prompt(request: PromptRequest, systemPrompt: string): Promise { +async function prompt( + request: PromptRequest, + systemPrompt: string, + coreDevProxy?: CoreDevProxyHooks, +): Promise { const credentials = new InMemoryCredentialStore(); const live = request.provider !== "faux"; + const transport: SafeTransportObservation = {}; if (live) { try { await credentials.modify(request.provider, async () => request.credential); @@ -434,6 +622,14 @@ async function prompt(request: PromptRequest, systemPrompt: string): Promise { +export async function runStdio( + documents: PromptDocuments, + coreDevProxy?: CoreDevProxyHooks, +): Promise { // The registration name is historical; it statically includes Pi's OAuth // implementations so a single-file Node bundle can perform Codex login. registerBunOAuthFlows(); - const request = await readRequest(); + const request = await readRequest(coreDevProxy); switch (request.action) { case "catalog": await catalog(); @@ -500,12 +716,23 @@ export async function runStdio(documents: PromptDocuments): Promise { await login(request); break; case "prompt": - await prompt(request, await readSystemPrompt(documents)); + await prompt(request, await readSystemPrompt(documents), coreDevProxy); break; } } export function reportStdioFailure(error: unknown): void { - send(sanitizeRunnerFailure(error)); + send(stdioFailureEnvelope(error)); process.exitCode = 1; } + +export function stdioFailureEnvelope(error: unknown): SanitizedRunnerFailure & { + protocol_version: number; + terminal: "failed"; +} { + return { + ...sanitizeRunnerFailure(error), + protocol_version: STDIO_PROTOCOL_VERSION, + terminal: "failed", + }; +} diff --git a/services/sos-agent/test/runner.test.ts b/services/sos-agent/test/runner.test.ts index a5c9ad4..42ec3f2 100644 --- a/services/sos-agent/test/runner.test.ts +++ b/services/sos-agent/test/runner.test.ts @@ -4,12 +4,24 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { + Response as NodeFetchResponse, + type RequestInit as NodeFetchRequestInit, +} from "node-fetch"; import { decodeRequest, + observeProviderFetch, PINNED_OPENROUTER_MODEL, + preflightProviderDns, promptResponseModel, sanitizeRunnerFailure, + stdioFailureEnvelope, } from "../src/stdio-runner.js"; +import { + CORE_DEV_OPENROUTER_PROXY, + fixedCoreDevProxyFetch, +} from "../src/core-dev-proxy.js"; +import { nodeProviderFetchWithOptions } from "../src/provider-fetch.js"; import { buildSystemPrompt } from "../src/prompt-policy.js"; test("the package build removes obsolete Android-only runner outputs", async () => { @@ -20,6 +32,23 @@ test("the package build removes obsolete Android-only runner outputs", async () await assert.rejects(fs.access(path.resolve("dist/android-runner.cjs"))); }); +test("only the Core-dev bundle contains the fixed CONNECT proxy", async () => { + const [ordinary, coreDev] = await Promise.all([ + fs.readFile(path.resolve("dist/agent-runner.cjs"), "utf8"), + fs.readFile(path.resolve("dist/agent-runner-core-dev.cjs"), "utf8"), + ]); + for (const marker of ["http://127.0.0.1:37173", "HttpsProxyAgent"]) { + assert.equal(ordinary.includes(marker), false); + assert.equal(coreDev.includes(marker), true); + } + for (const bundle of [ordinary, coreDev]) { + assert.equal(bundle.includes("nodeProviderFetch"), true); + assert.equal(bundle.includes("WebAssembly"), false); + } + assert.equal(ordinary.includes(PINNED_OPENROUTER_MODEL), true); + assert.equal(coreDev.includes(PINNED_OPENROUTER_MODEL), true); +}); + test("the packaged runner applies the bounded faux Pi contract", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "sos-agent-runner-")); const api = path.join(directory, "experience-api.md"); @@ -54,6 +83,8 @@ test("the packaged runner applies the bounded faux Pi contract", async () => { assert.equal(response.type, "prompt_complete"); assert.equal(response.provider, "faux"); assert.equal(response.model, "faux"); + assert.equal(response.protocol_version, 2); + assert.equal(response.terminal, "completed"); assert.equal(response.source, candidate); assert.deepEqual(response.actions, [ "get_experience_context", @@ -113,6 +144,97 @@ test("the bounded OpenRouter request accepts only the campaign model", () => { } }); +test("the Core-dev proxy contract accepts one fixed loopback value only", async () => { + const request = { + action: "prompt", + provider: "openrouter", + model: PINNED_OPENROUTER_MODEL, + credential: { type: "api_key", key: "synthetic-never-log" }, + prompt: "fixed", + currentSource: "return { api_version = 3 }", + coreDevProxy: CORE_DEV_OPENROUTER_PROXY, + }; + assert.equal(CORE_DEV_OPENROUTER_PROXY, "http://127.0.0.1:37173"); + assert.throws(() => decodeRequest(JSON.stringify(request)), /invalid Pi runner request/); + + const arguments_ = [ + "stdio", + "--api-doc", + "/nonexistent/core-dev-proxy-test-api", + "--example", + "/nonexistent/core-dev-proxy-test-primary", + "--example-secondary", + "/nonexistent/core-dev-proxy-test-secondary", + ]; + const devTerminal = await exchangeTerminal( + [path.resolve("dist/agent-runner-core-dev.cjs"), ...arguments_], + request, + ); + assert.equal(devTerminal.category, "unknown"); + const ordinaryTerminal = await exchangeTerminal( + [path.resolve("dist/agent-runner.cjs"), ...arguments_], + request, + ); + assert.equal(ordinaryTerminal.category, "invalid_request"); + for (const coreDevProxy of [ + "http://127.0.0.1:37174", + "http://0.0.0.0:37173", + "http://example.test:37173", + "https://127.0.0.1:37173", + ]) { + const terminal = await exchangeTerminal( + [path.resolve("dist/agent-runner-core-dev.cjs"), ...arguments_], + { ...request, coreDevProxy }, + ); + assert.equal(terminal.category, "invalid_request"); + } + + let agentPresent = false; + const proxied: typeof globalThis.fetch = (input, init) => + fixedCoreDevProxyFetch(input, init, async (_input, init) => { + agentPresent = Boolean((init as NodeFetchRequestInit | undefined)?.agent); + return new NodeFetchResponse(null, { status: 204 }); + }); + await proxied("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + body: "synthetic-body-never-log", + }); + assert.equal(agentPresent, true); + assert.throws( + () => proxied("https://api.openai.com/v1/chat/completions"), + /invalid Pi runner request/, + ); +}); + +test("the shared JITless transport preserves requests and exposes a Web response stream", async () => { + let receivedUrl = ""; + let receivedInit: NodeFetchRequestInit | undefined; + const response = await nodeProviderFetchWithOptions( + new Request("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer synthetic-never-log" }, + body: "synthetic-body-never-log", + }), + undefined, + { redirect: "error" }, + async (url, init) => { + receivedUrl = url; + receivedInit = init; + return new NodeFetchResponse("data: synthetic-stream\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }, + ); + assert.equal(receivedUrl, "https://openrouter.ai/api/v1/chat/completions"); + assert.equal(receivedInit?.method, "POST"); + assert.equal(receivedInit?.redirect, "error"); + assert.equal(Buffer.from(receivedInit?.body as Buffer).toString(), "synthetic-body-never-log"); + assert.equal(response.headers.get("content-type"), "text/event-stream"); + assert.equal(typeof response.body?.getReader, "function"); + assert.equal(await response.text(), "data: synthetic-stream\n\n"); +}); + test("runner failures expose only bounded categories and safe numeric status", () => { const secret = "sk-or-v1-do-not-surface"; const failure = sanitizeRunnerFailure({ @@ -131,6 +253,132 @@ test("runner failures expose only bounded categories and safe numeric status", ( assert.ok(!JSON.stringify(failure).includes(secret)); }); +test("structured resolver, connect, TLS, and unknown failures have distinct safe categories", () => { + const secret = "sk-or-v1-never-emit-this"; + const dns = sanitizeRunnerFailure( + Object.assign(new Error(`provider-controlled ${secret}`), { code: "EAI_AGAIN" }), + PINNED_OPENROUTER_MODEL, + "dns", + ); + const connection = sanitizeRunnerFailure( + new TypeError("fetch failed", { cause: { code: "ECONNREFUSED", secret } }), + PINNED_OPENROUTER_MODEL, + ); + const tls = sanitizeRunnerFailure( + Object.assign(new Error(`malicious ENOTFOUND ${secret}`), { + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }), + PINNED_OPENROUTER_MODEL, + ); + const unknown = sanitizeRunnerFailure( + new Error(`provider body says getaddrinfo EAI_AGAIN ECONNREFUSED ${secret}`), + PINNED_OPENROUTER_MODEL, + ); + assert.deepEqual( + [dns.category, connection.category, tls.category, unknown.category], + ["dns_resolution", "connect_refused", "tls_failure", "unknown"], + ); + assert.ok(!JSON.stringify(dns).includes(secret)); + assert.ok(!JSON.stringify(connection).includes(secret)); + assert.ok(!JSON.stringify(tls).includes(secret)); + assert.ok(!JSON.stringify(unknown).includes(secret)); +}); + +test("network failure codes and HTTP status are exhaustively classified without content", () => { + const cases = [ + ["dns", "ETIMEDOUT", "dns_timeout"], + ["dns", "EPERM", "dns_proxy_unavailable"], + ["provider", "ENOTFOUND", "dns_resolution"], + ["provider", "UND_ERR_CONNECT_TIMEOUT", "connect_timeout"], + ["provider", "ECONNREFUSED", "connect_refused"], + ["provider", "ECONNRESET", "connect_reset"], + ["provider", "ENETUNREACH", "network_unreachable"], + ["provider", "ERR_TLS_CERT_ALTNAME_INVALID", "tls_failure"], + ] as const; + for (const [context, code, category] of cases) { + assert.equal( + sanitizeRunnerFailure({ code, message: "attacker text" }, PINNED_OPENROUTER_MODEL, context) + .category, + category, + ); + } + assert.deepEqual( + [400, 401, 403, 429, 503].map((status) => { + const failure = sanitizeRunnerFailure({ status, body: "untrusted" }); + return [failure.category, failure.status]; + }), + [ + ["provider_rejected", 400], + ["credential_rejected", 401], + ["credential_rejected", 403], + ["rate_limited", 429], + ["provider_unavailable", 503], + ], + ); +}); + +test("the fetch observer retains only structured transport or numeric HTTP evidence", async () => { + const observation: { failure?: ReturnType } = {}; + const observed = observeProviderFetch( + PINNED_OPENROUTER_MODEL, + observation, + async () => new Response("malicious provider body ENOTFOUND", { status: 429 }), + ); + await observed("https://openrouter.ai/api/v1/chat/completions"); + assert.equal(observation.failure?.category, "rate_limited"); + assert.equal(observation.failure?.status, 429); + assert.ok(!JSON.stringify(observation).includes("malicious")); +}); + +test("an r11-style invisible DNS failure now has exactly one bounded terminal envelope", () => { + const terminal = stdioFailureEnvelope( + Object.assign(new Error("visible UI text is not protocol evidence"), { code: "EAI_AGAIN" }), + ); + assert.equal(terminal.protocol_version, 2); + assert.equal(terminal.terminal, "failed"); + assert.equal(terminal.category, "dns_resolution"); + assert.deepEqual(Object.keys(terminal).sort(), [ + "category", + "error", + "model", + "protocol_version", + "stage", + "terminal", + "type", + ]); +}); + +test("OpenRouter DNS startup fails before provider or tool-sequence processing", async () => { + const failure = await preflightProviderDns( + "openrouter", + PINNED_OPENROUTER_MODEL, + async () => { + throw Object.assign(new Error("untrusted detail"), { code: "EPERM" }); + }, + ); + assert.deepEqual(failure, { + type: "error", + stage: "transport", + category: "dns_proxy_unavailable", + error: "The Android DNS proxy was unavailable.", + model: PINNED_OPENROUTER_MODEL, + }); +}); + +test("OpenRouter DNS startup uses exactly the fixed hostname and continues on success", async () => { + const hostnames: string[] = []; + const failure = await preflightProviderDns( + "openrouter", + PINNED_OPENROUTER_MODEL, + async (hostname) => hostnames.push(hostname), + ); + assert.equal(failure, undefined); + assert.deepEqual(hostnames, ["openrouter.ai"]); + + await preflightProviderDns("openai", "unused", async (hostname) => hostnames.push(hostname)); + assert.deepEqual(hostnames, ["openrouter.ai"]); +}); + test("the prompt policy rejects the actual combined document bytes", () => { assert.throws( () => buildSystemPrompt("é".repeat(512 * 1024), ["reference"]), @@ -161,3 +409,21 @@ function exchange(arguments_: string[], request: unknown): Promise> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, arguments_, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk)); + child.once("error", reject); + child.once("close", () => { + const line = stdout.trim().split("\n").at(-1); + if (!line) { + reject(new Error("runner returned no terminal response")); + return; + } + resolve(JSON.parse(line) as Record); + }); + child.stdin.end(JSON.stringify(request)); + }); +} diff --git a/tests/a33xctl-host-test.sh b/tests/a33xctl-host-test.sh index 56a0b48..36d6814 100755 --- a/tests/a33xctl-host-test.sh +++ b/tests/a33xctl-host-test.sh @@ -7,19 +7,479 @@ ctl="$repo_root/tools/a33xctl" mock_adb="$repo_root/tests/fixtures/a33xctl-mock-adb" test_root="$(mktemp -d /tmp/sos-a33xctl-host-test.XXXXXX)" trap 'rm -rf -- "$test_root"' EXIT +export A33XCTL_MOCK_INVOCATION_LOG="$test_root/adb-invocations.log" +export A33XCTL_MOCK_CREDENTIAL_STATE="$test_root/core-dev-state" +: >"$A33XCTL_MOCK_INVOCATION_LOG" +printf 'EMPTY\n' >"$A33XCTL_MOCK_CREDENTIAL_STATE" -A33XCTL_ADB="$mock_adb" "$ctl" inspect-core1-readiness \ +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +# Exercise the exact target-files SYSTEM-property validator used by both Core +# inspectors. Every call runs under this script's strict nounset mode. +# shellcheck source=../tools/a33x/core-system-properties.sh +source "$repo_root/tools/a33x/core-system-properties.sh" + +for variant in ordinary dev-credential; do + case_root="$test_root/SYSTEM property $variant" + target_files="$case_root/target files" + temp_parent="$case_root/temporary files" + mkdir -p "$target_files/SYSTEM" "$temp_parent" + + for invalid in missing empty weakened; do + rm -f -- "$target_files/SYSTEM/build.prop" + case "$invalid" in + missing) ;; + empty) : >"$target_files/SYSTEM/build.prop" ;; + weakened) + printf 'ro.build.type=userdebug\nro.debuggable=1\n' \ + >"$target_files/SYSTEM/build.prop" + ;; + esac + if TMPDIR="$temp_parent" inspect_core_system_properties \ + "$target_files" core1 "$variant" >"$case_root/$invalid.out" \ + 2>"$case_root/$invalid.err"; then + printf '%s Core inspector accepted %s SYSTEM properties\n' \ + "$variant" "$invalid" >&2 + exit 1 + fi + if [[ "$invalid" == missing || "$invalid" == empty ]]; then + grep -F 'SYSTEM property data is missing or empty in target-files' \ + "$case_root/$invalid.err" >/dev/null + else + grep -F 'image weakened Lineage' "$case_root/$invalid.err" >/dev/null + fi + [[ -z "$(find "$temp_parent" -mindepth 1 -print -quit)" ]] + done + + if [[ "$variant" == dev-credential ]]; then + printf 'ro.build.type=user\nro.debuggable=0\n' \ + >"$target_files/SYSTEM/build.prop" + if TMPDIR="$temp_parent" inspect_core_system_properties \ + "$target_files" core1 "$variant" >"$case_root/wrong-type.out" \ + 2>"$case_root/wrong-type.err"; then + printf 'Core-dev inspector accepted the wrong SYSTEM build type\n' >&2 + exit 1 + fi + grep -F 'not the registered userdebug product' \ + "$case_root/wrong-type.err" >/dev/null + [[ -z "$(find "$temp_parent" -mindepth 1 -print -quit)" ]] + fi + + printf 'ro.build.type=userdebug\nro.debuggable=0\n' \ + >"$target_files/SYSTEM/build.prop" + TMPDIR="$temp_parent" inspect_core_system_properties \ + "$target_files" core1 "$variant" + [[ -z "$(find "$temp_parent" -mindepth 1 -print -quit)" ]] +done + +A33XCTL_MOCK_PROBE_RESPONSE=lf A33XCTL_ADB="$mock_adb" \ + "$ctl" inspect-core1-readiness \ --serial MOCKSERIAL \ - --expected-revision sos.core1.test.revision \ + --expected-revision sos.core1dev.0123456789ab.cdef01234567 \ >"$test_root/readiness.out" grep -Fx 'core1_readiness=PASS' "$test_root/readiness.out" >/dev/null grep -Fx 'native_lifecycle=PASS' "$test_root/readiness.out" >/dev/null + +# Reproduce the observed r10 distinction explicitly: exec-out exits zero but +# exposes no bytes, while the production CLI's no-PTY shell transport carries +# the exact framed status. +A33XCTL_MOCK_ALLOW_EMPTY_EXEC_OUT=1 A33XCTL_MOCK_INVOCATION_LOG= \ + "$mock_adb" -s MOCKSERIAL exec-out \ + /system_ext/bin/sos-core-dev-credential probe "$test_root/exec-out-empty.out" 2>"$test_root/exec-out-empty.err" +[[ ! -s "$test_root/exec-out-empty.out" && ! -s "$test_root/exec-out-empty.err" ]] + +# The deployed r10 client emits the exact LF-terminated READY line. Exercise +# that byte framing through readiness above, permit only its CRLF transport +# equivalent, and reject every missing/ambiguous/binary variant. +A33XCTL_MOCK_PROBE_RESPONSE=crlf A33XCTL_ADB="$mock_adb" \ + "$ctl" inspect-core1-readiness --serial MOCKSERIAL \ + --expected-revision sos.core1dev.0123456789ab.cdef01234567 \ + >"$test_root/readiness-crlf.out" +grep -Fx 'core1_readiness=PASS' "$test_root/readiness-crlf.out" >/dev/null +for response_case in missing-newline double-newline prefix suffix whitespace \ + extra-output stderr-output nul wrong-status nonzero; do + if A33XCTL_MOCK_PROBE_RESPONSE="$response_case" \ + A33XCTL_ADB="$mock_adb" "$ctl" inspect-core1-readiness \ + --serial MOCKSERIAL \ + --expected-revision sos.core1dev.0123456789ab.cdef01234567 \ + >"$test_root/probe-$response_case.out" \ + 2>"$test_root/probe-$response_case.err"; then + printf 'ambiguous Core-dev probe output passed: %s\n' "$response_case" >&2 + exit 1 + fi + grep -F 'Core-dev probe failed: endpoint.protocol expected=v1 actual=mismatch' \ + "$test_root/probe-$response_case.err" >/dev/null +done +if A33XCTL_MOCK_SHELL_T_UNSUPPORTED=1 A33XCTL_ADB="$mock_adb" \ + "$ctl" inspect-core1-readiness --serial MOCKSERIAL \ + --expected-revision sos.core1dev.0123456789ab.cdef01234567 \ + >"$test_root/probe-shell-t-unsupported.out" \ + 2>"$test_root/probe-shell-t-unsupported.err"; then + printf 'Core-dev readiness passed without shell -T support\n' >&2 + exit 1 +fi +grep -F 'Core-dev probe failed: ADB transport requires shell -T support' \ + "$test_root/probe-shell-t-unsupported.err" >/dev/null + +grep -F 'property_is("ro.debuggable", "1")' \ + "$repo_root/apps/experience/src/core_dev_credential.rs" \ + "$repo_root/apps/experience/src/core_dev_product.rs" >/dev/null && { + printf 'Core-dev tooling still requires broad Android debugging\n' >&2 + exit 1 +} if A33XCTL_ADB="$mock_adb" "$ctl" inspect-core1-readiness \ --serial MOCKSERIAL --expected-revision sos.core1.wrong >/dev/null 2>&1; then printf 'wrong Core 1 revision unexpectedly passed\n' >&2 exit 1 fi +A33XCTL_ADB="$mock_adb" "$ctl" inspect-core1-readiness \ + --serial MOCKORDINARY \ + --expected-revision sos.core1.0123456789ab.cdef01234567 \ + >"$test_root/ordinary-readiness.out" +grep -Fx 'core1_readiness=PASS' "$test_root/ordinary-readiness.out" >/dev/null + +A33XCTL_MOCK_PROBE_RESPONSE=crlf A33XCTL_MOCK_CLEAR_RESPONSE=crlf \ + A33XCTL_ADB="$mock_adb" "$ctl" core1-dev-clear-openrouter-key \ + --serial MOCKSERIAL >"$test_root/dev-clear.out" +grep -Fx 'core1_dev_openrouter_key=CLEARED' "$test_root/dev-clear.out" >/dev/null +grep -Fx 'serial=MOCKSERIAL' "$test_root/dev-clear.out" >/dev/null +grep -Fx 'shell -T -- /system_ext/bin/sos-core-dev-credential status' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +if A33XCTL_MOCK_STATUS_RESPONSE=configured A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-clear-openrouter-key --serial MOCKSERIAL \ + >"$test_root/dev-clear-state-mismatch.out" \ + 2>"$test_root/dev-clear-state-mismatch.err"; then + printf 'Core-dev clear passed without EMPTY status\n' >&2 + exit 1 +fi +grep -F 'clear status mismatch: expected=EMPTY actual=CONFIGURED' \ + "$test_root/dev-clear-state-mismatch.err" >/dev/null +if A33XCTL_MOCK_CLEAR_RESPONSE=extra-output A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-clear-openrouter-key --serial MOCKSERIAL \ + >"$test_root/dev-clear-extra.out" 2>"$test_root/dev-clear-extra.err"; then + printf 'ambiguous Core-dev clear output passed\n' >&2 + exit 1 +fi +grep -F 'Core returned an invalid development credential acknowledgement' \ + "$test_root/dev-clear-extra.err" >/dev/null +run_mock_set() { + local response="$1" output="$2" shell_mode="${3:-normal}" + A33XCTL_MOCK_SET_RESPONSE="$response" python3 - \ + "$ctl" "$mock_adb" "$response" "$shell_mode" >"$output" 2>&1 <<'PY' +import errno, os, pty, sys +pid, fd = pty.fork() +if pid == 0: + env = dict(os.environ, A33XCTL_ADB=sys.argv[2]) + if sys.argv[4] == "xtrace": + executable = "/usr/bin/bash" + argv = ["bash", "-x", sys.argv[1], "core1-dev-set-openrouter-key", "--serial", "MOCKSERIAL"] + else: + executable = sys.argv[1] + argv = [sys.argv[1], "core1-dev-set-openrouter-key", "--serial", "MOCKSERIAL"] + os.execve(executable, argv, env) +output = b"" +prompt = b"Paste the disposable OpenRouter key, then press Enter: " +while prompt not in output: + output += os.read(fd, 4096) +if sys.argv[4] == "overlong": + os.write(fd, b"x" * 513 + b"\n") +else: + os.write(fd, b"mock-non-secret\n") +while True: + try: + output += os.read(fd, 4096) + except OSError as error: + if error.errno != errno.EIO: + raise + break +_, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output.replace(b"\r", b"")) +raise SystemExit(os.waitstatus_to_exitcode(status)) +PY +} + +run_mock_set crlf "$test_root/dev-set.out" xtrace +grep -Fx 'core1_dev_openrouter_key=SET' "$test_root/dev-set.out" >/dev/null +grep -Fx 'serial=MOCKSERIAL' "$test_root/dev-set.out" >/dev/null +! grep -F 'mock-non-secret' "$test_root/dev-set.out" >/dev/null +[[ "$(<"$A33XCTL_MOCK_CREDENTIAL_STATE")" == CONFIGURED ]] +A33XCTL_ADB="$mock_adb" "$ctl" core1-dev-status-openrouter-key \ + --serial MOCKSERIAL >"$test_root/dev-status-configured.out" +grep -Fx 'core1_dev_openrouter_key=CONFIGURED' \ + "$test_root/dev-status-configured.out" >/dev/null +grep -Fx 'serial=MOCKSERIAL' "$test_root/dev-status-configured.out" >/dev/null +A33XCTL_MOCK_SMOKE_RESPONSE=crlf A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-submit-agent-smoke --serial MOCKSERIAL \ + >"$test_root/dev-agent-smoke.out" +grep -Fx 'core1_dev_agent_smoke=SUBMITTED' \ + "$test_root/dev-agent-smoke.out" >/dev/null +grep -Fx 'serial=MOCKSERIAL' "$test_root/dev-agent-smoke.out" >/dev/null +[[ "$(<"$A33XCTL_MOCK_CREDENTIAL_STATE")" == CONFIGURED ]] +grep -Fx 'shell -T -- /system_ext/bin/sos-core-dev-credential agent-smoke' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null + +A33XCTL_ADB="$mock_adb" "$ctl" core1-dev-run-agent-smoke \ + --serial MOCKSERIAL >"$test_root/dev-agent-tunnel-smoke.out" +grep -Fx 'core1_dev_agent_smoke=COMPLETED' \ + "$test_root/dev-agent-tunnel-smoke.out" >/dev/null +tunnel_evidence="$(sed -n 's/^evidence_root=//p' "$test_root/dev-agent-tunnel-smoke.out")" +case "$tunnel_evidence" in + "$repo_root/artifacts/device-gates/core-dev-smoke-"*) ;; + *) fail "Core-dev smoke returned an unsafe evidence path" ;; +esac +[[ -s "$tunnel_evidence/framebuffer.png" ]] +grep -F 'transport=adb_reverse_connect' \ + "$tunnel_evidence/safe-lifecycle.txt" >/dev/null +grep -F 'core_ui_attempt event=terminal' \ + "$tunnel_evidence/safe-lifecycle.txt" >/dev/null +grep -E 'android_agent_activation_commit .*phase=committed authority=system' \ + "$tunnel_evidence/safe-lifecycle.txt" >/dev/null +grep -Fx 'core1_dev_openrouter_key=CONFIGURED' \ + "$tunnel_evidence/status-after.txt" >/dev/null +"$ctl" evidence-manifest-verify --root "$tunnel_evidence" \ + --manifest "$tunnel_evidence/manifest.tsv" >/dev/null +grep -E '^reverse tcp:37173 tcp:[0-9]+$' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +grep -Fx 'reverse --remove tcp:37173' "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +[[ "$(<"$A33XCTL_MOCK_CREDENTIAL_STATE")" == CONFIGURED ]] +rm -rf -- "$tunnel_evidence" + +if A33XCTL_MOCK_SMOKE_RESPONSE=extra-output A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-run-agent-smoke --serial MOCKSERIAL \ + >"$test_root/dev-agent-tunnel-failure.out" \ + 2>"$test_root/dev-agent-tunnel-failure.err"; then + fail "Core-dev tunnel smoke accepted a rejected submit" +fi +failed_tunnel_evidence="$(sed -n 's/^evidence_root=//p' \ + "$test_root/dev-agent-tunnel-failure.out")" +case "$failed_tunnel_evidence" in + "$repo_root/artifacts/device-gates/core-dev-smoke-"*) ;; + *) fail "failed Core-dev smoke returned an unsafe evidence path" ;; +esac +grep -Fx 'core1_dev_openrouter_key=CONFIGURED' \ + "$failed_tunnel_evidence/status-after.txt" >/dev/null +grep -Fx 'reverse --remove tcp:37173' "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +[[ "$(<"$A33XCTL_MOCK_CREDENTIAL_STATE")" == CONFIGURED ]] +rm -rf -- "$failed_tunnel_evidence" + +signal_marker="$test_root/signal-started" +: >"$signal_marker" +A33XCTL_MOCK_SMOKE_NO_TERMINAL=1 A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-run-agent-smoke --serial MOCKSERIAL \ + >"$test_root/dev-agent-tunnel-signal.out" \ + 2>"$test_root/dev-agent-tunnel-signal.err" & +signal_pid=$! +for _ in {1..50}; do + signal_evidence="$(find "$repo_root/artifacts/device-gates" -maxdepth 1 \ + -type d -name 'core-dev-smoke-*-mockserial' -newer "$signal_marker" \ + -print -quit)" + [[ -n "$signal_evidence" && -f "$signal_evidence/reverse-setup.txt" ]] && break + sleep 0.02 +done +[[ -n "${signal_evidence:-}" ]] +kill -TERM "$signal_pid" +if wait "$signal_pid"; then + fail "signaled Core-dev smoke unexpectedly succeeded" +fi +grep -Fx 'reverse --remove tcp:37173' "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +[[ "$(<"$A33XCTL_MOCK_CREDENTIAL_STATE")" == CONFIGURED ]] +rm -rf -- "$signal_evidence" +if A33XCTL_MOCK_SMOKE_RESPONSE=extra-output A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-submit-agent-smoke --serial MOCKSERIAL \ + >"$test_root/dev-agent-smoke-extra.out" \ + 2>"$test_root/dev-agent-smoke-extra.err"; then + printf 'ambiguous Core-dev smoke output passed\n' >&2 + exit 1 +fi +grep -F 'Core rejected the fixed development agent smoke submit' \ + "$test_root/dev-agent-smoke-extra.err" >/dev/null +[[ "$(<"$A33XCTL_MOCK_CREDENTIAL_STATE")" == CONFIGURED ]] +printf 'EMPTY\n' >"$A33XCTL_MOCK_CREDENTIAL_STATE" +if A33XCTL_ADB="$mock_adb" "$ctl" core1-dev-submit-agent-smoke \ + --serial MOCKSERIAL >"$test_root/dev-agent-smoke-empty.out" \ + 2>"$test_root/dev-agent-smoke-empty.err"; then + printf 'Core-dev smoke passed without configured credential\n' >&2 + exit 1 +fi +grep -F 'smoke requires credential state CONFIGURED; actual=EMPTY' \ + "$test_root/dev-agent-smoke-empty.err" >/dev/null +printf 'CONFIGURED\n' >"$A33XCTL_MOCK_CREDENTIAL_STATE" +if A33XCTL_MOCK_STATUS_RESPONSE=empty \ + run_mock_set lf "$test_root/dev-set-state-mismatch.out"; then + printf 'Core-dev set passed without CONFIGURED status\n' >&2 + exit 1 +fi +grep -F 'set status mismatch: expected=CONFIGURED actual=EMPTY' \ + "$test_root/dev-set-state-mismatch.out" >/dev/null +if run_mock_set extra-output "$test_root/dev-set-extra.out"; then + printf 'ambiguous Core-dev set output passed\n' >&2 + exit 1 +fi +grep -F 'credential state is unknown, so run core1-dev-clear-openrouter-key' \ + "$test_root/dev-set-extra.out" >/dev/null +set_invocations_before="$(grep -Fc \ + 'shell -T -- /system_ext/bin/sos-core-dev-credential set' \ + "$A33XCTL_MOCK_INVOCATION_LOG")" +if run_mock_set lf "$test_root/dev-set-overlong.out" overlong; then + printf 'overlong Core-dev credential input passed\n' >&2 + exit 1 +fi +grep -F 'credential input exceeds 512 bytes' \ + "$test_root/dev-set-overlong.out" >/dev/null +! grep -F 'xxxxxxxx' "$test_root/dev-set-overlong.out" >/dev/null +set_invocations_after="$(grep -Fc \ + 'shell -T -- /system_ext/bin/sos-core-dev-credential set' \ + "$A33XCTL_MOCK_INVOCATION_LOG")" +[[ "$set_invocations_after" == "$set_invocations_before" ]] +A33XCTL_MOCK_STATUS_RESPONSE=crlf A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-status-openrouter-key --serial MOCKSERIAL \ + >"$test_root/dev-status-crlf.out" +grep -Fx 'core1_dev_openrouter_key=CONFIGURED' \ + "$test_root/dev-status-crlf.out" >/dev/null +for response_case in missing-newline double-newline prefix suffix whitespace \ + extra-output stderr-output nul wrong-status nonzero; do + if A33XCTL_MOCK_STATUS_RESPONSE="$response_case" A33XCTL_ADB="$mock_adb" \ + "$ctl" core1-dev-status-openrouter-key --serial MOCKSERIAL \ + >"$test_root/dev-status-$response_case.out" \ + 2>"$test_root/dev-status-$response_case.err"; then + printf 'ambiguous Core-dev status output passed: %s\n' "$response_case" >&2 + exit 1 + fi + grep -F 'Core returned an invalid development credential status' \ + "$test_root/dev-status-$response_case.err" >/dev/null +done +if A33XCTL_ADB="$mock_adb" "$ctl" core1-dev-clear-openrouter-key \ + --serial MOCKORDINARY >/dev/null 2>&1; then + printf 'ordinary Core unexpectedly accepted the development credential client\n' >&2 + exit 1 +fi + +for rejection in \ + 'MOCKWRONGREV|ro.build.version.incremental' \ + 'MOCKWRONGTYPE|ro.build.type' \ + 'MOCKWRONGMARKER|ro.sos.build_variant'; do + serial="${rejection%%|*}" + marker="${rejection#*|}" + revision=sos.core1dev.0123456789ab.cdef01234567 + [[ "$serial" != MOCKWRONGREV ]] || revision=sos.core1dev.not-a-digest.cdef01234567 + if A33XCTL_ADB="$mock_adb" "$ctl" inspect-core1-readiness \ + --serial "$serial" --expected-revision "$revision" \ + >"$test_root/$serial.out" 2>"$test_root/$serial.err"; then + printf 'invalid Core-dev contract unexpectedly passed readiness: %s\n' "$serial" >&2 + exit 1 + fi + grep -F "Core product marker mismatch: $marker expected=" \ + "$test_root/$serial.err" >/dev/null + if A33XCTL_ADB="$mock_adb" "$ctl" core1-dev-clear-openrouter-key \ + --serial "$serial" >"$test_root/$serial-clear.out" \ + 2>"$test_root/$serial-clear.err"; then + printf 'invalid Core-dev contract unexpectedly passed clear: %s\n' "$serial" >&2 + exit 1 + fi + grep -F "Core product marker mismatch: $marker expected=" \ + "$test_root/$serial-clear.err" >/dev/null +done + +for rejection in \ + 'MOCKMISSINGCLIENT|client.executable expected=present actual=missing' \ + 'MOCKDENIEDCLIENT|client.execution expected=allowed actual=selinux-denied' \ + 'MOCKENDPOINTDOWN|endpoint.availability expected=ready actual=unavailable' \ + 'MOCKWRONGPEER|endpoint.peer_product expected=accepted actual=rejected' \ + 'MOCKPROTOCOL|endpoint.status expected=v1-known actual=mismatch' \ + 'MOCKBADMAGIC|endpoint.magic expected=SOSK actual=mismatch' \ + 'MOCKBADVERSION|endpoint.version expected=v1 actual=mismatch' \ + 'MOCKBADSTATUS|endpoint.status expected=v1-known actual=mismatch' \ + 'MOCKSHORTIO|endpoint.io expected=complete actual=short'; do + serial="${rejection%%|*}" + category="${rejection#*|}" + if A33XCTL_ADB="$mock_adb" "$ctl" inspect-core1-readiness \ + --serial "$serial" \ + --expected-revision sos.core1dev.0123456789ab.cdef01234567 \ + >"$test_root/$serial.out" 2>"$test_root/$serial.err"; then + printf 'failed Core-dev handshake unexpectedly passed readiness: %s\n' "$serial" >&2 + exit 1 + fi + grep -F "Core-dev probe failed: $category" "$test_root/$serial.err" >/dev/null + if A33XCTL_ADB="$mock_adb" "$ctl" core1-dev-clear-openrouter-key \ + --serial "$serial" >"$test_root/$serial-clear.out" \ + 2>"$test_root/$serial-clear.err"; then + printf 'failed Core-dev handshake unexpectedly passed clear: %s\n' "$serial" >&2 + exit 1 + fi + grep -F "Core-dev probe failed: $category" \ + "$test_root/$serial-clear.err" >/dev/null +done + +grep -F 'read -r -s -n 513 secret' "$ctl" >/dev/null +grep -F 'core_dev_status_file_matches "$stdout_file" "$expected_status"' \ + "$ctl" >/dev/null +grep -F 'shell -T -- \' "$ctl" >/dev/null +! grep -F 'exec-out \' "$ctl" >/dev/null +! grep -E 'adb.*(OPENROUTER|credential|key).*\$' "$ctl" >/dev/null +grep -Fx 'shell -T -- /system_ext/bin/sos-core-dev-credential set' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +grep -Fx 'shell -T -- /system_ext/bin/sos-core-dev-credential clear' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +grep -Fx 'shell -T -- /system_ext/bin/sos-core-dev-credential status' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +grep -Fx 'shell -T -- /system_ext/bin/sos-core-dev-credential probe' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +! grep -E '(^| )exec-out( |$)|(^| )-t(t)?( |$)' \ + "$A33XCTL_MOCK_INVOCATION_LOG" >/dev/null +! grep -R -F 'mock-non-secret' "$test_root" >/dev/null + +ordinary_product="$repo_root/aosp/device/sos/a33x/lineage_sos_core1_a33x.mk" +dev_product="$repo_root/aosp/device/sos/a33x/lineage_sos_core1_dev_a33x.mk" +ordinary_expansion="$( + make -s -f "$ordinary_product" 'inherit-product=' TARGET_BUILD_VARIANT=userdebug \ + --eval 'sos-print:;@printf "%s|%s|%s\n" "$(PRODUCT_NAME)" "$(PRODUCT_PACKAGES)" "$(PRODUCT_SYSTEM_EXT_PROPERTIES)"' \ + sos-print +)" +dev_expansion="$( + make -s -f "$dev_product" 'inherit-product=' TARGET_BUILD_VARIANT=userdebug \ + --eval 'sos-print:;@printf "%s|%s|%s\n" "$(PRODUCT_NAME)" "$(PRODUCT_PACKAGES)" "$(PRODUCT_SYSTEM_EXT_PROPERTIES)"' \ + sos-print +)" +[[ "$ordinary_expansion" == \ + 'lineage_sos_core1_a33x|sos-agent-runner|ro.sos.build_variant=core1-ordinary' ]] +[[ "$dev_expansion" == \ + 'lineage_sos_core1_dev_a33x|sos-core-dev-credential sos-node-core-dev sos-agent-runner-core-dev|ro.sos.build_variant=core1-dev-credential ro.sos.dev_credential=1' ]] +if make -s -f "$dev_product" 'inherit-product=' TARGET_BUILD_VARIANT=user \ + --eval 'sos-print:;@:' sos-print >/dev/null 2>&1; then + printf 'Core-dev product unexpectedly permitted a user build\n' >&2 + exit 1 +fi +if make -s -f "$dev_product" 'inherit-product=' TARGET_BUILD_VARIANT=eng \ + --eval 'sos-print:;@:' sos-print >/dev/null 2>&1; then + printf 'Core-dev product unexpectedly permitted an eng build\n' >&2 + exit 1 +fi +grep -F 'target_device="$LINEAGE_SOS_CORE1_DEV_DEVICE"' "$ctl" >/dev/null +grep -F 'container env "BUILD_NUMBER=$build_number" bash -lc' "$ctl" >/dev/null +grep -F '[[ -f "$client" ]]' "$ctl" >/dev/null +grep -F 'system_ext/bin/sos-core-dev-credential 0 2000 755 capabilities=0x0' \ + "$ctl" >/dev/null +grep -F 'sos_core_dev_credential_exec:s0' "$ctl" >/dev/null +grep -F 'CORE_DEV_PROXY_DEVICE_PORT=37173' "$ctl" >/dev/null +grep -F 'CORE_DEV_PROXY_AUTHORITY=openrouter.ai:443' "$ctl" >/dev/null +! grep -E 'core1-dev-run-agent-smoke.*(prompt|provider|proxy|model|key)' "$ctl" >/dev/null +! grep -E 'sos-node-core-dev|sos-core-dev-credential|sos-agent-runner-core-dev|ro\.sos\.dev_credential' \ + "$ordinary_product" >/dev/null +grep -Fx ' sos-agent-runner' "$ordinary_product" >/dev/null +grep -Fx ' sos-node-core-dev \' "$dev_product" >/dev/null +grep -Fx ' sos-agent-runner-core-dev' "$dev_product" >/dev/null +[[ "$(grep -Fc ' filename: "agent-runner.cjs",' \ + "$repo_root/aosp/device/sos/a33x/Android.bp")" -eq 1 ]] +[[ "$(grep -Fc ' filename: "agent-runner-core-dev.cjs",' \ + "$repo_root/aosp/device/sos/a33x/Android.bp")" -eq 1 ]] +! grep -F 'overrides: ["sos-agent-runner"]' \ + "$repo_root/aosp/device/sos/a33x/Android.bp" >/dev/null + mkdir "$test_root/evidence" printf 'bravo\n' >"$test_root/evidence/b.txt" printf 'alpha\n' >"$test_root/evidence/a.txt" diff --git a/tests/a33xctl-patch-series-test.sh b/tests/a33xctl-patch-series-test.sh new file mode 100755 index 0000000..87d7634 --- /dev/null +++ b/tests/a33xctl-patch-series-test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ctl="$repo_root/tools/a33xctl" +source_root="${SOS_LINEAGE_ROOT:-$HOME/dev/lineage-a33x}" +test_root="$(mktemp -d /tmp/sos-a33xctl-patch-test.XXXXXX)" +lineage_root="$test_root/lineage-a33x" +trap 'rm -rf -- "$test_root"' EXIT + +clone_sparse_project() { + [[ "$#" -ge 2 ]] || return 2 + local relative_project="$1" + shift + local source_project="$source_root/$relative_project" + local destination_project="$lineage_root/$relative_project" + + [[ -d "$source_project/.git" || -f "$source_project/.git" ]] || { + printf 'expected Lineage source project missing: %s\n' "$relative_project" >&2 + return 1 + } + mkdir -p "$(dirname "$destination_project")" + git clone --quiet --shared --no-checkout "$source_project" "$destination_project" + git -C "$destination_project" sparse-checkout set --no-cone "$@" + git -C "$destination_project" checkout --quiet --detach HEAD +} + +clone_sparse_project device/samsung/s5e8825-common \ + BoardConfigCommon.mk \ + common.mk \ + configs/init/init.s5e8825.rc \ + shims/libepicoperator/epicoperator.c +clone_sparse_project vendor/samsung/s5e8825-common Android.bp +clone_sparse_project frameworks/base \ + services/core/java/com/android/server/am/ActivityManagerService.java \ + services/core/java/com/android/server/am/AppErrors.java \ + services/core/java/com/android/server/pm/PackageInstallerService.java \ + services/core/java/com/android/server/wm/ActivityStarter.java \ + services/core/java/com/android/server/wm/WindowManagerService.java + +SOS_LINEAGE_ROOT="$lineage_root" "$ctl" check-patch-series >/dev/null +SOS_LINEAGE_ROOT="$lineage_root" "$ctl" apply-patches >/dev/null +first_identity="$( + for relative_project in \ + device/samsung/s5e8825-common \ + vendor/samsung/s5e8825-common \ + frameworks/base; do + git -C "$lineage_root/$relative_project" diff --no-ext-diff --binary + done | sha256sum +)" +SOS_LINEAGE_ROOT="$lineage_root" "$ctl" apply-patches >/dev/null +second_identity="$( + for relative_project in \ + device/samsung/s5e8825-common \ + vendor/samsung/s5e8825-common \ + frameworks/base; do + git -C "$lineage_root/$relative_project" diff --no-ext-diff --binary + done | sha256sum +)" +[[ "$second_identity" == "$first_identity" ]] || { + printf 'second patch bootstrap changed the complete ordered result\n' >&2 + exit 1 +} +SOS_LINEAGE_ROOT="$lineage_root" "$ctl" check-patch-series >/dev/null + +printf 'a33xctl_patch_series_test=PASS\n' diff --git a/tests/core-dev-connect-bridge-test.py b/tests/core-dev-connect-bridge-test.py new file mode 100644 index 0000000..4c24792 --- /dev/null +++ b/tests/core-dev-connect-bridge-test.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import io +import socket +import subprocess +import sys +import threading +import time +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.dont_write_bytecode = True +SPEC = importlib.util.spec_from_file_location( + "core_dev_connect_bridge", ROOT / "tools" / "core-dev-connect-bridge.py" +) +assert SPEC is not None and SPEC.loader is not None +bridge_module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(bridge_module) + + +class BridgeHarness: + def __init__(self) -> None: + self.upstream_peer: socket.socket | None = None + self.events_output = io.StringIO() + + def connector() -> socket.socket: + bridge_end, peer_end = socket.socketpair() + self.upstream_peer = peer_end + return bridge_end + + self.server = bridge_module.ConnectBridge( + connector, + bridge_module.EventLog(self.events_output), + ) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + if self.upstream_peer is not None: + self.upstream_peer.close() + + def request(self, payload: bytes) -> tuple[socket.socket, bytes]: + client = socket.create_connection(self.server.server_address, timeout=2) + client.sendall(payload) + return client, client.recv(4096) + + def events(self) -> list[str]: + return self.events_output.getvalue().splitlines() + + def wait_for_event(self, prefix: str, timeout_seconds: float = 2.0) -> list[str]: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + events = self.events() + if any(event.startswith(prefix) for event in events): + return events + time.sleep(0.01) + return self.events() + + +class ConnectBridgeTest(unittest.TestCase): + def setUp(self) -> None: + self.harness = BridgeHarness() + + def tearDown(self) -> None: + self.harness.close() + + def test_binds_only_ipv4_loopback(self) -> None: + self.assertEqual(self.harness.server.server_address[0], "127.0.0.1") + + def test_rejects_non_allowlisted_and_malformed_requests(self) -> None: + cases = [ + (b"GET / HTTP/1.1\r\nHost: openrouter.ai:443\r\n\r\n", b" 405 "), + ( + b"CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\n\r\n", + b" 403 ", + ), + ( + b"CONNECT openrouter.ai:80 HTTP/1.1\r\nHost: openrouter.ai:80\r\n\r\n", + b" 403 ", + ), + ( + b"CONNECT openrouter.ai:443 HTTP/1.0\r\nHost: openrouter.ai:443\r\n\r\n", + b" 400 ", + ), + (b"CONNECT openrouter.ai:443 HTTP/1.1\r\n\r\n", b" 400 "), + ( + b"CONNECT openrouter.ai:443 HTTP/1.1\r\nHost: openrouter.ai:443\r\n" + b"Content-Length: 6\r\n\r\nsecret", + b" 400 ", + ), + ] + for request, expected in cases: + with self.subTest(request=request): + client, response = self.harness.request(request) + self.assertIn(expected, response) + client.close() + events = self.harness.events() + self.assertEqual(events.count("bridge_event=connection_accepted"), len(cases)) + self.assertEqual( + sum(event.startswith("bridge_event=request_rejected status=") for event in events), + len(cases), + ) + + def test_relays_opaque_bytes_in_both_directions_without_output(self) -> None: + stdout = io.StringIO() + stderr = io.StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + client, response = self.harness.request( + b"CONNECT openrouter.ai:443 HTTP/1.1\r\n" + b"Host: openrouter.ai:443\r\n\r\n" + ) + self.assertEqual(response, b"HTTP/1.1 200 Connection Established\r\n\r\n") + assert self.harness.upstream_peer is not None + secret = b"synthetic-secret-body-never-log" + client.sendall(secret) + self.assertEqual(self.harness.upstream_peer.recv(len(secret)), secret) + ciphertext = b"\x16\x03\x03opaque-tls-record" + self.harness.upstream_peer.sendall(ciphertext) + self.assertEqual(client.recv(len(ciphertext)), ciphertext) + client.close() + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "") + events = self.harness.wait_for_event("bridge_event=relay_terminal ") + self.assertIn("bridge_event=connection_accepted", events) + self.assertIn( + "bridge_event=connect_accepted authority=openrouter.ai:443", + events, + ) + self.assertIn("bridge_event=upstream_connected", events) + self.assertIn( + "bridge_event=relay_started direction=device_to_upstream", + events, + ) + self.assertIn( + "bridge_event=relay_started direction=upstream_to_device", + events, + ) + terminal = next(event for event in events if event.startswith("bridge_event=relay_terminal ")) + self.assertIn(f"device_to_upstream_bytes={len(secret)}", terminal) + self.assertIn(f"upstream_to_device_bytes={len(ciphertext)}", terminal) + self.assertNotIn("synthetic-secret", "\n".join(events)) + self.assertNotIn("opaque-tls-record", "\n".join(events)) + + def test_real_jitless_native_http_proxy_sends_connect_before_tls(self) -> None: + proxy = "http://{}:{}".format(*self.harness.server.server_address) + modules = ROOT / "services" / "sos-agent" / "node_modules" + node_fetch = modules / "node-fetch" / "src" / "index.js" + https_proxy_agent = modules / "https-proxy-agent" + script = """ +const { pathToFileURL } = require("node:url"); +const { HttpsProxyAgent } = require(process.argv[2]); +(async () => { + const { default: fetch } = await import(pathToFileURL(process.argv[1]).href); + const agent = new HttpsProxyAgent(process.argv[3]); + await fetch("https://openrouter.ai/api/v1/models", { agent }); +})().then(() => process.exit(2), () => process.exit(0)); +""" + process = subprocess.Popen( + [ + "node", + "--jitless", + "-e", + script, + str(node_fetch), + str(https_proxy_agent), + proxy, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + events = self.harness.wait_for_event("bridge_event=upstream_connected") + self.assertIn( + "bridge_event=connect_accepted authority=openrouter.ai:443", + events, + ) + assert self.harness.upstream_peer is not None + self.harness.upstream_peer.settimeout(2) + tls_record = self.harness.upstream_peer.recv(4096) + self.assertTrue(tls_record.startswith(b"\x16\x03")) + self.harness.upstream_peer.close() + self.harness.upstream_peer = None + stdout, stderr = process.communicate(timeout=2) + self.assertEqual(process.returncode, 0) + self.assertEqual(stdout, b"") + self.assertTrue( + stderr == b"" or b"disabling flag --expose_wasm" in stderr + ) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=2) + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/a33xctl-mock-adb b/tests/fixtures/a33xctl-mock-adb index 5e8e469..0f4de1d 100755 --- a/tests/fixtures/a33xctl-mock-adb +++ b/tests/fixtures/a33xctl-mock-adb @@ -2,31 +2,231 @@ set -euo pipefail -[[ "$#" -ge 3 && "$1" == -s && "$2" == MOCKSERIAL ]] || exit 64 +[[ "$#" -ge 3 && "$1" == -s ]] || exit 64 +serial="$2" +case "$serial" in + MOCKSERIAL|MOCKORDINARY|MOCKWRONGREV|MOCKWRONGTYPE|MOCKWRONGMARKER|MOCKMISSINGCLIENT|MOCKDENIEDCLIENT|MOCKENDPOINTDOWN|MOCKWRONGPEER|MOCKPROTOCOL|MOCKBADMAGIC|MOCKBADVERSION|MOCKBADSTATUS|MOCKSHORTIO) ;; + *) exit 64 ;; +esac shift 2 +if [[ -n "${A33XCTL_MOCK_INVOCATION_LOG:-}" ]]; then + printf '%s\n' "$*" >>"$A33XCTL_MOCK_INVOCATION_LOG" +fi case "$*" in - 'shell getprop ro.build.version.incremental') printf '%s\r\n' 'sos.core1.test.revision' ;; + 'shell getprop ro.build.version.incremental') + case "$serial" in + MOCKORDINARY) printf '%s\r\n' 'sos.core1.0123456789ab.cdef01234567' ;; + MOCKWRONGREV) printf '%s\r\n' 'sos.core1dev.not-a-digest.cdef01234567' ;; + *) printf '%s\r\n' 'sos.core1dev.0123456789ab.cdef01234567' ;; + esac + ;; 'shell getprop ro.sos.core.stage') printf '1\r\n' ;; 'shell getprop ro.sos.lifecycle') printf 'active\r\n' ;; 'shell getprop ro.sos.profile') printf 'core\r\n' ;; 'shell getprop ro.sos.providers') printf 'core-native\r\n' ;; 'shell getprop ro.sos.ui_owner') printf 'native-sos-no-zygote\r\n' ;; + 'shell getprop ro.sos.build_variant') + if [[ "$serial" == MOCKORDINARY ]]; then + printf 'core1-ordinary\r\n' + elif [[ "$serial" == MOCKWRONGMARKER ]]; then + printf 'core1-wrong\r\n' + else + printf 'core1-dev-credential\r\n' + fi + ;; + 'shell getprop ro.sos.dev_credential') + if [[ "$serial" != MOCKORDINARY ]]; then + printf '1\r\n' + fi + ;; + 'shell getprop ro.build.type') + if [[ "$serial" == MOCKWRONGTYPE ]]; then + printf 'user\r\n' + else + printf 'userdebug\r\n' + fi + ;; + 'shell getprop ro.debuggable') printf '0\r\n' ;; 'shell getprop ro.zygote') printf 'no_zygote\r\n' ;; 'shell getprop init.svc.sos_core_host') printf 'running\r\n' ;; 'shell getprop init.svc.sos_authority') printf 'running\r\n' ;; 'shell getprop init.svc.sos_core_platform') printf 'running\r\n' ;; 'shell getenforce') printf 'Enforcing\r\n' ;; + 'shell date +%s') printf '1787040000\r\n' ;; 'shell dumpsys SurfaceFlinger --list') printf 'Background\r\nSOS Core Experience\r\n' ;; 'shell pidof sos-core-host') printf '921 932\r\n' ;; 'shell pidof sos-authority') printf '941\r\n' ;; 'shell pidof sos-core-platform') printf '942\r\n' ;; + 'exec-out /system_ext/bin/sos-core-dev-credential probe') + [[ "${A33XCTL_MOCK_ALLOW_EMPTY_EXEC_OUT:-}" == 1 ]] || exit 65 + # Reproduce the observed r10 exec-out transport: remote exit 0 with no + # bytes on either host stream. + [[ -z "$(dd bs=1 count=1 status=none 2>/dev/null)" ]] || exit 70 + ;; + 'shell -T -- /system_ext/bin/sos-core-dev-credential probe') + if [[ "${A33XCTL_MOCK_SHELL_T_UNSUPPORTED:-}" == 1 ]]; then + printf "adb shell: invalid option -- 'T'\n" >&2 + exit 1 + fi + if IFS= read -r -n 1 mock_unexpected; then + printf 'probe received unexpected stdin\n' >&2 + exit 70 + fi + if [[ "$serial" == MOCKSERIAL ]]; then + case "${A33XCTL_MOCK_PROBE_RESPONSE:-lf}" in + lf) printf 'core_dev_credential=READY\n' ;; + crlf) printf 'core_dev_credential=READY\r\n' ;; + missing-newline) printf 'core_dev_credential=READY' ;; + double-newline) printf 'core_dev_credential=READY\n\n' ;; + prefix) printf 'xcore_dev_credential=READY\n' ;; + suffix) printf 'core_dev_credential=READYx\n' ;; + whitespace) printf 'core_dev_credential=READY \n' ;; + extra-output) printf 'core_dev_credential=READY\nextra\n' ;; + stderr-output) + printf 'core_dev_credential=READY\n' + printf 'extra\n' >&2 + ;; + nul) printf 'core_dev_credential=READY\0\n' ;; + wrong-status) printf 'core_dev_credential=SET\n' ;; + nonzero) + printf 'core_dev_credential=READY\n' + exit 68 + ;; + *) exit 69 ;; + esac + exit 0 + fi + case "$serial" in + MOCKORDINARY|MOCKMISSINGCLIENT) + printf '/system/bin/sh: /system_ext/bin/sos-core-dev-credential: not found\n' >&2 + exit 127 + ;; + MOCKDENIEDCLIENT) + printf '/system/bin/sh: /system_ext/bin/sos-core-dev-credential: Permission denied\n' >&2 + exit 126 + ;; + MOCKENDPOINTDOWN) + printf 'error: Core development credential request failed (endpoint_unavailable)\n' >&2 + exit 1 + ;; + MOCKWRONGPEER) + printf 'error: Core development credential request failed (wrong_peer)\n' >&2 + exit 1 + ;; + MOCKPROTOCOL) + printf 'error: Core development credential request failed (protocol_mismatch)\n' >&2 + exit 1 + ;; + MOCKBADMAGIC) category=bad_magic ;; + MOCKBADVERSION) category=bad_version ;; + MOCKBADSTATUS) category=bad_status ;; + MOCKSHORTIO) category=short_io ;; + *) printf 'core_dev_credential=READY\n' ;; + esac + if [[ -n "${category:-}" ]]; then + printf 'error: Core development credential request failed (%s)\n' "$category" >&2 + exit 1 + fi + ;; 'logcat -d -b all -v threadtime') printf '%s\n' \ '08-17 14:45:30.000 I sos-core-host: core1_experience_start ce_available=false native_synthetic_password=false de_storage=true' \ '08-17 14:45:30.100 I sos-core-host: native_gpui_start width=1080 height=2400 density=450 ui_owner=native-sos-no-zygote' \ '08-17 14:45:31.000 I sos-experience: SOS experience window is live frame_padding=none' ;; + logcat\ -d\ -v\ brief\ -T\ 1787040000.000) + if [[ "${A33XCTL_MOCK_SMOKE_NO_TERMINAL:-}" != 1 ]]; then + printf '%s\n' \ + 'I/sos-experience: core_dev_agent_smoke state=queued prompt=fixed_non_secret transport=adb_reverse_connect' \ + 'I/sos-experience: core_ui_attempt event=attempt_received attempt=1 provider=openrouter model=deepseek/deepseek-v4-flash-0731 configured=true busy=false input_present=true stage=ui category=none status=pending correlation=serialized' \ + 'I/sos-experience: android_agent_child_start pid=77 expected_domain=sos_core_dev_agent provider_identity=openrouter model=deepseek/deepseek-v4-flash-0731 platform=core hardening=jitless fd_boundary=stdio_only stderr=discarded transport=adb_reverse_connect' \ + 'I/sos-experience: core_ui_attempt event=terminal attempt=1 provider=openrouter model=deepseek/deepseek-v4-flash-0731 configured=true busy=false input_present=true stage=ui category=completed status=completed correlation=serialized' \ + 'I/sos-experience: android_agent_activation_stage_ack request_id=77 revision=synthetic state_stage_id=2 phase=staged authority_committed=false' \ + 'I/sos-experience: android_agent_activation_commit request_id=77 revision=synthetic state_revision=3 source_sha256=synthetic phase=committed authority=system' + fi + ;; + reverse\ tcp:37173\ tcp:*) ;; + 'reverse --remove tcp:37173') ;; + 'exec-out screencap -p') printf '\211PNG\r\n\032\nmock-framebuffer' ;; + 'shell -T -- /system_ext/bin/sos-core-dev-credential clear') + [[ "$serial" == MOCKSERIAL ]] || exit 66 + if IFS= read -r -n 1 mock_unexpected; then + printf 'clear received unexpected stdin\n' >&2 + exit 70 + fi + printf 'EMPTY\n' >"${A33XCTL_MOCK_CREDENTIAL_STATE:?}" + case "${A33XCTL_MOCK_CLEAR_RESPONSE:-lf}" in + lf) printf 'core_dev_credential=CLEARED\n' ;; + crlf) printf 'core_dev_credential=CLEARED\r\n' ;; + extra-output) printf 'core_dev_credential=CLEARED\nextra\n' ;; + *) exit 69 ;; + esac + ;; + 'shell -T -- /system_ext/bin/sos-core-dev-credential set') + [[ "$serial" == MOCKSERIAL ]] || exit 66 + [[ "$*" != *mock-non-secret* ]] || exit 71 + ! env | grep -F 'mock-non-secret' >/dev/null || exit 72 + IFS= read -r mock_value + [[ "$mock_value" == mock-non-secret ]] || exit 67 + if IFS= read -r -n 1 mock_unexpected; then + printf 'set received more than one input line\n' >&2 + exit 70 + fi + printf 'CONFIGURED\n' >"${A33XCTL_MOCK_CREDENTIAL_STATE:?}" + case "${A33XCTL_MOCK_SET_RESPONSE:-lf}" in + lf) printf 'core_dev_credential=SET\n' ;; + crlf) printf 'core_dev_credential=SET\r\n' ;; + extra-output) printf 'core_dev_credential=SET\nextra\n' ;; + *) exit 69 ;; + esac + ;; + 'shell -T -- /system_ext/bin/sos-core-dev-credential status') + [[ "$serial" == MOCKSERIAL ]] || exit 66 + if IFS= read -r -n 1 mock_unexpected; then + printf 'status received unexpected stdin\n' >&2 + exit 70 + fi + mock_state="$(<"${A33XCTL_MOCK_CREDENTIAL_STATE:?}")" + [[ "$mock_state" == CONFIGURED || "$mock_state" == EMPTY ]] || exit 73 + case "${A33XCTL_MOCK_STATUS_RESPONSE:-lf}" in + lf) printf 'core_dev_credential=%s\n' "$mock_state" ;; + crlf) printf 'core_dev_credential=%s\r\n' "$mock_state" ;; + configured) printf 'core_dev_credential=CONFIGURED\n' ;; + empty) printf 'core_dev_credential=EMPTY\n' ;; + missing-newline) printf 'core_dev_credential=%s' "$mock_state" ;; + double-newline) printf 'core_dev_credential=%s\n\n' "$mock_state" ;; + prefix) printf 'xcore_dev_credential=%s\n' "$mock_state" ;; + suffix) printf 'core_dev_credential=%sx\n' "$mock_state" ;; + whitespace) printf 'core_dev_credential=%s \n' "$mock_state" ;; + extra-output) printf 'core_dev_credential=%s\nextra\n' "$mock_state" ;; + stderr-output) + printf 'core_dev_credential=%s\n' "$mock_state" + printf 'extra\n' >&2 + ;; + nul) printf 'core_dev_credential=%s\0\n' "$mock_state" ;; + wrong-status) printf 'core_dev_credential=READY\n' ;; + nonzero) + printf 'core_dev_credential=%s\n' "$mock_state" + exit 68 + ;; + *) exit 69 ;; + esac + ;; + 'shell -T -- /system_ext/bin/sos-core-dev-credential agent-smoke') + [[ "$serial" == MOCKSERIAL ]] || exit 66 + if IFS= read -r -n 1 mock_unexpected; then + printf 'agent-smoke received unexpected stdin\n' >&2 + exit 70 + fi + [[ "$(<"${A33XCTL_MOCK_CREDENTIAL_STATE:?}")" == CONFIGURED ]] || exit 74 + case "${A33XCTL_MOCK_SMOKE_RESPONSE:-lf}" in + lf) printf 'core_dev_agent_smoke=SUBMITTED\n' ;; + crlf) printf 'core_dev_agent_smoke=SUBMITTED\r\n' ;; + extra-output) printf 'core_dev_agent_smoke=SUBMITTED\nextra\n' ;; + *) exit 69 ;; + esac + ;; *) printf 'unexpected mock adb arguments: %s\n' "$*" >&2 exit 65 diff --git a/tests/fixtures/core-agent-policy.cil b/tests/fixtures/core-agent-policy.cil new file mode 100644 index 0000000..fd0e1c8 --- /dev/null +++ b/tests/fixtures/core-agent-policy.cil @@ -0,0 +1,87 @@ +(class process (execmem)) +(class file (getattr open read map)) +(class fd (use)) +(class fifo_file (getattr ioctl read write)) +(class sock_file (write)) +(class unix_stream_socket (connectto)) +(class tcp_socket (create read write getattr setattr getopt setopt bind connect name_connect)) +(class udp_socket (create read write bind connect ioctl name_bind node_bind)) +(class rawip_socket (create read write bind connect ioctl node_bind)) +(class icmp_socket (create read write bind connect ioctl node_bind)) +(classorder (process file fd fifo_file sock_file unix_stream_socket tcp_socket udp_socket rawip_socket icmp_socket)) +(sid kernel) +(sidorder (kernel)) +(user system_u) +(role object_r) +(type sos_core_host) +(type sos_core_agent) +(type sos_core_dev_agent) +(type netd) +(type dnsproxyd_socket) +(type fwmarkd_socket) +(type net_dns_prop) +(type proc_meminfo) +(type sos_agent_https_port) +(type sos_core_dev_proxy_port) +(type generic_port) +(type generic_node) +(roletype object_r sos_core_host) +(roletype object_r sos_core_agent) +(roletype object_r sos_core_dev_agent) +(roletype object_r netd) +(roletype object_r dnsproxyd_socket) +(roletype object_r fwmarkd_socket) +(roletype object_r net_dns_prop) +(roletype object_r proc_meminfo) +(roletype object_r sos_agent_https_port) +(roletype object_r sos_core_dev_proxy_port) +(roletype object_r generic_port) +(roletype object_r generic_node) +(userrole system_u object_r) +(userlevel system_u (s0)) +(userrange system_u ((s0) (s0 (c0)))) +(sensitivity s0) +(sensitivitycategory s0 (c0)) +(sensitivityorder (s0)) +(category c0) +(categoryorder (c0)) +(sidcontext kernel (system_u object_r sos_core_host ((s0) (s0)))) +(allow sos_core_agent sos_core_host (fd (use))) +(allow sos_core_agent sos_core_host (fifo_file (getattr ioctl read write))) +(allowx sos_core_agent sos_core_host (ioctl fifo_file (0x5401))) +(allow sos_core_agent proc_meminfo (file (open read))) +(allow sos_core_agent sos_core_agent (udp_socket (create))) +(allow sos_core_agent dnsproxyd_socket (sock_file (write))) +(allow sos_core_agent fwmarkd_socket (sock_file (write))) +(allow sos_core_agent netd (unix_stream_socket (connectto))) +(allow netd sos_core_agent (fd (use))) +(allow netd sos_core_agent (tcp_socket (read write getattr setattr getopt setopt))) +(allow sos_core_agent sos_core_agent (tcp_socket (create read write bind connect))) +(allow sos_core_agent sos_agent_https_port (tcp_socket (name_connect))) +(neverallow sos_core_host sos_core_host (tcp_socket (create))) +(neverallow sos_core_host sos_core_host (udp_socket (create))) +(neverallow sos_core_agent sos_core_agent (udp_socket (read write bind connect ioctl name_bind node_bind))) +(neverallow sos_core_agent generic_port (udp_socket (name_bind))) +(neverallow sos_core_agent generic_port (tcp_socket (name_connect))) +(neverallow sos_core_agent generic_node (udp_socket (node_bind))) +(neverallow sos_core_agent net_dns_prop (file (getattr open read map))) +(neverallow sos_core_agent sos_core_agent (process (execmem))) +(allow sos_core_dev_agent sos_core_host (fd (use))) +(allow sos_core_dev_agent sos_core_host (fifo_file (getattr ioctl read write))) +(allowx sos_core_dev_agent sos_core_host (ioctl fifo_file (0x5401))) +(allow sos_core_dev_agent proc_meminfo (file (open read))) +(allow sos_core_dev_agent sos_core_dev_agent (udp_socket (create))) +(allow sos_core_dev_agent dnsproxyd_socket (sock_file (write))) +(allow sos_core_dev_agent fwmarkd_socket (sock_file (write))) +(allow sos_core_dev_agent netd (unix_stream_socket (connectto))) +(allow netd sos_core_dev_agent (fd (use))) +(allow netd sos_core_dev_agent (tcp_socket (read write getattr setattr getopt setopt))) +(allow sos_core_dev_agent sos_core_dev_agent (tcp_socket (create read write bind connect))) +(allow sos_core_dev_agent sos_agent_https_port (tcp_socket (name_connect))) +(allow sos_core_dev_agent sos_core_dev_proxy_port (tcp_socket (name_connect))) +(neverallow sos_core_dev_agent sos_core_dev_agent (udp_socket (read write bind connect ioctl name_bind node_bind))) +(neverallow sos_core_dev_agent generic_port (udp_socket (name_bind))) +(neverallow sos_core_dev_agent generic_port (tcp_socket (name_connect))) +(neverallow sos_core_dev_agent generic_node (udp_socket (node_bind))) +(neverallow sos_core_dev_agent net_dns_prop (file (getattr open read map))) +(neverallow sos_core_dev_agent sos_core_dev_agent (process (execmem))) diff --git a/tests/fixtures/core-dev-credential-policy.cil b/tests/fixtures/core-dev-credential-policy.cil new file mode 100644 index 0000000..2dd966a --- /dev/null +++ b/tests/fixtures/core-dev-credential-policy.cil @@ -0,0 +1,51 @@ +;; BEGIN FOCUSED CIL +(class process (transition sigchld siginh rlimitinh)) +(class file (getattr open read execute map entrypoint)) +(class fd (use)) +(class fifo_file (getattr read write)) +(class unix_stream_socket (create getattr setattr lock read write append bind connect listen accept getopt setopt shutdown recvfrom sendto name_bind connectto ioctl)) +(class tcp_socket (read)) +(class udp_socket (read)) +(class rawip_socket (read)) +(class icmp_socket (read)) +(classorder (process file fd fifo_file unix_stream_socket tcp_socket udp_socket rawip_socket icmp_socket)) +(sid kernel) +(sidorder (kernel)) +(user system_u) +(role object_r) +(type shell) +(type adbd) +(type sos_core_host) +(type sos_core_dev_credential) +(type sos_core_dev_credential_exec) +(roletype object_r shell) +(roletype object_r adbd) +(roletype object_r sos_core_host) +(roletype object_r sos_core_dev_credential) +(roletype object_r sos_core_dev_credential_exec) +(userrole system_u object_r) +(userlevel system_u (s0)) +(userrange system_u ((s0) (s0 (c0)))) +(sensitivity s0) +(sensitivitycategory s0 (c0)) +(sensitivityorder (s0)) +(category c0) +(categoryorder (c0)) +(sidcontext kernel (system_u object_r shell ((s0) (s0)))) +(allow shell sos_core_dev_credential_exec (file (getattr open read execute map))) +(allow shell sos_core_dev_credential (process (transition siginh rlimitinh))) +(allow sos_core_dev_credential sos_core_dev_credential_exec (file (entrypoint open read execute getattr map))) +(allow sos_core_dev_credential shell (process (sigchld))) +(typetransition shell sos_core_dev_credential_exec process sos_core_dev_credential) +(allow sos_core_dev_credential shell (fd (use))) +(allow sos_core_dev_credential adbd (fd (use))) +(allow sos_core_dev_credential adbd (unix_stream_socket (read write))) +(allow sos_core_dev_credential shell (fifo_file (getattr read write))) +(allow sos_core_dev_credential self (unix_stream_socket (create getattr setattr lock read write append bind connect listen accept getopt setopt shutdown recvfrom sendto name_bind ioctl))) +(allow sos_core_dev_credential sos_core_host (unix_stream_socket (connectto))) +(neverallow shell sos_core_host (unix_stream_socket (connectto))) +(neverallow sos_core_dev_credential self (tcp_socket (read))) +(neverallow sos_core_dev_credential self (udp_socket (read))) +(neverallow sos_core_dev_credential self (rawip_socket (read))) +(neverallow sos_core_dev_credential self (icmp_socket (read))) +;; END FOCUSED CIL diff --git a/tools/a33x/core-system-properties.sh b/tools/a33x/core-system-properties.sh new file mode 100644 index 0000000..27e9164 --- /dev/null +++ b/tools/a33x/core-system-properties.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +# Validate the SYSTEM partition property data staged into target-files. Keep +# this helper sourceable so the host regression can exercise the exact +# inspector path without requiring a product build. +inspect_core_system_properties() ( + [[ "$#" -eq 3 ]] || \ + fail "internal usage: inspect_core_system_properties " + local target_files="$1" stage="$2" product_variant="$3" + local property_source="$target_files/SYSTEM/build.prop" + local inspection_root="" system_properties="" + + inspection_root="$(mktemp -d "${TMPDIR:-/tmp}/sos-a33x-system-properties.XXXXXX")" || \ + fail "SOS $stage $product_variant SYSTEM property inspection setup failed" + trap 'rm -rf -- "$inspection_root"' EXIT + + [[ -s "$property_source" ]] || \ + fail "SOS $stage $product_variant SYSTEM property data is missing or empty in target-files" + system_properties="$inspection_root/build.prop" + cp -- "$property_source" "$system_properties" || \ + fail "SOS $stage $product_variant SYSTEM property data could not be staged for inspection" + [[ -s "$system_properties" ]] || \ + fail "SOS $stage $product_variant staged SYSTEM property data is empty" + + grep -Fx 'ro.debuggable=0' "$system_properties" >/dev/null || \ + fail "SOS $stage $product_variant image weakened Lineage's intentional global debugging posture" + if [[ "$product_variant" == dev-credential ]]; then + grep -Fx 'ro.build.type=userdebug' "$system_properties" >/dev/null || \ + fail "SOS Core 1 development image is not the registered userdebug product" + fi +) diff --git a/tools/a33xctl b/tools/a33xctl index 77c5a52..30a7f67 100755 --- a/tools/a33xctl +++ b/tools/a33xctl @@ -9,17 +9,63 @@ LINEAGE_MANIFEST="https://github.com/LineageOS/android.git" LINEAGE_MANIFEST_REV="2a46cce5dff3a9a3ab98b3c25a7564084b34cd1f" LINEAGE_LOCAL_MANIFEST="$SOS_ROOT/aosp/manifests/a33x-lineage-23.0.xml" LINEAGE_PATCH_DIR="$SOS_ROOT/aosp/patches/a33x-lineage-23.0" +readonly -a LINEAGE_PATCH_PROJECTS=( + device/samsung/s5e8825-common + vendor/samsung/s5e8825-common + device/samsung/s5e8825-common + frameworks/base + device/samsung/s5e8825-common + device/samsung/s5e8825-common + device/samsung/s5e8825-common + device/samsung/s5e8825-common +) +readonly -a LINEAGE_PATCH_FILES=( + 0001-s5e8825-fix-epicoperator-symbol.patch + 0002-s5e8825-match-libsec-ril-protobuf-soname.patch + 0003-s5e8825-package-recovery-init.patch + 0004-frameworks-base-enforce-sos-core-install-policy.patch + 0005-s5e8825-select-no-zygote-for-sos-core1.patch + 0006-s5e8825-allow-sos-core-tsp-enable.patch + 0007-s5e8825-chown-tsp-enabled.patch + 0008-s5e8825-select-no-zygote-for-sos-core1-dev.patch +) +readonly -a LINEAGE_PATCH_SHA256S=( + a8dea6c8c01f3c8572f952b8455560c690e700626df0332bd391abc04b175c61 + 18517d33328c5ffdf101bf8e8b1f25d5383d96ee50cfde5463a6d036da543795 + 51557dbd9e58d1788b505cf87d267ea260c362f9311eef2d9c62d743e906b2d4 + 6a691c8cddca2f817cb90473f18444347450d2f030832a2f5c4b95c4dee685ce + b833ad51a7c61a1c85ad1dc93d70de416f574264c23502d2f97637edbbde152c + 94b33caf99600dbd28290b10408069834d0e203ae16150fa8f361114471d62ab + 61909d66dc7a16d57411fd5da9d7c6dd3cc53c039aba2d02df9fea74584da725 + 842d80ad1d2e18c2199f253f9d645cb1b92685f6633d1c2cc299278369b03c0a +) +readonly -a LINEAGE_PATCH_BASELINE_PROJECTS=( + device/samsung/s5e8825-common + vendor/samsung/s5e8825-common + frameworks/base +) +readonly -a LINEAGE_PATCH_BASELINE_REVISIONS=( + 33dd9c99978647a44aa22089db4830f95bb91fb8 + 4a2275bfabd9fcce764bcf773a7d1e236ff67346 + 155701ba6a05d0061dbab1b9f1bfec2040cf4158 +) LINEAGE_SOS_COMPAT0_DEVICE="sos_compat0_a33x" LINEAGE_SOS_COMPAT1_DEVICE="sos_compat_a33x" LINEAGE_SOS_CORE_SHADOW_DEVICE="sos_core_a33x" LINEAGE_SOS_CORE0B_DEVICE="sos_core0b_a33x" LINEAGE_SOS_CORE1_DEVICE="sos_core1_a33x" +LINEAGE_SOS_CORE1_DEV_DEVICE="sos_core1_dev_a33x" LINEAGE_SOS_OVERLAY_SOURCE="$SOS_ROOT/aosp/device/sos/a33x" LINEAGE_SOS_OVERLAY_DESTINATION="$LINEAGE_ROOT/device/sos/a33x" LINEAGE_JOBS="${SOS_LINEAGE_JOBS:-8}" LINEAGE_SYNC_JOBS="${SOS_LINEAGE_SYNC_JOBS:-8}" LINEAGE_CCACHE="${SOS_LINEAGE_CCACHE:-$HOME/dev/.cache/lineage-a33x-ccache}" LINEAGE_CCACHE_SIZE="${SOS_LINEAGE_CCACHE_SIZE:-50G}" +CORE_DEV_PROXY_DEVICE_PORT=37173 +CORE_DEV_PROXY_AUTHORITY=openrouter.ai:443 + +# shellcheck source=tools/a33x/core-system-properties.sh +source "$SOS_ROOT/tools/a33x/core-system-properties.sh" info() { printf '==> %s\n' "$*" @@ -53,6 +99,9 @@ check_agent_runner_contract() { 'decoded.provider === "openrouter" && decoded.model !== PINNED_OPENROUTER_MODEL' \ "$runner" >/dev/null || \ fail "shared Pi runner omitted exact OpenRouter request enforcement" + grep -F 'protocol_version: STDIO_PROTOCOL_VERSION' "$runner" >/dev/null && \ + grep -F 'terminal: "failed"' "$runner" >/dev/null || \ + fail "shared Pi runner omitted its bounded terminal protocol" info "shared Pi runner exact OpenRouter assignment and request guard passed" } @@ -68,6 +117,576 @@ check_core_runtime_model_contract() { info "Core runtime pinned model bytes and mismatch rejection passed" } +check_core_agent_hardening_source() { + [[ "$#" -eq 0 ]] || fail "usage: ./tools/a33xctl check-core-agent-hardening" + local contract="$SOS_ROOT/apps/experience/src/android_agent_contract.rs" + local agent="$SOS_ROOT/apps/experience/src/android/agent.rs" + local credential="$SOS_ROOT/apps/experience/src/core_credential.rs" + local child_fds="$SOS_ROOT/apps/experience/src/core_child_fds.rs" + local provider_fetch="$SOS_ROOT/services/sos-agent/src/provider-fetch.ts" + local host_policy="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_host.te" + local agent_policy="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_agent.te" + local policy_dir="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/system_ext" + local jitless_line runner_line + + jitless_line="$(grep -n -m1 '^ "--jitless",$' "$contract" | cut -d: -f1)" + runner_line="$(grep -n -m1 '^ CORE_CHILD_LAUNCH.runner_path,$' \ + "$contract" | cut -d: -f1)" + [[ -n "$jitless_line" && -n "$runner_line" && "$jitless_line" -lt "$runner_line" ]] || \ + fail "Core Node --jitless hardening must precede the fixed runner script" + grep -F '.args(CORE_NODE_ARGS)' "$agent" >/dev/null || \ + fail "Core child launch bypasses the inspected fixed argument contract" + grep -F '.stderr(Stdio::null())' "$agent" >/dev/null || \ + fail "Core child stderr is no longer discarded" + grep -F 'restrict_to_standard_fds(&mut command);' "$agent" >/dev/null && \ + grep -F 'libc::SYS_close_range' "$child_fds" >/dev/null && \ + grep -F 'const CLOSE_RANGE_CLOEXEC: u32 = 1 << 2;' "$child_fds" >/dev/null && \ + grep -F '3_u32' "$child_fds" >/dev/null && \ + grep -F 'u32::MAX' "$child_fds" >/dev/null && \ + grep -F 'fd_boundary=stdio_only' "$agent" >/dev/null || \ + fail "Core child does not fail closed after restricting inheritance to descriptors 0/1/2" + grep -Fx 'type sos_core_agent, domain, coredomain;' "$agent_policy" >/dev/null || \ + fail "Core Node child lacks its dedicated system_ext coredomain" + grep -Fx 'domain_auto_trans(sos_core_host, sos_node_exec, sos_core_agent)' \ + "$agent_policy" >/dev/null || \ + fail "Core Node execution does not transition out of the trusted UI host" + grep -Fx 'allow sos_core_host sos_core_agent:process sigkill;' \ + "$agent_policy" >/dev/null || \ + fail "Core host cannot enforce bounded child cleanup" + grep -Fx 'allow sos_core_agent sos_core_host:fd use;' "$agent_policy" >/dev/null && \ + grep -Fx 'allow sos_core_agent sos_core_host:fifo_file { getattr ioctl read write };' \ + "$agent_policy" >/dev/null && \ + grep -Fx 'allowxperm sos_core_agent sos_core_host:fifo_file ioctl { TCGETS };' \ + "$agent_policy" >/dev/null || \ + fail "Core Node child lacks its bounded inherited pipe contract" + grep -Fx 'allow sos_core_agent proc_meminfo:file { open read };' "$agent_policy" >/dev/null || \ + fail "Core Node startup lacks the narrow read-only meminfo policy" + grep -Fx 'unix_socket_connect(sos_core_agent, dnsproxyd, netd)' "$agent_policy" >/dev/null && \ + grep -Fx 'unix_socket_connect(sos_core_agent, fwmarkd, netd)' "$agent_policy" >/dev/null && \ + grep -Fx 'allow netd sos_core_agent:fd use;' "$agent_policy" >/dev/null && \ + grep -Fx 'allow netd sos_core_agent:tcp_socket { read write getattr setattr getopt setopt };' \ + "$agent_policy" >/dev/null && \ + grep -Fx 'allow sos_core_agent self:udp_socket create;' "$agent_policy" >/dev/null && \ + grep -Fx 'allow sos_core_agent self:tcp_socket create_stream_socket_perms;' \ + "$agent_policy" >/dev/null && \ + grep -Fx 'allow sos_core_agent sos_agent_https_port:tcp_socket name_connect;' \ + "$agent_policy" >/dev/null || \ + fail "Core agent lacks its constrained Android resolver/TLS path" + grep -Fx 'type sos_agent_https_port, port_type;' \ + "$policy_dir/private/port.te" >/dev/null && \ + grep -Fx 'portcon tcp 443 u:object_r:sos_agent_https_port:s0' \ + "$policy_dir/private/port_contexts" >/dev/null || \ + fail "Core agent TLS permission is not bound to TCP port 443" + ! grep -R -E '(^|[[:space:]])(get_prop|set_prop)\([^,]+,[[:space:]]*net_dns_prop\)|allow[[:space:]]+[^[:space:]]+[[:space:]]+net_dns_prop:' \ + "$policy_dir" >/dev/null || \ + fail "SOS policy must not bypass Android's obsolete net.dns property neverallow" + ! grep -E '^net_domain\(sos_core_host\)|^allow[[:space:]]+sos_core_host[[:space:]].*:(tcp_socket|udp_socket|rawip_socket|icmp_socket)' \ + "$host_policy" >/dev/null || \ + fail "trusted Core UI host must not inherit agent network authority" + ! grep -R -E '^net_domain\(sos_core_agent\)|^allow[[:space:]]+sos_core_agent[[:space:]].*:(rawip_socket|icmp_socket)' \ + "$policy_dir" >/dev/null || \ + fail "Core agent policy must not inherit broad net_domain socket authority" + ! grep -R -E '^allow[[:space:]]+sos_core_agent[[:space:]].*:udp_socket' \ + "$policy_dir" | grep -Fv 'allow sos_core_agent self:udp_socket create;' >/dev/null || \ + fail "Core agent UDP authority exceeds Bionic's socket-creation-only DNS proxy probe" + grep -Fx 'neverallow sos_core_agent self:udp_socket ~{ create };' "$agent_policy" >/dev/null && \ + grep -Fx 'neverallow sos_core_agent port_type:udp_socket *;' "$agent_policy" >/dev/null && \ + grep -Fx 'neverallow sos_core_agent node_type:{ icmp_socket rawip_socket udp_socket } *;' \ + "$agent_policy" >/dev/null && \ + grep -Fx 'neverallow sos_core_agent { port_type -sos_agent_https_port }:tcp_socket name_connect;' \ + "$agent_policy" >/dev/null || \ + fail "Core agent lacks structural rejection of direct UDP DNS and generic nodes/ports" + ! grep -R -E '^allow[[:space:]]+sos_core_agent[[:space:]]+.*:tcp_socket[[:space:]]+name_connect;' \ + "$policy_dir" | grep -Fv \ + 'allow sos_core_agent sos_agent_https_port:tcp_socket name_connect;' >/dev/null || \ + fail "Core agent may connect only to its TCP-443 label" + ! grep -R -E '(^|[[:space:]])(permissive|dontaudit)[[:space:]\(]' "$policy_dir" >/dev/null || \ + fail "SOS system_ext policy must not suppress or disable enforcement" + ! grep -R -E '^allow[[:space:]]+(sos_core_host|sos_core_agent).*execmem' \ + "$policy_dir" >/dev/null || \ + fail "Core host and agent policy must never grant execmem" + ! grep -F 'allow sos_core_host sos_node_exec:file execute_no_trans;' \ + "$host_policy" >/dev/null || \ + fail "Core Node may not inherit the trusted UI host domain" + for category in exit_failure signal empty_response invalid_response; do + grep -F "\"$category\"" "$contract" >/dev/null || \ + fail "Core child failure contract omitted category: $category" + done + grep -F 'pub const OPENROUTER_KEY_PREFIX: &str = "sk-or-v1-";' \ + "$credential" >/dev/null || \ + fail "Core credential ceremony omitted its fixed OpenRouter prefix helper" + grep -F 'suffix_count:' "$credential" >/dev/null || \ + fail "Core credential ceremony omitted visible suffix counting" + grep -F 'agent.state.errorMessage' \ + "$SOS_ROOT/services/sos-agent/src/stdio-runner.ts" >/dev/null || \ + fail "Pi terminal transport failure is not checked before tool sequence validation" + grep -F 'transport.failure ?? sanitizeRunnerFailure(undefined' \ + "$SOS_ROOT/services/sos-agent/src/stdio-runner.ts" >/dev/null || \ + fail "Pi terminal failure can still be classified from untrusted provider text" + grep -F 'await resolver("openrouter.ai")' \ + "$SOS_ROOT/services/sos-agent/src/stdio-runner.ts" >/dev/null || \ + fail "Core production DNS preflight is no longer fixed to OpenRouter" + grep -F 'fetch: options.fetch ?? nodeProviderFetch' \ + "$SOS_ROOT/services/sos-agent/src/runtime.ts" >/dev/null && \ + grep -F 'Readable.toWeb(response.body as Readable)' "$provider_fetch" >/dev/null && \ + grep -F 'fetchImplementation: typeof globalThis.fetch = nodeProviderFetch' \ + "$SOS_ROOT/services/sos-agent/src/stdio-runner.ts" >/dev/null || \ + fail "shared Pi transport is no longer compatible with Core's JITless Node boundary" + grep -F 'function safeErrorCodes(error: unknown)' \ + "$SOS_ROOT/services/sos-agent/src/stdio-runner.ts" >/dev/null && \ + ! grep -F 'candidate.message' \ + "$SOS_ROOT/services/sos-agent/src/stdio-runner.ts" >/dev/null || \ + fail "Core failure classification may consume untrusted error text" + for marker in \ + core_ui_attempt \ + attempt_received \ + dispatch_started \ + 'android_agent_effect_dispatch action=' \ + 'android_agent_request_accepted provider=' \ + 'android_agent_request_start provider=' \ + 'android_agent_child_launch_failure cause=' \ + 'android_agent_child_start pid=' \ + 'android_agent_child_request state=written' \ + 'android_agent_child_response_header protocol=2' \ + 'android_agent_child_exit code=' \ + 'android_agent_pi_response provider=' \ + 'android_agent_request_terminal stage=' \ + 'android_agent_ui_terminal status='; do + grep -R -F "$marker" \ + "$contract" "$agent" "$SOS_ROOT/apps/experience/src/android.rs" >/dev/null || \ + fail "Core request lifecycle omitted sanitized marker: $marker" + done + for category in \ + empty_input credential_missing busy model_policy dispatch_channel runtime_start \ + network_unavailable \ + dns_resolution dns_timeout dns_proxy_unavailable connect_timeout \ + connect_refused connect_reset network_unreachable tls_failure unknown; do + grep -F "\"$category\"" "$contract" >/dev/null || \ + fail "Core safe failure taxonomy omitted category: $category" + done + check_core_agent_policy + info "Core agent source hardening, safe failures, policy, and credential helper passed" +} + +check_core_agent_policy() { + local secilc="$LINEAGE_ROOT/out/host/linux-x86/bin/secilc" + local focused_cil="$SOS_ROOT/tests/fixtures/core-agent-policy.cil" + [[ -x "$secilc" && -f "$focused_cil" ]] || \ + fail "focused Core-agent policy test inputs are absent" + "$secilc" -m -M true -G -N -c 30 "$focused_cil" -o /dev/null -f /dev/null || \ + fail "focused Core-agent DNS/ioctl policy failed secilc" + ( + cd "$SOS_ROOT" + cargo test --locked -q -p core-agent-contract-test --lib + ) || fail "Core-agent inherited-FD boundary test failed" +} + +check_core_dev_credential_source() { + [[ "$#" -eq 0 ]] || fail "usage: ./tools/a33xctl check-core-dev-credential" + local cargo="$SOS_ROOT/apps/experience/Cargo.toml" + local endpoint="$SOS_ROOT/apps/experience/src/core_dev_credential.rs" + local android_host="$SOS_ROOT/apps/experience/src/android.rs" + local attempt_contract="$SOS_ROOT/apps/experience/src/android_agent_contract.rs" + local protocol="$SOS_ROOT/aosp/device/sos/a33x/core/dev_credential_protocol_v1.h" + local product_contract="$SOS_ROOT/apps/experience/src/core_dev_product.rs" + local client="$SOS_ROOT/aosp/device/sos/a33x/core/dev_credential_client.cpp" + local ordinary_product="$SOS_ROOT/aosp/device/sos/a33x/lineage_sos_core1_a33x.mk" + local dev_product="$SOS_ROOT/aosp/device/sos/a33x/lineage_sos_core1_dev_a33x.mk" + local android_bp="$SOS_ROOT/aosp/device/sos/a33x/Android.bp" + local product_common="$SOS_ROOT/aosp/device/sos/a33x/sos_a33x_common.mk" + local products="$SOS_ROOT/aosp/device/sos/a33x/AndroidProducts.mk" + local policy="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_host.te" + local client_policy="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/core_dev_private/sos_core_dev_credential.te" + local dev_policy_dir="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/core_dev_private" + local dev_agent_policy="$dev_policy_dir/sos_core_dev_agent.te" + local bridge="$SOS_ROOT/tools/core-dev-connect-bridge.py" + local file_types="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/core_dev_private/file.te" + local file_contexts="$SOS_ROOT/aosp/device/sos/a33x/sepolicy/core_dev_private/file_contexts" + local lineage_common="$LINEAGE_ROOT/vendor/lineage/config/common.mk" + local build_prop_generator="$LINEAGE_ROOT/build/soong/scripts/gen_build_prop.py" + local ctl="$SOS_ROOT/tools/a33xctl" + local core_dev_transport='"$adb_binary" -s "$serial" shell -T ' + core_dev_transport+='-- \' + local obsolete_core_dev_transport='exec''-out /system_ext/bin/sos-core-dev-credential' + local ordinary_runner_module dev_runner_module + + ordinary_runner_module="$(soong_module_block prebuilt_etc sos-agent-runner)" + dev_runner_module="$(soong_module_block prebuilt_etc sos-agent-runner-core-dev)" + + grep -Fx 'core-dev-credential = ["core-native"]' "$cargo" >/dev/null || \ + fail "Core development credential endpoint lacks its dedicated feature" + ! grep -E '^core-native = \[[^]]*core-dev-credential' "$cargo" >/dev/null || \ + fail "production Core feature unexpectedly includes development credentials" + grep -F 'ro.build.version.incremental' "$product_contract" >/dev/null && \ + grep -F '"ro.sos.build_variant"' "$product_contract" >/dev/null && \ + grep -F '"core1-dev-credential"' "$product_contract" >/dev/null && \ + grep -F '"ro.sos.dev_credential"' "$product_contract" >/dev/null && \ + grep -F '"ro.build.type"' "$product_contract" >/dev/null && \ + grep -F '"ro.debuggable"' "$product_contract" >/dev/null && \ + grep -F 'expected: "0"' "$product_contract" >/dev/null || \ + fail "Core development endpoint lacks its exact runtime product gates" + ! grep -F 'property_is("ro.debuggable", "1")' \ + "$endpoint" "$product_contract" >/dev/null || \ + fail "Core development endpoint still treats broad Android debugging as enablement" + ! grep -E 'android-base/properties|GetProperty|__system_property' "$client" >/dev/null || \ + fail "least-privilege Core development client unexpectedly reads properties" + grep -F 'strcmp(argv[1], "probe")' "$client" >/dev/null && \ + grep -F 'strcmp(argv[1], "status")' "$client" >/dev/null && \ + grep -F 'strcmp(argv[1], "agent-smoke")' "$client" >/dev/null && \ + grep -F 'core_dev_credential=READY' "$client" >/dev/null && \ + grep -F 'core_dev_credential=CONFIGURED' "$client" >/dev/null && \ + grep -F 'core_dev_credential=EMPTY' "$client" >/dev/null || \ + fail "Core development client lacks its secret-free handshake or state query" + grep -F '#include "dev_credential_protocol_v1.h"' "$client" >/dev/null && \ + grep -F '/core_dev_credential_protocol_v1.rs' "$endpoint" >/dev/null && \ + grep -F '#define SOS_CORE_DEV_V1_REQUEST_HEADER_BYTES 8' "$protocol" >/dev/null && \ + grep -F '// probe: 53 4f 53 4b 01 00 00 00' "$protocol" >/dev/null && \ + grep -F '// status: 53 4f 53 4b 01 03 00 00' "$protocol" >/dev/null || \ + fail "Core development client and endpoint lack the canonical v1 golden contract" + grep -F '// agent-smoke: 53 4f 53 4b 01 04 00 00' "$protocol" >/dev/null && \ + grep -F 'CORE_DEV_AGENT_SMOKE_PROMPT' \ + "$android_host" >/dev/null && \ + grep -F 'run_core_dev_client_status "$serial" agent-smoke SUBMITTED none' \ + "$ctl" >/dev/null || \ + fail "Core development smoke path lacks its fixed source prompt or canonical operation" + grep -F 'CoreDevSmokeAuthorization' "$android_host" >/dev/null && \ + grep -F 'CoreDevSmokeAuthorization' "$attempt_contract" >/dev/null && \ + grep -F 'consume_fixed_prompt(CORE_DEV_AGENT_SMOKE_PROMPT)' \ + "$android_host" >/dev/null && \ + grep -F 'AgentUiTransport::CoreDevFixedTunnel' "$attempt_contract" >/dev/null && \ + grep -F 'attempt.transport_ready(network_available)' "$android_host" >/dev/null && \ + ! grep -F 'CORE_DEV_AGENT_TUNNEL_ARMED' "$android_host" >/dev/null || \ + fail "Core-dev smoke lacks an authenticated single-use attempt transport snapshot" + grep -F 'CORE_DEV_OPENROUTER_PROXY = "http://127.0.0.1:37173"' \ + "$SOS_ROOT/services/sos-agent/src/core-dev-proxy.ts" >/dev/null && \ + grep -F 'url.hostname !== "openrouter.ai"' \ + "$SOS_ROOT/services/sos-agent/src/core-dev-proxy.ts" >/dev/null && \ + grep -F 'new HttpsProxyAgent(CORE_DEV_OPENROUTER_PROXY)' \ + "$SOS_ROOT/services/sos-agent/src/core-dev-proxy.ts" >/dev/null && \ + grep -F '{ agent, redirect: "error" }' \ + "$SOS_ROOT/services/sos-agent/src/core-dev-proxy.ts" >/dev/null && \ + grep -F 'node_path: "/system_ext/bin/sos-node-core-dev",' \ + "$attempt_contract" >/dev/null && \ + grep -F 'node_path: "/system_ext/bin/sos-node",' \ + "$attempt_contract" >/dev/null && \ + grep -F 'Command::new(CORE_CHILD_LAUNCH.node_path)' \ + "$SOS_ROOT/apps/experience/src/android/agent.rs" >/dev/null || \ + fail "Core-dev tunnel lacks its fixed proxy or distinct executable boundary" + grep -Fx 'LISTEN_HOST = "127.0.0.1"' "$bridge" >/dev/null && \ + grep -Fx 'ALLOWED_AUTHORITY = "openrouter.ai:443"' "$bridge" >/dev/null && \ + grep -Fx 'ALLOWED_HOST_HEADERS = frozenset((UPSTREAM_HOST, ALLOWED_AUTHORITY))' \ + "$bridge" >/dev/null && \ + grep -F 'if len(argv) != 2 or argv[0] != "--events" or not argv[1]:' \ + "$bridge" >/dev/null && \ + grep -F 'self.server.events.record("relay_started", direction=direction)' \ + "$bridge" >/dev/null || \ + fail "host CONNECT bridge lacks its loopback-only authority or content-free evidence contract" + grep -F 'SO_PEERCRED' "$endpoint" >/dev/null && grep -F 'SO_PEERSEC' "$endpoint" >/dev/null || \ + fail "Core development endpoint lacks uid and SELinux peer authentication" + grep -F 'const MAX_FRAME_BYTES:' "$endpoint" >/dev/null || \ + fail "Core development endpoint lacks a bounded wire frame" + grep -F 'memset_explicit(bytes.data(), 0, bytes.size())' "$client" >/dev/null || \ + fail "Core development client does not zeroize its bounded secret buffer" + ! grep -E '(^|[^[:alnum:]_])(fputs|fprintf|puts|printf)[[:space:]]*\(' \ + "$client" >/dev/null || \ + fail "Core development client uses buffered stdio on inherited ADB descriptors" + ! grep -E '(setprop|clipboard|/data/|/sdcard/|Authorization:|Bearer )' \ + "$endpoint" "$client" >/dev/null || \ + fail "Core development credential path references a forbidden secret transport" + grep -Fx 'PRODUCT_NAME := lineage_sos_core1_a33x' "$ordinary_product" >/dev/null && \ + grep -Fx ' sos-agent-runner' "$ordinary_product" >/dev/null && \ + grep -Fx ' ro.sos.build_variant=core1-ordinary' "$ordinary_product" >/dev/null || \ + fail "ordinary Core lacks its exact runner or immutable product identity" + ! grep -E 'sos-core-dev-credential|sos-node-core-dev|sos-agent-runner-core-dev|ro\.sos\.dev_credential' \ + "$ordinary_product" >/dev/null || \ + fail "ordinary Core product references development credential or tunnel packages" + grep -Fx 'PRODUCT_NAME := lineage_sos_core1_dev_a33x' "$dev_product" >/dev/null && \ + grep -Fx ' sos-core-dev-credential \' "$dev_product" >/dev/null && \ + grep -Fx ' sos-node-core-dev \' "$dev_product" >/dev/null && \ + grep -Fx ' sos-agent-runner-core-dev' "$dev_product" >/dev/null && \ + grep -Fx ' ro.sos.build_variant=core1-dev-credential \' "$dev_product" >/dev/null && \ + grep -Fx ' ro.sos.dev_credential=1' "$dev_product" >/dev/null && \ + grep -F 'ifneq ($(TARGET_BUILD_VARIANT),userdebug)' "$dev_product" >/dev/null || \ + fail "Core development product lacks exact package, identity, or build-type gates" + grep -F 'device/sos/a33x/sepolicy/core_dev_private' "$dev_product" >/dev/null || \ + fail "Core-dev product omitted its product-exclusive tunnel policy directory" + [[ "$(grep -Fc ' filename: "agent-runner.cjs",' "$android_bp")" -eq 1 && \ + "$(grep -Fc ' filename: "agent-runner-core-dev.cjs",' "$android_bp")" -eq 1 ]] && \ + grep -F ' src: "prebuilts/sos-agent/agent-runner.cjs",' \ + <<<"$ordinary_runner_module" >/dev/null && \ + grep -F ' filename: "agent-runner.cjs",' \ + <<<"$ordinary_runner_module" >/dev/null && \ + grep -F ' src: "prebuilts/sos-agent/agent-runner-core-dev.cjs",' \ + <<<"$dev_runner_module" >/dev/null && \ + grep -F ' filename: "agent-runner-core-dev.cjs",' \ + <<<"$dev_runner_module" >/dev/null && \ + ! grep -F 'overrides:' <<<"$ordinary_runner_module$dev_runner_module" >/dev/null || \ + fail "ordinary and Core-dev runner modules do not have unique Soong install identities" + ! grep -E '^[[:space:]]+sos-agent-runner(-core-dev)?' \ + "$product_common" >/dev/null || \ + fail "shared product composition must not select an ambiguous runner" + ! grep -R -E 'sos_core_dev_(agent|credential|proxy_port)|sos_node_core_dev_exec' \ + "$SOS_ROOT/aosp/device/sos/a33x/sepolicy/system_ext" >/dev/null || \ + fail "ordinary shared policy contains Core-dev credential or proxy authority" + local ordinary_runner_product + for ordinary_runner_product in \ + lineage_sos_compat0_a33x.mk lineage_sos_compat_a33x.mk \ + lineage_sos_core0b_a33x.mk lineage_sos_core1_a33x.mk \ + lineage_sos_core_a33x.mk; do + { grep -Fx ' sos-agent-runner \' \ + "$SOS_ROOT/aosp/device/sos/a33x/$ordinary_runner_product" >/dev/null || \ + grep -Fx ' sos-agent-runner' \ + "$SOS_ROOT/aosp/device/sos/a33x/$ordinary_runner_product" >/dev/null; } || \ + fail "ordinary product omitted its explicit runner: $ordinary_runner_product" + ! grep -E '^[[:space:]]+sos-agent-runner-core-dev' \ + "$SOS_ROOT/aosp/device/sos/a33x/$ordinary_runner_product" || \ + fail "ordinary product selects the Core-dev runner: $ordinary_runner_product" + done + grep -F 'runner_path: "/system_ext/etc/sos-agent/agent-runner.cjs",' \ + "$attempt_contract" >/dev/null && \ + grep -F 'runner_path: "/system_ext/etc/sos-agent/agent-runner-core-dev.cjs",' \ + "$attempt_contract" >/dev/null && \ + grep -F 'expected_domain: "sos_core_dev_agent",' \ + "$attempt_contract" >/dev/null || \ + fail "Core runtime lacks distinct ordinary and development runner paths" + grep -F '$(LOCAL_DIR)/lineage_sos_core1_dev_a33x.mk' "$products" >/dev/null && \ + grep -Fx ' lineage_sos_core1_dev_a33x-userdebug' "$products" >/dev/null || \ + fail "Core development product is not registered as a userdebug lunch target" + grep -F 'lineage_sos_core1_a33x lineage_sos_core1_dev_a33x' \ + "$SOS_ROOT/aosp/patches/a33x-lineage-23.0/0008-s5e8825-select-no-zygote-for-sos-core1-dev.patch" \ + >/dev/null || fail "Core-dev is absent from the no-Zygote product selection" + ! grep -R -F 'SOS_ENABLE_CORE_DEV_CREDENTIAL_BUILD' \ + "$SOS_ROOT/aosp/device/sos/a33x" >/dev/null || \ + fail "Core development packaging still depends on an ad hoc shell variable" + grep -Fx 'domain_auto_trans(shell, sos_core_dev_credential_exec, sos_core_dev_credential)' \ + "$client_policy" >/dev/null && \ + grep -Fx 'allow sos_core_dev_credential sos_core_host:unix_stream_socket connectto;' \ + "$client_policy" >/dev/null && \ + grep -Fx 'neverallow shell sos_core_host:unix_stream_socket connectto;' \ + "$policy" >/dev/null || \ + fail "Core development client lacks its exact domain transition and socket boundary" + grep -Fx 'domain_auto_trans(sos_core_host, sos_node_core_dev_exec, sos_core_dev_agent)' \ + "$dev_agent_policy" >/dev/null && \ + grep -Fx 'allow netd sos_core_dev_agent:fd use;' "$dev_agent_policy" >/dev/null && \ + grep -Fx 'allow netd sos_core_dev_agent:tcp_socket { read write getattr setattr getopt setopt };' \ + "$dev_agent_policy" >/dev/null && \ + grep -Fx 'allow sos_core_dev_agent sos_core_dev_proxy_port:tcp_socket name_connect;' \ + "$dev_agent_policy" >/dev/null && \ + grep -F 'neverallow sos_core_dev_agent' "$dev_agent_policy" >/dev/null && \ + grep -F '{ port_type -sos_agent_https_port -sos_core_dev_proxy_port }:tcp_socket name_connect;' \ + "$dev_agent_policy" >/dev/null || \ + fail "Core-dev Node domain lacks its fixed proxy-port confinement" + ! grep -F 'sos_core_dev_proxy_port' \ + "$SOS_ROOT/aosp/device/sos/a33x/sepolicy/system_ext/private/sos_core_agent.te" \ + >/dev/null || fail "ordinary Core agent gained the development proxy port" + local ordinary_runner="$SOS_ROOT/services/sos-agent/dist/agent-runner.cjs" + local dev_runner="$SOS_ROOT/services/sos-agent/dist/agent-runner-core-dev.cjs" + [[ -f "$ordinary_runner" && -f "$dev_runner" ]] || \ + fail "ordinary and Core-dev runner bundles must both be built" + ! grep -aF 'http://127.0.0.1:37173' "$ordinary_runner" >/dev/null && \ + ! grep -aF 'HttpsProxyAgent' "$ordinary_runner" >/dev/null || \ + fail "ordinary runner contains development CONNECT proxy code" + grep -aF 'http://127.0.0.1:37173' "$dev_runner" >/dev/null && \ + grep -aF 'HttpsProxyAgent' "$dev_runner" >/dev/null || \ + fail "Core-dev runner omitted its fixed CONNECT proxy implementation" + grep -aF 'nodeProviderFetch' "$ordinary_runner" >/dev/null && \ + grep -aF 'nodeProviderFetch' "$dev_runner" >/dev/null && \ + ! grep -aF 'WebAssembly' "$ordinary_runner" >/dev/null && \ + ! grep -aF 'WebAssembly' "$dev_runner" >/dev/null || \ + fail "shared Pi runner is not JITless-compatible without a Wasm HTTP parser" + check_agent_runner_contract "$dev_runner" + grep -Fx 'type sos_node_core_dev_exec, system_file_type, exec_type, file_type;' \ + "$dev_policy_dir/file.te" >/dev/null && \ + grep -Fx '/system_ext/bin/sos-node-core-dev u:object_r:sos_node_core_dev_exec:s0' \ + "$dev_policy_dir/file_contexts" >/dev/null && \ + grep -Fx 'type sos_core_dev_proxy_port, port_type;' \ + "$dev_policy_dir/port.te" >/dev/null && \ + grep -Fx 'portcon tcp 37173 u:object_r:sos_core_dev_proxy_port:s0' \ + "$dev_policy_dir/port_contexts" >/dev/null || \ + fail "Core-dev product policy lacks its fixed executable or proxy-port labels" + ! grep -E '^allow shell sos_core_host:|^allow sos_core_dev_credential .*(tcp_socket|udp_socket|property_service|logd|logdr|logdw|service_manager|binder|capability)' \ + "$policy" "$client_policy" >/dev/null || \ + fail "Core development policy grants shell or client authority outside the dedicated boundary" + ! grep -E '^allow sos_core_dev_credential adbd:unix_stream_socket .*getattr' \ + "$client_policy" >/dev/null || \ + fail "Core development client must not gain getattr on the inherited ADB socket" + local unexpected_client_allow + unexpected_client_allow="$( + grep -E '^allow sos_core_dev_credential ' "$client_policy" | grep -Ev \ + '^allow sos_core_dev_credential (shell:fd use;|adbd:fd use;|adbd:unix_stream_socket \{ read write \};|shell:fifo_file \{ getattr read write \};|self:unix_stream_socket create_socket_perms;|sos_core_host:unix_stream_socket connectto;)$' || true + )" + [[ -z "$unexpected_client_allow" ]] || \ + fail "Core development client policy contains an unreviewed allow" + ! grep -E '(^|[^[:alnum:]_])(net_domain|get_prop|set_prop|read_logd|write_logd|r_file_perms|rw_file_perms|r_dir_perms)([^[:alnum:]_]|$)' \ + "$client_policy" >/dev/null || \ + fail "Core development client gained network, property, log, or filesystem authority" + grep -Fx 'type sos_core_dev_credential_exec, system_file_type, exec_type, file_type;' \ + "$file_types" >/dev/null && \ + grep -Fx '/system_ext/bin/sos-core-dev-credential u:object_r:sos_core_dev_credential_exec:s0' \ + "$file_contexts" >/dev/null || \ + fail "Core development client lacks its dedicated immutable image label" + [[ -f "$lineage_common" && -f "$build_prop_generator" ]] || \ + fail "Lineage property-generation sources are absent" + grep -Fx 'PRODUCT_NOT_DEBUGGABLE_IN_USERDEBUG := true' "$lineage_common" >/dev/null && \ + grep -F 'if config["ProductNotDebuggableInUserdebug"]:' \ + "$build_prop_generator" >/dev/null && \ + grep -F 'props.append("ro.debuggable=0")' "$build_prop_generator" >/dev/null || \ + fail "Lineage no longer intentionally hardens userdebug with ro.debuggable=0" + [[ "$(grep -Fc "$core_dev_transport" "$ctl")" -eq 2 ]] || \ + fail "Core-dev host commands do not share the exact no-PTY ADB shell transport" + ! grep -F "$obsolete_core_dev_transport" "$ctl" >/dev/null || \ + fail "Core-dev credential commands still use the output-dropping ADB transport" + grep -F 'read -r -s -n 513 secret' "$ctl" >/dev/null && \ + grep -F 'run_core_dev_client_status "$serial" set SET inherit' "$ctl" >/dev/null && \ + grep -F 'require_core_dev_state "$serial" CONFIGURED' "$ctl" >/dev/null && \ + grep -F 'require_core_dev_state "$serial" EMPTY' "$ctl" >/dev/null || \ + fail "host mutations do not bound hidden input and verify the resulting state" + + check_core_dev_product_make_expansion "$ordinary_product" "$dev_product" + check_core_dev_credential_policy "$client_policy" + check_core_dev_credential_android_compile "$client" + ( + cd "$SOS_ROOT" + cargo test --locked -q -p core-dev-credential-protocol-test --lib -- --test-threads=1 + ) || fail "production C++ and Rust Core-dev protocol integration failed" + info "Core development credential handshake, cross-language v1 protocol, dedicated domain, Android compile, peer checks, and production exclusion passed" +} + +check_core_dev_credential_policy() { + [[ "$#" -eq 1 ]] || fail "internal usage: check_core_dev_credential_policy POLICY" + local policy="$1" macros="$LINEAGE_ROOT/system/sepolicy/public/te_macros" + local m4_binary="$LINEAGE_ROOT/prebuilts/build-tools/linux-x86/bin/m4" + local secilc="$LINEAGE_ROOT/out/host/linux-x86/bin/secilc" + local temporary user_expanded dev_expanded focused_cil + [[ -f "$macros" && -x "$m4_binary" && -x "$secilc" ]] || \ + fail "expanded policy test inputs are absent" + temporary="$(mktemp -d /tmp/sos-core-dev-policy.XXXXXX)" + trap 'rm -rf -- "$temporary"' EXIT + user_expanded="$temporary/user.te" + dev_expanded="$temporary/userdebug.te" + "$m4_binary" -D target_build_variant=user "$macros" "$policy" >"$user_expanded" + "$m4_binary" -D target_build_variant=userdebug "$macros" "$policy" >"$dev_expanded" + ! grep -F 'type_transition shell sos_core_dev_credential_exec:process sos_core_dev_credential;' \ + "$user_expanded" >/dev/null || fail "production policy contains the dev-client transition" + grep -F 'type_transition shell sos_core_dev_credential_exec:process sos_core_dev_credential;' \ + "$dev_expanded" >/dev/null || fail "userdebug policy omitted the dev-client transition" + grep -F 'allow sos_core_dev_credential sos_core_dev_credential_exec:file { entrypoint open read execute getattr map };' \ + "$dev_expanded" >/dev/null || fail "dev-client transition omitted the exact entrypoint" + + focused_cil="$temporary/policy.cil" + sed -n '/^;; BEGIN FOCUSED CIL$/,/^;; END FOCUSED CIL$/p' \ + "$SOS_ROOT/tests/fixtures/core-dev-credential-policy.cil" >"$focused_cil" + "$secilc" -m -M true -G -N -c 30 "$focused_cil" -o /dev/null -f /dev/null || \ + fail "focused expanded Core-dev CIL failed secilc" + rm -rf -- "$temporary" + trap - EXIT +} + +check_core_dev_product_make_expansion() { + [[ "$#" -eq 2 ]] || \ + fail "internal usage: check_core_dev_product_make_expansion ORDINARY DEV" + local ordinary_product="$1" dev_product="$2" ordinary_expansion dev_expansion + require_command make + + ordinary_expansion="$( + make -s -f "$ordinary_product" 'inherit-product=' TARGET_BUILD_VARIANT=userdebug \ + --eval 'sos-print:;@printf "%s|%s|%s\n" "$(PRODUCT_NAME)" "$(PRODUCT_PACKAGES)" "$(PRODUCT_SYSTEM_EXT_PROPERTIES)"' \ + sos-print + )" || fail "ordinary Core product make expansion failed" + dev_expansion="$( + make -s -f "$dev_product" 'inherit-product=' TARGET_BUILD_VARIANT=userdebug \ + --eval 'sos-print:;@printf "%s|%s|%s\n" "$(PRODUCT_NAME)" "$(PRODUCT_PACKAGES)" "$(PRODUCT_SYSTEM_EXT_PROPERTIES)"' \ + sos-print + )" || fail "Core-dev product make expansion failed" + + [[ "$ordinary_expansion" == \ + 'lineage_sos_core1_a33x|sos-agent-runner|ro.sos.build_variant=core1-ordinary' ]] || \ + fail "ordinary Core direct product expansion is not development-free" + [[ "$dev_expansion" == \ + 'lineage_sos_core1_dev_a33x|sos-core-dev-credential sos-node-core-dev sos-agent-runner-core-dev|ro.sos.build_variant=core1-dev-credential ro.sos.dev_credential=1' ]] || \ + fail "Core-dev direct product expansion omitted its exact package or properties" + if make -s -f "$dev_product" 'inherit-product=' TARGET_BUILD_VARIANT=user \ + --eval 'sos-print:;@:' sos-print >/dev/null 2>&1; then + fail "Core-dev product unexpectedly permits a production user build" + fi + if make -s -f "$dev_product" 'inherit-product=' TARGET_BUILD_VARIANT=eng \ + --eval 'sos-print:;@:' sos-print >/dev/null 2>&1; then + fail "Core-dev product unexpectedly permits an unregistered eng build" + fi +} + +check_core_dev_credential_android_compile() { + [[ "$#" -eq 1 ]] || fail "internal usage: check_core_dev_credential_android_compile CLIENT" + local client="$1" + local clang_config="$LINEAGE_ROOT/build/soong/cc/config/global.go" + local bionic_symbols="$LINEAGE_ROOT/bionic/libc/libc.map.txt" + local clang_version clang_root + local -a required_paths + + [[ -f "$clang_config" ]] || fail "LineageOS source is not synced: $clang_config" + clang_version="${LLVM_PREBUILTS_VERSION:-$( + awk -F'"' '/ClangDefaultVersion[[:space:]]+=/ { print $2; exit }' "$clang_config" + )}" + [[ "$clang_version" == clang-* && "$clang_version" != */* ]] || \ + fail "could not resolve the pinned Android clang version" + clang_root="$LINEAGE_ROOT/prebuilts/clang/host/linux-x86/$clang_version" + required_paths=( + "$clang_root/bin/clang++" + "$clang_root/android_libc++/platform/aarch64/include/c++/v1" + "$clang_root/include/c++/v1" + "$LINEAGE_ROOT/bionic/libc/include" + "$bionic_symbols" + "$LINEAGE_ROOT/bionic/libc/kernel/uapi/asm-arm64" + "$LINEAGE_ROOT/bionic/libc/kernel/uapi" + "$LINEAGE_ROOT/bionic/libc/kernel/android/uapi" + "$LINEAGE_ROOT/bionic/libc/system_properties/include" + "$LINEAGE_ROOT/system/libbase/include" + "$LINEAGE_ROOT/external/fmtlib/include" + ) + local path + for path in "${required_paths[@]}"; do + [[ -e "$path" ]] || fail "Android client compile input is missing: $path" + done + grep -Fx ' memset_explicit;' "$bionic_symbols" >/dev/null || \ + fail "Bionic does not export the required explicit-erasure API" + + "$clang_root/bin/clang++" \ + -c \ + -nostdlibinc \ + -O2 \ + -target aarch64-linux-android10000 \ + -march=armv8.2-a+dotprod \ + -mcpu=cortex-a55 \ + -DANDROID \ + -DNDEBUG \ + -D_FORTIFY_SOURCE=2 \ + -D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ \ + -D__LIBC_API__=10000 \ + -D__LIBM_API__=10000 \ + -D__LIBDL_API__=10000 \ + -std=gnu++20 \ + -fno-exceptions \ + -fno-rtti \ + -fno-strict-aliasing \ + -fstack-protector-strong \ + -fPIE \ + -Wall \ + -Werror \ + -Wextra \ + -Wunguarded-availability \ + -I"$LINEAGE_ROOT/system/libbase/include" \ + -I"$LINEAGE_ROOT/external/fmtlib/include" \ + -I"$clang_root/android_libc++/platform/aarch64/include/c++/v1" \ + -I"$clang_root/include/c++/v1" \ + -I"$LINEAGE_ROOT/bionic/libc/system_properties/include" \ + -isystem "$LINEAGE_ROOT/bionic/libc/include" \ + -isystem "$LINEAGE_ROOT/bionic/libc/kernel/uapi/asm-arm64" \ + -isystem "$LINEAGE_ROOT/bionic/libc/kernel/uapi" \ + -isystem "$LINEAGE_ROOT/bionic/libc/kernel/android/uapi" \ + -o /dev/null \ + "$client" || fail "Core development client failed its Android/Bionic target compile" +} + soong_module_block() { [[ "$#" -eq 2 ]] || fail "internal usage: soong_module_block " local module_type="$1" module_name="$2" @@ -126,7 +745,7 @@ check_product_graph() { fail "Compat 1 must not select the Core UI-removal marker" for profile_file in \ "$device_dir/lineage_sos_core0b_a33x.mk" \ - "$device_dir/lineage_sos_core1_a33x.mk"; do + "$device_dir/sos_core1_common.mk"; do grep -F ' sos-ui-removal-marker' "$profile_file" >/dev/null || \ fail "$(basename "$profile_file") does not select the Core UI-removal marker" ! grep -F ' sos-compat-ui-removal-marker' "$profile_file" >/dev/null || \ @@ -248,43 +867,143 @@ hydrate_source_lfs() { info "ARM64 WebView Git-LFS gate passed" } -apply_source_patches() { - [[ "$#" -eq 0 ]] || fail "usage: ./tools/a33xctl apply-patches" - local -a projects=( - device/samsung/s5e8825-common - vendor/samsung/s5e8825-common - device/samsung/s5e8825-common - frameworks/base - device/samsung/s5e8825-common - device/samsung/s5e8825-common - device/samsung/s5e8825-common - ) - local -a patches=( - 0001-s5e8825-fix-epicoperator-symbol.patch - 0002-s5e8825-match-libsec-ril-protobuf-soname.patch - 0003-s5e8825-package-recovery-init.patch - 0004-frameworks-base-enforce-sos-core-install-policy.patch - 0005-s5e8825-select-no-zygote-for-sos-core1.patch - 0006-s5e8825-allow-sos-core-tsp-enable.patch - 0007-s5e8825-chown-tsp-enabled.patch +declare -A SOURCE_PATCH_STATES=() +SOURCE_PATCH_STATE= + +source_patches_for_project() { + [[ "$#" -eq 1 ]] || fail "internal usage: source_patches_for_project PROJECT" + local relative_project="$1" index + for index in "${!LINEAGE_PATCH_FILES[@]}"; do + if [[ "${LINEAGE_PATCH_PROJECTS[$index]}" == "$relative_project" ]]; then + printf '%s\n' "$LINEAGE_PATCH_DIR/${LINEAGE_PATCH_FILES[$index]}" + fi + done +} + +inspect_source_patch_project() { + [[ "$#" -eq 2 ]] || \ + fail "internal usage: inspect_source_patch_project TEMP_ROOT PROJECT" + local temp_root="$1" relative_project="$2" + local project="$LINEAGE_ROOT/$relative_project" + local index_file="$temp_root/${relative_project//\//_}.index" + local baseline_file="$temp_root/baseline" expected_file="$temp_root/expected" + local patch path + local baseline_matches=1 expected_matches=1 + local -a patches=() touched_paths=() + + [[ -d "$project/.git" || -f "$project/.git" ]] || \ + fail "source checkout missing: $relative_project" + mapfile -t patches < <(source_patches_for_project "$relative_project") + [[ "${#patches[@]}" -gt 0 ]] || \ + fail "source patch project has no registered patches: $relative_project" + + GIT_INDEX_FILE="$index_file" git -C "$project" read-tree HEAD + for patch in "${patches[@]}"; do + if ! GIT_INDEX_FILE="$index_file" git -C "$project" \ + apply --cached --check "$patch"; then + fail "ordered source patch series does not apply to the pinned baseline: $patch" + fi + GIT_INDEX_FILE="$index_file" git -C "$project" apply --cached "$patch" + done + + mapfile -t touched_paths < <( + for patch in "${patches[@]}"; do + git -C "$project" apply --numstat "$patch" + done | cut -f3 | LC_ALL=C sort -u ) - local index project patch - for index in "${!patches[@]}"; do - project="$LINEAGE_ROOT/${projects[$index]}" - patch="$LINEAGE_PATCH_DIR/${patches[$index]}" - [[ -d "$project/.git" || -f "$project/.git" ]] || \ - fail "source checkout missing: ${projects[$index]}" + [[ "${#touched_paths[@]}" -gt 0 ]] || \ + fail "source patch series touches no files: $relative_project" + for path in "${touched_paths[@]}"; do + git -C "$project" show "HEAD:$path" >"$baseline_file" || \ + fail "source patch baseline path is not a regular tracked file: $relative_project/$path" + GIT_INDEX_FILE="$index_file" git -C "$project" show ":$path" >"$expected_file" || \ + fail "source patch result path is not a regular tracked file: $relative_project/$path" + cmp -s "$project/$path" "$baseline_file" || baseline_matches=0 + cmp -s "$project/$path" "$expected_file" || expected_matches=0 + done + + if [[ "$expected_matches" == 1 ]]; then + SOURCE_PATCH_STATE=applied + elif [[ "$baseline_matches" == 1 ]]; then + SOURCE_PATCH_STATE=baseline + else + fail "source patch project is neither pinned baseline nor complete ordered result: $relative_project" + fi +} + +preflight_source_patch_series() { + [[ "$#" -eq 0 ]] || fail "internal usage: preflight_source_patch_series" + local temp_root index patch actual_sha relative_project actual_revision + temp_root="$(mktemp -d /tmp/sos-a33x-patch-series.XXXXXX)" + + [[ "${#LINEAGE_PATCH_PROJECTS[@]}" == "${#LINEAGE_PATCH_FILES[@]}" && + "${#LINEAGE_PATCH_FILES[@]}" == "${#LINEAGE_PATCH_SHA256S[@]}" ]] || \ + fail "source patch series metadata lengths differ" + [[ "${#LINEAGE_PATCH_BASELINE_PROJECTS[@]}" == \ + "${#LINEAGE_PATCH_BASELINE_REVISIONS[@]}" ]] || \ + fail "source patch baseline metadata lengths differ" + + for index in "${!LINEAGE_PATCH_FILES[@]}"; do + patch="$LINEAGE_PATCH_DIR/${LINEAGE_PATCH_FILES[$index]}" [[ -f "$patch" ]] || fail "required source patch missing: $patch" + actual_sha="$(sha256sum "$patch" | cut -d' ' -f1)" + [[ "$actual_sha" == "${LINEAGE_PATCH_SHA256S[$index]}" ]] || \ + fail "source patch provenance hash changed: $patch" + done + for index in "${!LINEAGE_PATCH_BASELINE_PROJECTS[@]}"; do + relative_project="${LINEAGE_PATCH_BASELINE_PROJECTS[$index]}" + [[ -d "$LINEAGE_ROOT/$relative_project/.git" || \ + -f "$LINEAGE_ROOT/$relative_project/.git" ]] || \ + fail "source checkout missing: $relative_project" + actual_revision="$(git -C "$LINEAGE_ROOT/$relative_project" rev-parse HEAD)" + [[ "$actual_revision" == "${LINEAGE_PATCH_BASELINE_REVISIONS[$index]}" ]] || \ + fail "source patch baseline revision changed for $relative_project: $actual_revision" + inspect_source_patch_project "$temp_root" "$relative_project" + SOURCE_PATCH_STATES["$relative_project"]="$SOURCE_PATCH_STATE" + info "source patch project preflight: $relative_project ($SOURCE_PATCH_STATE)" + done - if git -C "$project" apply --reverse --check "$patch" 2>/dev/null; then - info "source patch already applied: $(basename "$patch")" - elif git -C "$project" apply --check "$patch"; then - git -C "$project" apply "$patch" + rm -rf -- "$temp_root" +} + +check_source_patch_series() { + [[ "$#" -eq 0 ]] || fail "usage: ./tools/a33xctl check-patch-series" + local index + preflight_source_patch_series + for index in "${!LINEAGE_PATCH_FILES[@]}"; do + printf 'patch=%02d\tproject=%s\tsha256=%s\tfile=%s\n' \ + "$((index + 1))" "${LINEAGE_PATCH_PROJECTS[$index]}" \ + "${LINEAGE_PATCH_SHA256S[$index]}" "${LINEAGE_PATCH_FILES[$index]}" + done + info "complete ordered source patch series and bootstrap state passed" +} + +apply_source_patches() { + [[ "$#" -eq 0 ]] || fail "usage: ./tools/a33xctl apply-patches" + local index relative_project patch + preflight_source_patch_series + + for relative_project in "${LINEAGE_PATCH_BASELINE_PROJECTS[@]}"; do + if [[ "${SOURCE_PATCH_STATES[$relative_project]}" == applied ]]; then + info "source patch project already complete: $relative_project" + fi + done + for index in "${!LINEAGE_PATCH_FILES[@]}"; do + relative_project="${LINEAGE_PATCH_PROJECTS[$index]}" + if [[ "${SOURCE_PATCH_STATES[$relative_project]}" == baseline ]]; then + patch="$LINEAGE_PATCH_DIR/${LINEAGE_PATCH_FILES[$index]}" + git -C "$LINEAGE_ROOT/$relative_project" apply --check "$patch" + git -C "$LINEAGE_ROOT/$relative_project" apply "$patch" info "applied source patch: $(basename "$patch")" - else - fail "source patch cannot be applied cleanly: $patch" fi done + + preflight_source_patch_series + for relative_project in "${LINEAGE_PATCH_BASELINE_PROJECTS[@]}"; do + [[ "${SOURCE_PATCH_STATES[$relative_project]}" == applied ]] || \ + fail "source patch project did not reach the complete ordered result: $relative_project" + done + info "complete ordered source patch series applied" } stage_sos() { @@ -295,6 +1014,7 @@ stage_sos() { local node="$SOS_ROOT/artifacts/sos-node-android-arm64" local node_cxx="$SOS_ROOT/artifacts/sos-node-libc++_shared.so" local agent_runner="$SOS_ROOT/services/sos-agent/dist/agent-runner.cjs" + local core_dev_agent_runner="$SOS_ROOT/services/sos-agent/dist/agent-runner-core-dev.cjs" local agent_api="$SOS_ROOT/docs/experience-api.md" local agent_example_primary="$SOS_ROOT/experiences/default.luau" local agent_example_secondary="$SOS_ROOT/experiences/timeflow.luau" @@ -308,6 +1028,8 @@ stage_sos() { [[ -x "$node" ]] || fail "Android ARM64 Node missing; run ./tools/build-android-node" [[ -f "$node_cxx" ]] || fail "Android Node libc++ runtime missing; run ./tools/build-android-node" [[ -f "$agent_runner" ]] || fail "shared Pi runner missing; run npm run build in services/sos-agent" + [[ -f "$core_dev_agent_runner" ]] || \ + fail "Core-dev Pi runner missing; run npm run build in services/sos-agent" [[ -f "$agent_api" && -f "$agent_example_primary" && -f "$agent_example_secondary" ]] || \ fail "Android Pi prompt documents are missing" [[ -f "$bootstrap" ]] || fail "SOS bootstrap experience missing" @@ -346,6 +1068,8 @@ stage_sos() { rm -f -- "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/android-runner.cjs" install -m 0644 "$agent_runner" \ "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/agent-runner.cjs" + install -m 0644 "$core_dev_agent_runner" \ + "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/agent-runner-core-dev.cjs" install -m 0644 "$agent_api" \ "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/experience-api.md" install -m 0644 "$agent_example_primary" \ @@ -361,6 +1085,7 @@ stage_sos() { "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/arm64/sos-node" \ "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/arm64/libc++_shared.so" \ "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/agent-runner.cjs" \ + "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/agent-runner-core-dev.cjs" \ "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/experience-api.md" \ "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/example-primary.luau" \ "$LINEAGE_SOS_OVERLAY_DESTINATION/prebuilts/sos-agent/example-secondary.luau" \ @@ -391,9 +1116,9 @@ build_rom() { build_sos_profile() { [[ "$#" -ge 1 && "$#" -le 2 ]] || \ - fail "internal usage: build_sos_profile [provider-probe]" + fail "internal usage: build_sos_profile [provider-probe|dev-credential]" local profile="$1" runtime_variant="${2:-release}" - local target_device staged_input_sha apk_flags + local target_device staged_input_sha apk_flags build_identity local core_runtime_features=core-native case "$profile" in compat0) @@ -415,6 +1140,7 @@ build_sos_profile() { core1) target_device="$LINEAGE_SOS_CORE1_DEVICE" apk_flags="--home" + build_identity=core1 ;; *) fail "unsupported SOS profile: $profile" ;; esac @@ -423,6 +1149,13 @@ build_sos_profile() { fail "the non-shipping provider probe is restricted to Core 1" core_runtime_features=core-native,core-provider-acceptance info "enabling the non-shipping Core 1 provider acceptance probe" + elif [[ "$runtime_variant" == dev-credential ]]; then + [[ "$profile" == core1 ]] || \ + fail "development credential injection is restricted to Core 1" + core_runtime_features=core-native,core-dev-credential + target_device="$LINEAGE_SOS_CORE1_DEV_DEVICE" + build_identity=core1dev + info "enabling the explicit non-production Core 1 credential endpoint" elif [[ "$runtime_variant" != release ]]; then fail "unsupported SOS runtime variant: $runtime_variant" fi @@ -473,7 +1206,7 @@ build_sos_profile() { find "$LINEAGE_PATCH_DIR" -maxdepth 1 -type f -name '*.patch' -print0 | \ sort -z | xargs -0 sha256sum } | sha256sum | cut -d' ' -f1)" - build_number="sos.${profile}.${sos_revision}.${staged_input_sha:0:12}" + build_number="sos.${build_identity:-$profile}.${sos_revision}.${staged_input_sha:0:12}" mkdir -p "$LINEAGE_CCACHE" # These images embed BUILD_NUMBER in AVB descriptors but Ninja does not track # that environment value as an input. Invalidate only the generated @@ -529,6 +1262,11 @@ build_core1() { build_sos_profile core1 } +build_core1_dev() { + [[ "$#" -eq 0 ]] || fail "usage: ./tools/a33xctl build-core1-dev" + build_sos_profile core1 dev-credential +} + build_core1_provider_probe() { [[ "$#" -eq 0 ]] || \ fail "usage: ./tools/a33xctl build-core1-provider-probe" @@ -598,6 +1336,10 @@ inspect_rom() { package_suffix=sos_core1_a33x build_command=build-core1 ;; + lineage_sos_core1_dev_a33x) + package_suffix=sos_core1_dev_a33x + build_command=build-core1-dev + ;; *) fail "unsupported a33x target product: $target_product" ;; esac package="$(find "$product_out" -maxdepth 1 -type f \ @@ -716,6 +1458,7 @@ inspect_compat_profile() { local bootstrap="$target_files/SYSTEM_EXT/etc/sos/default.luau" local init_rc="$target_files/SYSTEM_EXT/etc/init/sos-authority.rc" local properties="$target_files/SYSTEM_EXT/etc/build.prop" + local system_properties="$target_files/SYSTEM/build.prop" local seapp_contexts="$target_files/SYSTEM_EXT/etc/selinux/system_ext_seapp_contexts" local file_contexts="$target_files/SYSTEM_EXT/etc/selinux/system_ext_file_contexts" local privapp_permissions="$target_files/SYSTEM_EXT/etc/permissions/privapp-permissions-sos.xml" @@ -1008,10 +1751,11 @@ inspect_compat_profile() { done for agent_marker in \ 'android_agent_effect_dispatch action=' \ - 'android_agent_thread_start provider=' \ + 'android_agent_request_accepted provider=' \ 'android_agent_request_start provider=' \ 'android_agent_pi_response provider=' \ - 'android_agent_failure stage='; do + 'android_agent_request_terminal stage=' \ + 'android_agent_ui_terminal status='; do strings "$host_runtime" | grep -F "$agent_marker" >/dev/null || \ fail "native Compat runtime omitted agent lifecycle marker: $agent_marker" done @@ -1223,8 +1967,13 @@ inspect_core() { } inspect_core_stage() { - [[ "$#" -eq 1 ]] || fail "internal usage: inspect_core_stage " - local stage="$1" target_product expected_stage expected_owner expected_lifecycle bridge no_zygote + [[ "$#" -ge 1 && "$#" -le 2 ]] || \ + fail "internal usage: inspect_core_stage [ordinary|dev-credential]" + local stage="$1" product_variant="${2:-ordinary}" + local target_product expected_stage expected_owner expected_lifecycle bridge no_zygote + local expected_revision_identity expected_build_variant + check_core_agent_hardening_source + check_core_dev_credential_source case "$stage" in core0b) target_product=lineage_sos_core0b_a33x @@ -1233,52 +1982,86 @@ inspect_core_stage() { expected_lifecycle=legacy bridge=present no_zygote=false + expected_revision_identity=core0b ;; core1) - target_product=lineage_sos_core1_a33x expected_stage=1 expected_owner=native-sos-no-zygote expected_lifecycle=active bridge=absent no_zygote=true + case "$product_variant" in + ordinary) + target_product=lineage_sos_core1_a33x + expected_revision_identity=core1 + expected_build_variant=core1-ordinary + ;; + dev-credential) + target_product=lineage_sos_core1_dev_a33x + expected_revision_identity=core1dev + expected_build_variant=core1-dev-credential + ;; + *) fail "unsupported Core 1 product variant: $product_variant" ;; + esac ;; *) fail "unsupported SOS Core stage: $stage" ;; esac inspect_rom "$target_product" local target_files="$LINEAGE_ROOT/out/target/product/a33x/obj/PACKAGING/target_files_intermediates/${target_product}-target_files" + if [[ "$stage" == core1 ]]; then + inspect_core_system_properties "$target_files" "$stage" "$product_variant" + fi local host="$target_files/SYSTEM_EXT/bin/sos-core-host" local host_runtime="$target_files/SYSTEM_EXT/lib64/libsos_core_experience.so" local host_init_rc="$target_files/SYSTEM_EXT/etc/init/sos-core-host.rc" local properties="$target_files/SYSTEM_EXT/etc/build.prop" local authority="$target_files/SYSTEM_EXT/bin/sos-authority" local node="$target_files/SYSTEM_EXT/bin/sos-node" - local agent_runner="$target_files/SYSTEM_EXT/etc/sos-agent/agent-runner.cjs" local agent_api="$target_files/SYSTEM_EXT/etc/sos-agent/experience-api.md" local agent_example_primary="$target_files/SYSTEM_EXT/etc/sos-agent/example-primary.luau" local agent_example_secondary="$target_files/SYSTEM_EXT/etc/sos-agent/example-secondary.luau" local system_ext_policy="$target_files/SYSTEM_EXT/etc/selinux/system_ext_sepolicy.cil" + local misc_info="$target_files/META/misc_info.txt" local bridge_apk="$target_files/SYSTEM_EXT/priv-app/SosFrameworkBridge/SosFrameworkBridge.apk" local core_platform="$target_files/SYSTEM_EXT/bin/sos-core-platform" local core_platform_rc="$target_files/SYSTEM_EXT/etc/init/sos-core-platform.rc" local core_app_manifest="$target_files/SYSTEM_EXT/etc/sos/core-apps.json" local marker="$target_files/SYSTEM_EXT/bin/sos-ui-removal-marker" local compat_marker="$target_files/SYSTEM_EXT/bin/sos-compat-ui-removal-marker" - local artifact + local runner_filename=agent-runner.cjs + local artifact expected_agent_runner="$SOS_ROOT/services/sos-agent/dist/agent-runner.cjs" + if [[ "$product_variant" == dev-credential ]]; then + runner_filename=agent-runner-core-dev.cjs + expected_agent_runner="$SOS_ROOT/services/sos-agent/dist/agent-runner-core-dev.cjs" + fi + local agent_runner="$target_files/SYSTEM_EXT/etc/sos-agent/$runner_filename" + local runner_device_path="/system_ext/etc/sos-agent/$runner_filename" + local unexpected_runner="$target_files/SYSTEM_EXT/etc/sos-agent/agent-runner-core-dev.cjs" + if [[ "$product_variant" == dev-credential ]]; then + unexpected_runner="$target_files/SYSTEM_EXT/etc/sos-agent/agent-runner.cjs" + fi for artifact in \ "$host" "$host_runtime" "$host_init_rc" "$properties" "$authority" \ "$node" "$agent_runner" "$agent_api" "$agent_example_primary" \ - "$agent_example_secondary" "$system_ext_policy"; do + "$agent_example_secondary" "$system_ext_policy" "$misc_info"; do [[ -f "$artifact" ]] || fail "SOS $stage artifact missing: $artifact" stat --printf='%n %s bytes\n' "$artifact" sha256sum "$artifact" done [[ ! -e "$target_files/SYSTEM_EXT/priv-app/SosShell/SosShell.apk" ]] || \ fail "SOS Core must not package the Android Activity shell" + [[ ! -e "$unexpected_runner" ]] || \ + fail "SOS $stage packaged both ordinary and development Pi runners" file "$host" | grep -F 'ARM aarch64' >/dev/null || \ fail "SOS $stage native host is not AArch64" file "$host_runtime" | grep -F 'ARM aarch64' >/dev/null || \ fail "SOS $stage GPUI runtime is not AArch64" + strings "$node" | grep -Fx -- '--jitless' >/dev/null || \ + fail "SOS $stage shared Node runtime does not support --jitless" + strings "$host_runtime" | grep -F \ + 'platform=core hardening=jitless' >/dev/null || \ + fail "SOS $stage GPUI runtime omitted the hardened Core child launch marker" strings "$host_runtime" | grep -F \ 'SOS experience window is live frame_padding=none' >/dev/null || \ fail "SOS $stage GPUI runtime still reserves Android-style frame bands" @@ -1307,18 +2090,20 @@ inspect_core_stage() { fail "SOS $stage GPUI provider still depends on Android loopback networking" strings "$host_runtime" | grep -F '/data/misc/sos/revision.sock' >/dev/null || \ fail "SOS $stage GPUI runtime still depends on Android loopback networking" - strings "$host_runtime" | grep -F \ - '/system_ext/etc/sos-agent/agent-runner.cjs' >/dev/null || \ - fail "SOS $stage GPUI runtime omitted the shared Pi runner boundary" + strings "$host_runtime" | grep -F "$runner_device_path" >/dev/null || \ + fail "SOS $stage GPUI runtime omitted its selected Pi runner boundary" check_core_runtime_model_contract "$host_runtime" for agent_marker in \ 'android_agent_effect_dispatch action=' \ - 'android_agent_thread_start provider=' \ + 'android_agent_request_accepted provider=' \ 'android_agent_request_start provider=' \ + 'android_agent_child_launch_failure cause=' \ 'android_agent_child_start pid=' \ + 'android_agent_child_request state=written' \ 'android_agent_child_exit code=' \ - 'android_agent_child_response type=' \ - 'android_agent_failure stage='; do + 'android_agent_child_response_header protocol=2' \ + 'android_agent_request_terminal stage=' \ + 'android_agent_ui_terminal status='; do strings "$host_runtime" | grep -F "$agent_marker" >/dev/null || \ fail "SOS $stage GPUI runtime omitted sanitized agent lifecycle marker: $agent_marker" done @@ -1337,8 +2122,20 @@ inspect_core_stage() { strings "$host_runtime" | grep -F \ 'android_agent_pi_response provider=' >/dev/null || \ fail "SOS $stage GPUI runtime omitted response model evidence" - cmp -s "$agent_runner" "$SOS_ROOT/services/sos-agent/dist/agent-runner.cjs" || \ - fail "SOS $stage packaged Pi runner differs from the shared built bundle" + cmp -s "$agent_runner" "$expected_agent_runner" || \ + fail "SOS $stage packaged Pi runner differs from its product-specific built bundle" + if [[ "$product_variant" == dev-credential ]]; then + grep -aF 'http://127.0.0.1:37173' "$agent_runner" >/dev/null && \ + grep -aF 'HttpsProxyAgent' "$agent_runner" >/dev/null || \ + fail "Core-dev packaged runner omitted the fixed CONNECT proxy" + else + ! grep -aF 'http://127.0.0.1:37173' "$agent_runner" >/dev/null && \ + ! grep -aF 'HttpsProxyAgent' "$agent_runner" >/dev/null || \ + fail "ordinary product packaged development CONNECT proxy code" + fi + grep -aF 'nodeProviderFetch' "$agent_runner" >/dev/null && \ + ! grep -aF 'WebAssembly' "$agent_runner" >/dev/null || \ + fail "packaged shared Pi runner cannot parse provider HTTP while JITless" cmp -s "$agent_api" "$SOS_ROOT/docs/experience-api.md" || \ fail "SOS $stage packaged Pi API document differs from repository source" cmp -s "$agent_example_primary" "$SOS_ROOT/experiences/default.luau" || \ @@ -1347,9 +2144,59 @@ inspect_core_stage() { fail "SOS $stage packaged Pi secondary example differs from repository source" [[ ! -e "$target_files/SYSTEM_EXT/etc/sos-agent/android-runner.cjs" ]] || \ fail "SOS $stage retained the superseded Android-only Pi runner" - grep -F '(allow sos_core_host sos_node_exec (file (' "$system_ext_policy" | \ - grep -F 'execute_no_trans' >/dev/null || \ - fail "SOS $stage Core host cannot execute the shared Pi runner" + grep -F '(typetransition sos_core_host sos_node_exec process sos_core_agent)' \ + "$system_ext_policy" >/dev/null || \ + fail "SOS $stage Core Node execution does not enter its agent domain" + grep -F '(allow sos_core_agent sos_node_exec (file (' "$system_ext_policy" | \ + grep -F 'entrypoint' >/dev/null || \ + fail "SOS $stage Core agent cannot enter through the immutable Node binary" + grep -F '(allow sos_core_agent proc_meminfo (file (' "$system_ext_policy" | \ + grep -E '\(file \((open read|read open)\)\)\)$' >/dev/null || \ + fail "SOS $stage policy omitted the narrow Node meminfo read" + grep -F '(allow sos_core_agent self (udp_socket (create)))' \ + "$system_ext_policy" >/dev/null || \ + fail "SOS $stage policy omitted Bionic's creation-only DNS proxy eligibility probe" + grep -F '(allow sos_core_agent sos_core_host (fifo_file (' \ + "$system_ext_policy" | grep -F 'ioctl' >/dev/null && \ + grep -F '(allowx sos_core_agent sos_core_host (ioctl fifo_file (0x5401)))' \ + "$system_ext_policy" >/dev/null || \ + fail "SOS $stage policy omitted Node's exact pipe TCGETS contract" + grep -F '(allow sos_core_agent dnsproxyd_socket (sock_file (' \ + "$system_ext_policy" | grep -F 'write' >/dev/null && \ + grep -F '(allow sos_core_agent fwmarkd_socket (sock_file (' \ + "$system_ext_policy" | grep -F 'write' >/dev/null && \ + grep -F '(allow sos_core_agent netd (unix_stream_socket (connectto)))' \ + "$system_ext_policy" >/dev/null || \ + fail "SOS $stage policy omitted the supported Android DNS/fwmark proxy path" + grep -F '(portcon tcp 443 (u object_r sos_agent_https_port ' \ + "$system_ext_policy" >/dev/null && \ + grep -F '(allow sos_core_agent sos_agent_https_port (tcp_socket (name_connect)))' \ + "$system_ext_policy" >/dev/null || \ + fail "SOS $stage policy omitted the confined TCP-443 client path" + if grep -E '^\(allow sos_core_agent [^ ]+ \(tcp_socket \([^)]*name_connect' \ + "$system_ext_policy" | \ + grep -Fv '(allow sos_core_agent sos_agent_https_port (tcp_socket (name_connect)))' \ + >/dev/null; then + fail "SOS $stage Core agent can connect to a TCP destination other than port 443" + fi + if grep -F '(allow sos_core_host net_dns_prop ' "$system_ext_policy" >/dev/null || \ + grep -F '(allow sos_core_agent net_dns_prop ' "$system_ext_policy" >/dev/null; then + fail "SOS $stage policy bypasses Android's obsolete net.dns property boundary" + fi + if grep -E '\(allow (sos_core_host|sos_core_agent) [^ ]+ \(process \([^)]*execmem' \ + "$system_ext_policy" >/dev/null; then + fail "SOS $stage policy grants forbidden executable-memory authority to Core" + fi + if grep -E '\(allow sos_core_host [^ ]+ \((tcp_socket|udp_socket|rawip_socket|icmp_socket) ' \ + "$system_ext_policy" >/dev/null; then + fail "SOS $stage trusted UI host inherited agent network authority" + fi + if grep -E '^\(typeattributeset netdomain .*sos_core_agent|\(allow sos_core_agent [^ ]+ \((rawip_socket|icmp_socket) ' \ + "$system_ext_policy" >/dev/null || \ + grep -E '\(allow sos_core_agent [^ ]+ \(udp_socket \([^)]*(bind|connect|read|write|ioctl|name_bind|node_bind)' \ + "$system_ext_policy" >/dev/null; then + fail "SOS $stage Core agent inherited broad network socket authority" + fi grep -F '(allow sos_core_host sos_authority (unix_stream_socket (connectto)))' \ "$system_ext_policy" >/dev/null || \ fail "SOS $stage policy cannot connect Core to the revision authority" @@ -1365,10 +2212,16 @@ inspect_core_stage() { grep -Fx 'ro.sos.profile=core' "$properties" grep -Fx 'ro.sos.disable_user_apk_install=true' "$properties" grep -Fx 'ro.sos.revision_format=3' "$properties" + if [[ "$stage" == core1 ]]; then + grep -Fx "ro.sos.build_variant=$expected_build_variant" "$properties" || \ + fail "SOS Core 1 image reports the wrong immutable build variant" + fi + grep -F "/${target_product}/a33x:" "$misc_info" >/dev/null || \ + fail "SOS $stage target-files fingerprint reports the wrong product" unzip -p "$target_files/SYSTEM/framework/services.jar" 'classes*.dex' | strings | \ grep -F 'SOS Core product policy prevents user APK installation' >/dev/null || \ fail "SOS $stage framework does not enforce the Core APK-install policy" - grep -E "^ro\\.system_ext\\.build\\.version\\.incremental=sos\\.${stage}\\.[0-9a-f]{12}\\.[0-9a-f]{12}$" \ + grep -E "^ro\\.system_ext\\.build\\.version\\.incremental=sos\\.${expected_revision_identity}\\.[0-9a-f]{12}\\.[0-9a-f]{12}$" \ "$properties" || fail "SOS $stage build lacks a content-derived fingerprint" grep -Fx 'ro.sos.core.autostart=preunlock' "$properties" @@ -1539,6 +2392,93 @@ inspect_core1() { ! readelf -Ws "$host_runtime" | grep -F \ ' sos_core_provider_acceptance_probe' >/dev/null || \ fail "Core 1 release inspection rejects the non-shipping provider probe" + local target_files="$LINEAGE_ROOT/out/target/product/a33x/obj/PACKAGING/target_files_intermediates/lineage_${LINEAGE_SOS_CORE1_DEVICE}-target_files" + ! strings "$host_runtime" | grep -F 'core_dev_credential state=ready' >/dev/null || \ + fail "ordinary Core 1 must exclude the development credential endpoint" + [[ ! -e "$target_files/SYSTEM_EXT/bin/sos-core-dev-credential" ]] || \ + fail "ordinary Core 1 must exclude the development credential client" + [[ ! -e "$target_files/SYSTEM_EXT/bin/sos-node-core-dev" ]] || \ + fail "ordinary Core 1 must exclude the development tunnel Node executable" + [[ ! -e "$target_files/SYSTEM_EXT/etc/sos-agent/agent-runner-core-dev.cjs" ]] || \ + fail "ordinary Core 1 must exclude the development runner" + ! grep -F 'system_ext/bin/sos-core-dev-credential ' \ + "$target_files/META/system_ext_filesystem_config.txt" >/dev/null || \ + fail "ordinary Core 1 filesystem metadata includes the development client" + ! grep -Fx 'ro.sos.dev_credential=1' "$target_files/SYSTEM_EXT/etc/build.prop" >/dev/null || \ + fail "ordinary Core 1 must exclude the development credential property" + ! grep -E 'sos_core_dev_(agent|credential|proxy_port)|sos_node_core_dev_exec' \ + "$target_files/SYSTEM_EXT/etc/selinux/system_ext_sepolicy.cil" >/dev/null || \ + fail "ordinary Core 1 policy includes development credential or tunnel authority" +} + +inspect_core1_dev() { + [[ "$#" -eq 0 ]] || fail "usage: ./tools/a33xctl inspect-core1-dev" + inspect_core_stage core1 dev-credential + local target_files="$LINEAGE_ROOT/out/target/product/a33x/obj/PACKAGING/target_files_intermediates/lineage_${LINEAGE_SOS_CORE1_DEV_DEVICE}-target_files" + local host_runtime="$target_files/SYSTEM_EXT/lib64/libsos_core_experience.so" + local client="$target_files/SYSTEM_EXT/bin/sos-core-dev-credential" + local dev_node="$target_files/SYSTEM_EXT/bin/sos-node-core-dev" + local file_contexts="$target_files/SYSTEM_EXT/etc/selinux/system_ext_file_contexts" + local system_ext_policy="$target_files/SYSTEM_EXT/etc/selinux/system_ext_sepolicy.cil" + [[ -f "$client" ]] || fail "Core 1 development credential client is absent" + [[ -f "$dev_node" ]] || fail "Core 1 development tunnel Node executable is absent" + grep -Fx 'system_ext/bin/sos-core-dev-credential 0 2000 755 capabilities=0x0' \ + "$target_files/META/system_ext_filesystem_config.txt" >/dev/null || \ + fail "Core 1 development credential client lacks executable image metadata" + file "$client" | grep -F 'ARM aarch64' >/dev/null || \ + fail "Core 1 development credential client is not AArch64" + file "$dev_node" | grep -F 'ARM aarch64' >/dev/null || \ + fail "Core 1 development tunnel Node executable is not AArch64" + grep -Fx 'ro.sos.dev_credential=1' "$target_files/SYSTEM_EXT/etc/build.prop" >/dev/null || \ + fail "Core 1 development credential property is absent" + grep -Fx 'ro.sos.build_variant=core1-dev-credential' \ + "$target_files/SYSTEM_EXT/etc/build.prop" >/dev/null || \ + fail "Core 1 development immutable product marker is absent" + grep -Fx \ + '/system_ext/bin/sos-core-dev-credential u:object_r:sos_core_dev_credential_exec:s0' \ + "$file_contexts" >/dev/null || \ + fail "Core 1 development client lacks its dedicated image label" + grep -Fx \ + '/system_ext/bin/sos-node-core-dev u:object_r:sos_node_core_dev_exec:s0' \ + "$file_contexts" >/dev/null || \ + fail "Core 1 development Node lacks its dedicated image label" + grep -Fx '(typetransition sos_core_host sos_node_core_dev_exec process sos_core_dev_agent)' \ + "$system_ext_policy" >/dev/null && \ + grep -Fx '(allow sos_core_dev_agent sos_core_dev_proxy_port (tcp_socket (name_connect)))' \ + "$system_ext_policy" >/dev/null && \ + grep -F '(portcon tcp 37173 (u object_r sos_core_dev_proxy_port ' \ + "$system_ext_policy" >/dev/null || \ + fail "Core 1 development Node lacks the fixed proxy-port domain boundary" + grep -Fx '(typetransition shell sos_core_dev_credential_exec process sos_core_dev_credential)' \ + "$system_ext_policy" >/dev/null || \ + fail "Core 1 development client lacks its shell domain transition" + grep -Fx '(allow sos_core_dev_credential sos_core_dev_credential_exec (file (read getattr map execute open entrypoint)))' \ + "$system_ext_policy" >/dev/null || \ + fail "Core 1 development client lacks its exact entrypoint policy" + grep -Fx '(allow sos_core_dev_credential sos_core_host (unix_stream_socket (connectto)))' \ + "$system_ext_policy" >/dev/null || \ + fail "Core 1 development client cannot reach its dedicated endpoint" + ! grep -Fx '(allow shell sos_core_host (unix_stream_socket (connectto)))' \ + "$system_ext_policy" >/dev/null || \ + fail "Core 1 development policy lets shell bypass the client domain" + for marker in \ + 'core_dev_credential state=ready transport=local peer=sos_core_dev_credential' \ + 'core_dev_credential state=set' \ + 'core_dev_credential state=cleared'; do + strings "$host_runtime" | grep -F "$marker" >/dev/null || \ + fail "Core 1 development endpoint omitted marker: $marker" + done + strings "$client" | grep -Fx 'core_dev_credential=READY' >/dev/null && \ + strings "$client" | grep -Fx 'core_dev_credential=SET' >/dev/null && \ + strings "$client" | grep -Fx 'core_dev_credential=CLEARED' >/dev/null || \ + fail "Core 1 development client omitted secret-free acknowledgements" + for product_marker in \ + ro.build.version.incremental ro.sos.build_variant ro.sos.dev_credential \ + ro.build.type ro.debuggable; do + strings "$host_runtime" | grep -F "$product_marker" >/dev/null || \ + fail "Core 1 development endpoint omitted product marker: $product_marker" + done + info "SOS Core 1 development credential artifact gate passed" } inspect_core1_provider_probe() { @@ -1585,6 +2525,499 @@ adb_readonly() { "$adb_binary" -s "$serial" "$@" } +resolve_core_dev_serial() { + [[ "$#" -eq 0 || ("$#" -eq 2 && "$1" == --serial) ]] || \ + fail "Core-dev commands accept only [--serial SERIAL]" + if [[ "$#" -eq 2 ]]; then + [[ "$2" =~ ^[A-Za-z0-9._:-]+$ ]] || fail "invalid device serial: $2" + printf '%s\n' "$2" + return + fi + local adb_binary="${A33XCTL_ADB:-adb}" line state + local -a serials=() + require_command "$adb_binary" + while IFS=$'\t' read -r line state; do + [[ "$state" == device ]] && serials+=("$line") + done < <("$adb_binary" devices | tail -n +2) + [[ "${#serials[@]}" -eq 1 ]] || \ + fail "exactly one ready ADB device is required; pass --serial SERIAL" + printf '%s\n' "${serials[0]}" +} + +safe_product_marker_actual() { + [[ "$#" -eq 1 ]] || fail "internal usage: safe_product_marker_actual VALUE" + if [[ -z "$1" ]]; then + printf '' + elif [[ "$1" =~ ^[A-Za-z0-9._:+/-]+$ ]]; then + printf '%s' "$1" + else + printf '' + fi +} + +safe_product_marker_equals() { + [[ "$#" -eq 3 ]] || \ + fail "internal usage: safe_product_marker_equals NAME EXPECTED ACTUAL" + local name="$1" expected="$2" actual="$3" + [[ "$actual" == "$expected" ]] || \ + fail "Core product marker mismatch: $name expected=$expected actual=$(safe_product_marker_actual "$actual")" +} + +safe_product_marker_matches() { + [[ "$#" -eq 4 ]] || \ + fail "internal usage: safe_product_marker_matches NAME EXPECTED ACTUAL REGEX" + local name="$1" expected="$2" actual="$3" pattern="$4" + [[ "$actual" =~ ^($pattern)$ ]] || \ + fail "Core product marker mismatch: $name expected=$expected actual=$(safe_product_marker_actual "$actual")" +} + +core_dev_status_file_matches() { + [[ "$#" -eq 2 ]] || \ + fail "internal usage: core_dev_status_file_matches FILE " + local response_file="$1" status="$2" expected byte_count + case "$status" in + READY|SET|CLEARED|CONFIGURED|EMPTY|SUBMITTED) ;; + *) fail "internal usage: invalid Core-dev status" ;; + esac + if [[ "$status" == SUBMITTED ]]; then + expected="core_dev_agent_smoke=SUBMITTED" + else + expected="core_dev_credential=$status" + fi + read -r byte_count < <(wc -c <"$response_file") || return 1 + if [[ "$byte_count" -eq $((${#expected} + 1)) ]]; then + cmp -s "$response_file" <(printf '%s\n' "$expected") + elif [[ "$byte_count" -eq $((${#expected} + 2)) ]]; then + cmp -s "$response_file" <(printf '%s\r\n' "$expected") + else + return 1 + fi +} + +core_dev_probe_failure_code() { + [[ "$#" -eq 3 ]] || \ + fail "internal usage: core_dev_probe_failure_code STATUS STDOUT STDERR" + local status="$1" stdout_file="$2" stderr_file="$3" + if grep -aiEq 'Permission denied|SELinux.*denied' \ + "$stdout_file" "$stderr_file"; then + printf '20\n' + elif grep -aEq 'not found|No such file or directory' \ + "$stdout_file" "$stderr_file"; then + printf '21\n' + elif grep -aFq '(endpoint_unavailable)' "$stdout_file" "$stderr_file"; then + printf '22\n' + elif grep -aEq '\((wrong_peer|request_rejected)\)' \ + "$stdout_file" "$stderr_file"; then + printf '23\n' + elif grep -aFq '(bad_magic)' "$stdout_file" "$stderr_file"; then + printf '24\n' + elif grep -aFq '(bad_version)' "$stdout_file" "$stderr_file"; then + printf '25\n' + elif grep -aEq '\((bad_status|protocol_mismatch_status|protocol_mismatch)\)' \ + "$stdout_file" "$stderr_file"; then + printf '26\n' + elif grep -aFq '(short_io)' "$stdout_file" "$stderr_file"; then + printf '27\n' + elif [[ "$status" -eq 126 ]]; then + printf '20\n' + elif [[ "$status" -eq 127 ]]; then + printf '21\n' + else + printf '2\n' + fi +} + +core_dev_shell_transport_unsupported() { + [[ "$#" -eq 1 ]] || \ + fail "internal usage: core_dev_shell_transport_unsupported STDERR" + LC_ALL=C grep -aEiq \ + "(invalid|unknown|unrecognized|unsupported) (option|argument).*['\"]?-?T|does not support.*-T" \ + "$1" +} + +run_core_dev_client_status() { + [[ "$#" -eq 4 ]] || \ + fail "internal usage: run_core_dev_client_status SERIAL OPERATION STATUS STDIN" + local serial="$1" operation="$2" expected_status="$3" stdin_mode="$4" + local adb_binary temp_root stdout_file stderr_file command_status result + case "$operation:$expected_status:$stdin_mode" in + probe:READY:none|clear:CLEARED:none|set:SET:inherit|status:STATE:none|agent-smoke:SUBMITTED:none) ;; + *) fail "internal usage: invalid Core-dev client invocation" ;; + esac + adb_binary="${A33XCTL_ADB:-adb}" + require_command "$adb_binary" + temp_root="$(mktemp -d "${TMPDIR:-/tmp}/sos-core-dev-status.XXXXXX")" + stdout_file="$temp_root/stdout" + stderr_file="$temp_root/stderr" + if [[ "$stdin_mode" == inherit ]]; then + "$adb_binary" -s "$serial" shell -T -- \ + /system_ext/bin/sos-core-dev-credential "$operation" \ + >"$stdout_file" 2>"$stderr_file" || command_status=$? + else + "$adb_binary" -s "$serial" shell -T -- \ + /system_ext/bin/sos-core-dev-credential "$operation" "$stdout_file" 2>"$stderr_file" || command_status=$? + fi + command_status="${command_status:-0}" + CORE_DEV_CLIENT_STATE='' + if [[ "$command_status" -eq 0 && ! -s "$stderr_file" ]]; then + if [[ "$expected_status" == STATE ]] && \ + core_dev_status_file_matches "$stdout_file" CONFIGURED; then + CORE_DEV_CLIENT_STATE=CONFIGURED + result=0 + elif [[ "$expected_status" == STATE ]] && \ + core_dev_status_file_matches "$stdout_file" EMPTY; then + CORE_DEV_CLIENT_STATE=EMPTY + result=0 + elif [[ "$expected_status" != STATE ]] && \ + core_dev_status_file_matches "$stdout_file" "$expected_status"; then + result=0 + else + result=2 + fi + elif core_dev_shell_transport_unsupported "$stderr_file"; then + result=28 + elif [[ "$operation" == probe && "$command_status" -ne 0 ]]; then + result="$(core_dev_probe_failure_code \ + "$command_status" "$stdout_file" "$stderr_file")" + else + result=2 + fi + rm -rf -- "$temp_root" + return "$result" +} + +query_core_dev_state() { + [[ "$#" -eq 1 ]] || fail "internal usage: query_core_dev_state SERIAL" + run_core_dev_client_status "$1" status STATE none +} + +require_core_dev_state() { + [[ "$#" -eq 2 ]] || \ + fail "internal usage: require_core_dev_state SERIAL " + local serial="$1" expected="$2" + [[ "$expected" == CONFIGURED || "$expected" == EMPTY ]] || \ + fail "internal usage: invalid expected Core-dev credential state" + query_core_dev_state "$serial" || return + [[ "$CORE_DEV_CLIENT_STATE" == "$expected" ]] || return 3 +} + +probe_core_dev_client() { + [[ "$#" -eq 1 ]] || fail "internal usage: probe_core_dev_client " + local serial="$1" result + if run_core_dev_client_status "$serial" probe READY none; then + return + else + result=$? + fi + case "$result" in + 20) + fail "Core-dev probe failed: client.execution expected=allowed actual=selinux-denied" ;; + 21) + fail "Core-dev probe failed: client.executable expected=present actual=missing" ;; + 22) + fail "Core-dev probe failed: endpoint.availability expected=ready actual=unavailable" ;; + 23) + fail "Core-dev probe failed: endpoint.peer_product expected=accepted actual=rejected" ;; + 24) + fail "Core-dev probe failed: endpoint.magic expected=SOSK actual=mismatch" ;; + 25) + fail "Core-dev probe failed: endpoint.version expected=v1 actual=mismatch" ;; + 26) + fail "Core-dev probe failed: endpoint.status expected=v1-known actual=mismatch" ;; + 27) + fail "Core-dev probe failed: endpoint.io expected=complete actual=short" ;; + 28) + fail "Core-dev probe failed: ADB transport requires shell -T support" ;; + *) + fail "Core-dev probe failed: endpoint.protocol expected=v1 actual=mismatch" ;; + esac +} + +validate_core_dev_target() { + [[ "$#" -eq 1 ]] || fail "internal usage: validate_core_dev_target " + local serial="$1" revision stage profile providers owner build_variant dev_gate build_type debuggable + revision="$(device_property "$serial" ro.build.version.incremental)" + stage="$(device_property "$serial" ro.sos.core.stage)" + profile="$(device_property "$serial" ro.sos.profile)" + providers="$(device_property "$serial" ro.sos.providers)" + owner="$(device_property "$serial" ro.sos.ui_owner)" + build_variant="$(device_property "$serial" ro.sos.build_variant)" + dev_gate="$(device_property "$serial" ro.sos.dev_credential)" + build_type="$(device_property "$serial" ro.build.type)" + debuggable="$(device_property "$serial" ro.debuggable)" + safe_product_marker_matches ro.build.version.incremental \ + 'sos.core1dev.<12-lower-hex>.<12-lower-hex>' "$revision" \ + 'sos\.core1dev\.[0-9a-f]{12}\.[0-9a-f]{12}' + safe_product_marker_equals ro.sos.core.stage 1 "$stage" + safe_product_marker_equals ro.sos.profile core "$profile" + safe_product_marker_equals ro.sos.providers core-native "$providers" + safe_product_marker_equals ro.sos.ui_owner native-sos-no-zygote "$owner" + safe_product_marker_equals ro.sos.build_variant core1-dev-credential "$build_variant" + safe_product_marker_equals ro.sos.dev_credential 1 "$dev_gate" + safe_product_marker_equals ro.build.type userdebug "$build_type" + safe_product_marker_equals ro.debuggable 0 "$debuggable" + probe_core_dev_client "$serial" +} + +core1_dev_set_openrouter_key() { + local serial status state_status + serial="$(resolve_core_dev_serial "$@")" + validate_core_dev_target "$serial" + [[ -t 0 && -r /dev/tty ]] || fail "credential input requires an interactive terminal" + require_command stty + if ( + # Never allow caller-enabled tracing to disclose the prompt value. + set +x + local secret='' saved_stty tty_fd secret_length + export LC_ALL=C + exec {tty_fd}<>/dev/tty + saved_stty="$(stty -g <&"$tty_fd")" + core_dev_prompt_cleanup() { + stty "$saved_stty" <&"$tty_fd" 2>/dev/null || true + printf '\n' >&"$tty_fd" 2>/dev/null || true + secret_length="${#secret}" + if [[ "$secret_length" -gt 0 ]]; then + printf -v secret '%*s' "$secret_length" '' + fi + secret='' + unset secret + exec {tty_fd}>&- + } + trap core_dev_prompt_cleanup EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + stty -echo <&"$tty_fd" + printf 'Paste the disposable OpenRouter key, then press Enter: ' >&"$tty_fd" + if ! IFS= read -r -s -n 513 secret <&"$tty_fd"; then + printf 'error: credential input ended before a complete line\n' >&2 + exit 2 + fi + if [[ "${#secret}" -gt 512 ]]; then + printf 'error: credential input exceeds 512 bytes\n' >&2 + exit 2 + fi + printf '%s\n' "$secret" | \ + run_core_dev_client_status "$serial" set SET inherit + ); then + status=0 + else + status=$? + fi + if [[ "$status" -eq 28 ]]; then + fail "Core-dev set failed: ADB transport requires shell -T support; credential state is unknown, so run core1-dev-clear-openrouter-key after repairing ADB" + elif [[ "$status" -ne 0 ]]; then + fail "Core-dev set acknowledgement is unavailable; credential state is unknown, so run core1-dev-clear-openrouter-key" + fi + if require_core_dev_state "$serial" CONFIGURED; then + state_status=0 + else + state_status=$? + fi + if [[ "$state_status" -eq 28 ]]; then + fail "Core-dev set status failed: ADB transport requires shell -T support; credential state is unknown, so run core1-dev-clear-openrouter-key after repairing ADB" + elif [[ "$state_status" -eq 3 ]]; then + fail "Core-dev set status mismatch: expected=CONFIGURED actual=$CORE_DEV_CLIENT_STATE; run core1-dev-clear-openrouter-key" + elif [[ "$state_status" -ne 0 ]]; then + fail "Core-dev set status is invalid; credential state is unknown, so run core1-dev-clear-openrouter-key" + fi + printf 'core1_dev_openrouter_key=SET\nserial=%s\n' "$serial" +} + +core1_dev_clear_openrouter_key() { + local serial status state_status + serial="$(resolve_core_dev_serial "$@")" + validate_core_dev_target "$serial" + if run_core_dev_client_status "$serial" clear CLEARED none; then + status=0 + else + status=$? + fi + if [[ "$status" -eq 28 ]]; then + fail "Core-dev clear failed: ADB transport requires shell -T support; credential state remains unknown" + elif [[ "$status" -ne 0 ]]; then + fail "Core returned an invalid development credential acknowledgement" + fi + if require_core_dev_state "$serial" EMPTY; then + state_status=0 + else + state_status=$? + fi + if [[ "$state_status" -eq 28 ]]; then + fail "Core-dev clear status failed: ADB transport requires shell -T support; credential state remains unknown" + elif [[ "$state_status" -eq 3 ]]; then + fail "Core-dev clear status mismatch: expected=EMPTY actual=$CORE_DEV_CLIENT_STATE" + elif [[ "$state_status" -ne 0 ]]; then + fail "Core returned an invalid development credential status; credential state remains unknown" + fi + printf 'core1_dev_openrouter_key=CLEARED\nserial=%s\n' "$serial" +} + +core1_dev_status_openrouter_key() { + local serial status + serial="$(resolve_core_dev_serial "$@")" + validate_core_dev_target "$serial" + if query_core_dev_state "$serial"; then + status=0 + else + status=$? + fi + [[ "$status" -ne 28 ]] || \ + fail "Core-dev status failed: ADB transport requires shell -T support" + [[ "$status" -eq 0 ]] || \ + fail "Core returned an invalid development credential status" + printf 'core1_dev_openrouter_key=%s\nserial=%s\n' \ + "$CORE_DEV_CLIENT_STATE" "$serial" +} + +core1_dev_submit_agent_smoke() { + local serial status + serial="$(resolve_core_dev_serial "$@")" + validate_core_dev_target "$serial" + if require_core_dev_state "$serial" CONFIGURED; then + status=0 + else + status=$? + [[ "$status" -ne 28 ]] || \ + fail "Core-dev smoke status failed: ADB transport requires shell -T support" + [[ "$status" -ne 3 ]] || \ + fail "Core-dev smoke requires credential state CONFIGURED; actual=$CORE_DEV_CLIENT_STATE" + fail "Core returned an invalid development credential status" + fi + if run_core_dev_client_status "$serial" agent-smoke SUBMITTED none; then + status=0 + else + status=$? + [[ "$status" -ne 28 ]] || \ + fail "Core-dev smoke submit failed: ADB transport requires shell -T support" + fail "Core rejected the fixed development agent smoke submit" + fi + printf 'core1_dev_agent_smoke=SUBMITTED\nserial=%s\n' "$serial" +} + +core1_dev_run_agent_smoke() ( + set -euo pipefail + local serial adb_binary evidence_root timestamp bridge_pid='' bridge_fd='' + local bridge_ready bridge_host_port reverse_installed=false terminal_seen=false + local terminal_completed=false authority_committed=false + local transaction_status=0 start_epoch deadline safe_partial + serial="$(resolve_core_dev_serial "$@")" + validate_core_dev_target "$serial" + adb_binary="${A33XCTL_ADB:-adb}" + require_command "$adb_binary" + require_command python3 + if ! require_core_dev_state "$serial" CONFIGURED; then + [[ "${CORE_DEV_CLIENT_STATE:-}" != EMPTY ]] || \ + fail "Core-dev smoke requires credential state CONFIGURED; actual=EMPTY" + fail "Core returned an invalid development credential status" + fi + + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + evidence_root="$SOS_ROOT/artifacts/device-gates/core-dev-smoke-${timestamp}-${serial,,}" + [[ ! -e "$evidence_root" ]] || fail "Core-dev smoke evidence path already exists" + mkdir -p "$evidence_root" + + cleanup_core_dev_smoke() { + local cleanup_status=0 + if [[ "$reverse_installed" == true ]]; then + "$adb_binary" -s "$serial" reverse --remove \ + "tcp:$CORE_DEV_PROXY_DEVICE_PORT" "$evidence_root/reverse-remove.txt" 2>&1 || cleanup_status=$? + reverse_installed=false + fi + if [[ -n "$bridge_pid" ]]; then + kill "$bridge_pid" 2>/dev/null || true + wait "$bridge_pid" 2>/dev/null || true + bridge_pid='' + fi + return "$cleanup_status" + } + trap 'cleanup_core_dev_smoke || true' EXIT + trap 'exit 130' HUP INT TERM + + coproc CORE_DEV_CONNECT_BRIDGE { + exec python3 "$SOS_ROOT/tools/core-dev-connect-bridge.py" \ + --events "$evidence_root/bridge-events.txt" \ + 2>"$evidence_root/bridge-stderr.txt" + } + bridge_pid="$CORE_DEV_CONNECT_BRIDGE_PID" + bridge_fd="${CORE_DEV_CONNECT_BRIDGE[0]}" + IFS= read -r -t 5 bridge_ready <&"$bridge_fd" || \ + fail "Core-dev CONNECT bridge did not become ready" + [[ "$bridge_ready" =~ ^CORE_DEV_CONNECT_BRIDGE_READY\ host=127\.0\.0\.1\ port=([0-9]{1,5})\ authority=openrouter\.ai:443$ ]] || \ + fail "Core-dev CONNECT bridge returned an invalid readiness record" + bridge_host_port="${BASH_REMATCH[1]}" + ((bridge_host_port >= 1024 && bridge_host_port <= 65535)) || \ + fail "Core-dev CONNECT bridge selected an invalid host port" + printf '%s\n' "$bridge_ready" >"$evidence_root/bridge-ready.txt" + + "$adb_binary" -s "$serial" reverse "tcp:$CORE_DEV_PROXY_DEVICE_PORT" \ + "tcp:$bridge_host_port" "$evidence_root/reverse-setup.txt" 2>&1 + reverse_installed=true + start_epoch="$(adb_readonly "$serial" shell date +%s | tr -d '\r\n')" + [[ "$start_epoch" =~ ^[0-9]{10}$ ]] || fail "device clock returned an invalid epoch" + + if ! run_core_dev_client_status "$serial" agent-smoke SUBMITTED none; then + transaction_status=1 + else + printf 'core_dev_agent_smoke=SUBMITTED\nserial=%s\n' "$serial" \ + >"$evidence_root/submit.txt" + deadline=$((SECONDS + 260)) + safe_partial="$evidence_root/safe-lifecycle.txt.partial" + while ((SECONDS < deadline)); do + adb_readonly "$serial" logcat -d -v brief -T "${start_epoch}.000" | \ + awk ' + /core_dev_agent_smoke state=queued/ { smoke = 1 } + smoke && /core_dev_agent_smoke state=queued|core_ui_attempt event=|android_agent_request_accepted|android_agent_request_start|android_agent_child_launch_failure|android_agent_child_start|android_agent_child_request state=written|android_agent_child_response_header|android_agent_child_exit|android_agent_request_result|android_agent_action_sequence_verified|android_agent_action_verified ordinal=|android_agent_candidate_validation_ack|android_agent_activation_stage_ack|android_agent_activation_commit/ { print } + ' \ + >"$safe_partial" + if grep -F 'core_ui_attempt event=terminal' "$safe_partial" >/dev/null; then + terminal_seen=true + if grep -E 'core_ui_attempt event=terminal .*category=completed status=completed' \ + "$safe_partial" >/dev/null; then + terminal_completed=true + else + transaction_status=1 + break + fi + fi + if grep -E 'android_agent_activation_commit .*phase=committed authority=system' \ + "$safe_partial" >/dev/null; then + authority_committed=true + fi + [[ "$terminal_completed" == true && "$authority_committed" == true ]] && break + sleep 1 + done + mv "$safe_partial" "$evidence_root/safe-lifecycle.txt" + [[ "$terminal_seen" == true ]] || transaction_status=1 + [[ "$terminal_completed" == true && "$authority_committed" == true ]] || \ + transaction_status=1 + fi + + "$adb_binary" -s "$serial" exec-out screencap -p "$evidence_root/framebuffer.png" 2>"$evidence_root/screencap-stderr.txt" || \ + transaction_status=1 + if ! require_core_dev_state "$serial" CONFIGURED; then + transaction_status=1 + fi + printf 'core1_dev_openrouter_key=%s\nserial=%s\n' \ + "${CORE_DEV_CLIENT_STATE:-UNKNOWN}" "$serial" >"$evidence_root/status-after.txt" + + cleanup_core_dev_smoke || transaction_status=1 + trap - EXIT HUP INT TERM + [[ ! -s "$evidence_root/bridge-stderr.txt" ]] || transaction_status=1 + evidence_manifest_generate --root "$evidence_root" \ + --output "$evidence_root/manifest.tsv" >/dev/null + evidence_manifest_verify --root "$evidence_root" \ + --manifest "$evidence_root/manifest.tsv" >/dev/null || transaction_status=1 + printf 'core1_dev_agent_smoke=%s\nserial=%s\nevidence_root=%s\n' \ + "$([[ "$terminal_completed" == true && "$authority_committed" == true ]] && \ + printf COMPLETED || printf FAILED)" \ + "$serial" "$evidence_root" + exit "$transaction_status" +) + device_property() { [[ "$#" -eq 2 ]] || fail "internal usage: device_property " adb_readonly "$1" shell getprop "$2" | tr -d '\r\n' @@ -1596,24 +3029,47 @@ inspect_core1_readiness() { local serial="$2" expected_revision="$4" validate_device_identity "$serial" "$expected_revision" - local revision stage lifecycle profile providers ui_owner zygote enforcing + local revision stage lifecycle profile providers ui_owner build_variant dev_gate build_type debuggable + local zygote enforcing revision="$(device_property "$serial" ro.build.version.incremental)" stage="$(device_property "$serial" ro.sos.core.stage)" lifecycle="$(device_property "$serial" ro.sos.lifecycle)" profile="$(device_property "$serial" ro.sos.profile)" providers="$(device_property "$serial" ro.sos.providers)" ui_owner="$(device_property "$serial" ro.sos.ui_owner)" + build_variant="$(device_property "$serial" ro.sos.build_variant)" + dev_gate="$(device_property "$serial" ro.sos.dev_credential)" + build_type="$(device_property "$serial" ro.build.type)" + debuggable="$(device_property "$serial" ro.debuggable)" zygote="$(device_property "$serial" ro.zygote)" enforcing="$(adb_readonly "$serial" shell getenforce | tr -d '\r\n')" - [[ "$revision" == "$expected_revision" ]] || \ - fail "Core 1 revision mismatch: expected $expected_revision, found $revision" - [[ "$stage" == 1 && "$lifecycle" == active && "$profile" == core ]] || \ - fail "device does not report the active Core 1 product" - [[ "$providers" == core-native && "$ui_owner" == native-sos-no-zygote ]] || \ - fail "device does not report Core 1 native ownership" - [[ "$zygote" == no_zygote ]] || fail "Core 1 unexpectedly enables Zygote" - [[ "$enforcing" == Enforcing ]] || fail "Core 1 SELinux is not Enforcing" + safe_product_marker_equals ro.build.version.incremental "$expected_revision" "$revision" + safe_product_marker_equals ro.sos.core.stage 1 "$stage" + safe_product_marker_equals ro.sos.lifecycle active "$lifecycle" + safe_product_marker_equals ro.sos.profile core "$profile" + safe_product_marker_equals ro.sos.providers core-native "$providers" + safe_product_marker_equals ro.sos.ui_owner native-sos-no-zygote "$ui_owner" + safe_product_marker_equals ro.debuggable 0 "$debuggable" + case "$revision" in + sos.core1.*) + safe_product_marker_equals ro.sos.build_variant core1-ordinary "$build_variant" + safe_product_marker_equals ro.sos.dev_credential '' \ + "${dev_gate:-}" + ;; + sos.core1dev.*) + safe_product_marker_matches ro.build.version.incremental \ + 'sos.core1dev.<12-lower-hex>.<12-lower-hex>' "$revision" \ + 'sos\.core1dev\.[0-9a-f]{12}\.[0-9a-f]{12}' + safe_product_marker_equals ro.sos.build_variant core1-dev-credential "$build_variant" + safe_product_marker_equals ro.sos.dev_credential 1 "$dev_gate" + safe_product_marker_equals ro.build.type userdebug "$build_type" + probe_core_dev_client "$serial" + ;; + *) fail "Core product marker mismatch: ro.build.version.incremental expected=sos.core1.|sos.core1dev. actual=$(safe_product_marker_actual "$revision")" ;; + esac + safe_product_marker_equals ro.zygote no_zygote "$zygote" + safe_product_marker_equals selinux.mode Enforcing "$enforcing" local surfaces host_pids authority_pid platform_pid host_service authority_service platform_service surfaces="$(adb_readonly "$serial" shell dumpsys SurfaceFlinger --list)" @@ -1794,6 +3250,8 @@ commands: sync sync sources and record an immutable resolved manifest hydrate-lfs fetch and verify the pinned ARM64 WebView LFS object apply-patches apply the audited local a33x source backports + check-patch-series + verify exact patch provenance, baseline, order, and bootstrap state stage-sos stage ARM64 SOS product sources and prebuilts check-product-graph verify profile marker selection and distinct install paths @@ -1801,6 +3259,10 @@ commands: verify the exact bundled OpenRouter assignment and request guard check-core-runtime-model-contract RUNTIME verify Core's pinned model bytes and mismatch rejection boundary + check-core-agent-hardening + verify Core Node argv, safe failures, policy, and credential helper + check-core-dev-credential + verify the development-only stdin credential boundary build-recovery build only the a33x recovery image inspect-recovery report size, SHA-256, file type, and AVB metadata build-rom build the complete a33x LineageOS install package @@ -1810,6 +3272,7 @@ commands: build-compat compatibility alias for build-compat1 build-core-shadow build the manual native display/GPUI probe target build-core1 build the no-Zygote native locked/recovery target + build-core1-dev build Core 1 with the explicit development credential endpoint build-core1-provider-probe build a non-shipping Core 1 provider-acceptance test OTA build-core compatibility alias for build-core-shadow @@ -1819,10 +3282,21 @@ commands: inspect-sos compatibility alias for inspect-compat1 inspect-core verify Core Shadow and retained recovery UI inspect-core1 verify no-Zygote native-only packaging + inspect-core1-dev verify development endpoint inclusion and production exclusion inspect-core1-provider-probe verify the non-shipping trusted provider probe is present inspect-core1-readiness --serial SERIAL --expected-revision REVISION read-only exact-product Core 1 readiness snapshot + core1-dev-set-openrouter-key [--serial SERIAL] + hidden one-paste install into the running Core process + core1-dev-clear-openrouter-key [--serial SERIAL] + clear the running Core process credential + core1-dev-status-openrouter-key [--serial SERIAL] + report only whether the running credential is configured + core1-dev-submit-agent-smoke [--serial SERIAL] + submit the fixed non-secret prompt through the production UI path + core1-dev-run-agent-smoke [--serial SERIAL] + run the fixed smoke through a loopback-only CONNECT bridge evidence-manifest-generate --root DIR --output MANIFEST atomically record finalized evidence path/size/SHA-256 evidence-manifest-verify --root DIR --manifest MANIFEST @@ -1844,10 +3318,13 @@ case "$command" in sync) sync_source "$@" ;; hydrate-lfs) hydrate_source_lfs "$@" ;; apply-patches) apply_source_patches "$@" ;; + check-patch-series) check_source_patch_series "$@" ;; stage-sos) stage_sos "$@" ;; check-product-graph) check_product_graph "$@" ;; check-agent-runner-contract) check_agent_runner_contract "$@" ;; check-core-runtime-model-contract) check_core_runtime_model_contract "$@" ;; + check-core-agent-hardening) check_core_agent_hardening_source "$@" ;; + check-core-dev-credential) check_core_dev_credential_source "$@" ;; build-recovery) build_recovery "$@" ;; inspect-recovery) inspect_recovery "$@" ;; build-rom) build_rom "$@" ;; @@ -1857,6 +3334,7 @@ case "$command" in build-core-shadow) build_core_shadow "$@" ;; build-core0b) build_core0b "$@" ;; build-core1) build_core1 "$@" ;; + build-core1-dev) build_core1_dev "$@" ;; build-core1-provider-probe) build_core1_provider_probe "$@" ;; build-core) build_core "$@" ;; inspect-rom) inspect_rom "$@" ;; @@ -1867,8 +3345,14 @@ case "$command" in inspect-core) inspect_core "$@" ;; inspect-core0b) inspect_core0b "$@" ;; inspect-core1) inspect_core1 "$@" ;; + inspect-core1-dev) inspect_core1_dev "$@" ;; inspect-core1-provider-probe) inspect_core1_provider_probe "$@" ;; inspect-core1-readiness) inspect_core1_readiness "$@" ;; + core1-dev-set-openrouter-key) core1_dev_set_openrouter_key "$@" ;; + core1-dev-clear-openrouter-key) core1_dev_clear_openrouter_key "$@" ;; + core1-dev-status-openrouter-key) core1_dev_status_openrouter_key "$@" ;; + core1-dev-submit-agent-smoke) core1_dev_submit_agent_smoke "$@" ;; + core1-dev-run-agent-smoke) core1_dev_run_agent_smoke "$@" ;; evidence-manifest-generate) evidence_manifest_generate "$@" ;; evidence-manifest-verify) evidence_manifest_verify "$@" ;; -h|--help|help|'') usage ;; diff --git a/tools/core-dev-connect-bridge.py b/tools/core-dev-connect-bridge.py new file mode 100644 index 0000000..50a0b23 --- /dev/null +++ b/tools/core-dev-connect-bridge.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Loopback-only HTTP CONNECT bridge for the bounded Core-dev smoke request.""" + +from __future__ import annotations + +import socket +import socketserver +import sys +import threading +from collections.abc import Callable +from pathlib import Path +from typing import TextIO + + +LISTEN_HOST = "127.0.0.1" +ALLOWED_AUTHORITY = "openrouter.ai:443" +UPSTREAM_HOST = "openrouter.ai" +UPSTREAM_PORT = 443 +ALLOWED_HOST_HEADERS = frozenset((UPSTREAM_HOST, ALLOWED_AUTHORITY)) +MAX_REQUEST_BYTES = 8192 +IO_TIMEOUT_SECONDS = 15 +RELAY_TIMEOUT_SECONDS = 300 + + +class EventLog: + """Writes only fixed bridge phases, categories, statuses, and byte counts.""" + + def __init__(self, output: TextIO | None = None) -> None: + self.output = output + self.lock = threading.Lock() + + def record(self, event: str, **fields: str | int) -> None: + if self.output is None: + return + values = " ".join(f"{name}={value}" for name, value in fields.items()) + line = f"bridge_event={event}{' ' if values else ''}{values}\n" + with self.lock: + self.output.write(line) + self.output.flush() + + +def connect_upstream() -> socket.socket: + return socket.create_connection((UPSTREAM_HOST, UPSTREAM_PORT), IO_TIMEOUT_SECONDS) + + +class ConnectBridge(socketserver.ThreadingTCPServer): + allow_reuse_address = False + daemon_threads = True + + def __init__( + self, + connector: Callable[[], socket.socket] = connect_upstream, + events: EventLog | None = None, + ) -> None: + self.connector = connector + self.events = events or EventLog() + super().__init__((LISTEN_HOST, 0), ConnectHandler) + + def handle_error(self, request: socket.socket, client_address: object) -> None: + self.events.record("handler_failure", category="internal") + del request, client_address + + +class ConnectHandler(socketserver.BaseRequestHandler): + request: socket.socket + server: ConnectBridge + + def handle(self) -> None: + phase = "read_connect" + self.server.events.record("connection_accepted") + self.request.settimeout(IO_TIMEOUT_SECONDS) + try: + request = self._read_request() + status = self._validate_request(request) + if status is not None: + self.server.events.record("request_rejected", status=status) + self._reject(status) + return + self.server.events.record("connect_accepted", authority=ALLOWED_AUTHORITY) + phase = "connect_upstream" + upstream = self.server.connector() + try: + self.server.events.record("upstream_connected") + phase = "confirm_connect" + self.request.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + upstream.settimeout(RELAY_TIMEOUT_SECONDS) + self.request.settimeout(RELAY_TIMEOUT_SECONDS) + phase = "relay" + device_bytes, upstream_bytes = self._relay(upstream) + self.server.events.record( + "relay_terminal", + device_to_upstream_bytes=device_bytes, + upstream_to_device_bytes=upstream_bytes, + ) + finally: + upstream.close() + except TimeoutError: + self.server.events.record("bridge_failure", phase=phase, category="timeout") + return + except ConnectionError: + self.server.events.record("bridge_failure", phase=phase, category="connection") + return + except OSError: + self.server.events.record("bridge_failure", phase=phase, category="os") + return + + def _read_request(self) -> bytes: + data = bytearray() + while b"\r\n\r\n" not in data: + chunk = self.request.recv(min(1024, MAX_REQUEST_BYTES + 1 - len(data))) + if not chunk: + raise ConnectionError("incomplete request") + data.extend(chunk) + if len(data) > MAX_REQUEST_BYTES: + raise ConnectionError("request too large") + return bytes(data) + + @staticmethod + def _validate_request(request: bytes) -> int | None: + header, separator, trailing = request.partition(b"\r\n\r\n") + if not separator or trailing: + return 400 + try: + lines = header.decode("ascii").split("\r\n") + except UnicodeDecodeError: + return 400 + if not lines or len(lines) > 64: + return 400 + parts = lines[0].split(" ") + if len(parts) != 3 or parts[2] != "HTTP/1.1": + return 400 + method, authority, _ = parts + if method != "CONNECT": + return 405 + if authority != ALLOWED_AUTHORITY: + return 403 + hosts = [] + for line in lines[1:]: + name, colon, value = line.partition(":") + if not colon or not name or any(character.isspace() for character in name): + return 400 + if any(ord(character) < 32 or ord(character) == 127 for character in value): + return 400 + lowered = name.lower() + stripped = value.strip() + if lowered == "host": + hosts.append(stripped) + if lowered == "content-length" and stripped != "0": + return 400 + if lowered == "transfer-encoding": + return 400 + if len(hosts) != 1 or hosts[0] not in ALLOWED_HOST_HEADERS: + return 400 + return None + + def _reject(self, status: int) -> None: + reason = {400: b"Bad Request", 403: b"Forbidden", 405: b"Method Not Allowed"}[status] + self.request.sendall( + b"HTTP/1.1 " + + str(status).encode("ascii") + + b" " + + reason + + b"\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" + ) + + def _relay(self, upstream: socket.socket) -> tuple[int, int]: + def pump( + source: socket.socket, + destination: socket.socket, + direction: str, + ) -> int: + transferred = 0 + try: + while chunk := source.recv(65536): + destination.sendall(chunk) + if transferred == 0: + self.server.events.record("relay_started", direction=direction) + transferred += len(chunk) + except (ConnectionError, OSError, TimeoutError): + pass + try: + destination.shutdown(socket.SHUT_WR) + except OSError: + pass + return transferred + + upstream_to_device = [0] + def reverse_pump() -> None: + upstream_to_device[0] = pump( + upstream, + self.request, + "upstream_to_device", + ) + try: + self.request.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + reverse = threading.Thread(target=reverse_pump, daemon=True) + reverse.start() + device_to_upstream = pump( + self.request, + upstream, + "device_to_upstream", + ) + try: + upstream.shutdown(socket.SHUT_RDWR) + except OSError: + pass + reverse.join() + return device_to_upstream, upstream_to_device[0] + + +def main(argv: list[str]) -> int: + if len(argv) != 2 or argv[0] != "--events" or not argv[1]: + return 64 + events_path = Path(argv[1]) + with events_path.open("x", encoding="ascii", buffering=1) as output: + with ConnectBridge(events=EventLog(output)) as bridge: + host, port = bridge.server_address + print( + f"CORE_DEV_CONNECT_BRIDGE_READY host={host} port={port} " + f"authority={ALLOWED_AUTHORITY}", + flush=True, + ) + bridge.serve_forever(poll_interval=0.1) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))