mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
fix(android): qualify background capabilities and emulator coverage
Deliver install confirmation, live core status, location foreground-service state and WallpaperService metadata. Add the SDK instrumentation smoke across API 26/29/30/33/36, keep size debt within the existing manifest, and document Android consent and provider-URI custody.
This commit is contained in:
parent
bdb534db36
commit
2d2f3250fb
17 changed files with 308 additions and 84 deletions
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
|
|
@ -828,7 +828,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
api-level: [26, 30, 33, 36]
|
||||
api-level: [26, 29, 30, 33, 36]
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
|
|
@ -842,6 +842,10 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-ouroboros-kvm.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" \
|
||||
"emulator" "system-images;android-${{ matrix.api-level }};default;x86_64"
|
||||
echo "no" | avdmanager create avd --force --name "obo-api-${{ matrix.api-level }}" \
|
||||
|
|
@ -858,6 +862,11 @@ jobs:
|
|||
--out "$ROOT/build" --keystore "$ROOT/test.keystore" --key-alias ouroboros-host \
|
||||
--keystore-pass-file "$ROOT/password" --create-development-key \
|
||||
--version-code "$GITHUB_RUN_NUMBER" --version-name "$(cat VERSION)"
|
||||
python android/host/build.py --sdk "$ANDROID_HOME" --java-home "$JAVA_HOME" \
|
||||
--out "$ROOT/device" --project android/tests/device \
|
||||
--keystore "$ROOT/test.keystore" --key-alias ouroboros-host \
|
||||
--keystore-pass-file "$ROOT/password" \
|
||||
--version-code "$GITHUB_RUN_NUMBER" --version-name "$(cat VERSION)"
|
||||
- name: Boot emulator and smoke install/start
|
||||
uses: reactivecircus/android-emulator-runner@e93c997dcab69054e999e3924af3d21c4a736e13 # v2.9.0
|
||||
with:
|
||||
|
|
@ -867,10 +876,24 @@ jobs:
|
|||
emulator-options: -no-window -no-audio -no-boot-anim
|
||||
disable-animations: true
|
||||
script: |
|
||||
set -euo pipefail
|
||||
adb install -r "$RUNNER_TEMP/android-smoke/build/Ouroboros.apk"
|
||||
adb install -r "$RUNNER_TEMP/android-smoke/device/app.apk"
|
||||
adb shell am start -n ai.ouroboros.android/.MainActivity
|
||||
sleep 2
|
||||
adb shell pm path ai.ouroboros.android
|
||||
adb shell dumpsys package ai.ouroboros.android | grep -E 'versionName|versionCode'
|
||||
adb shell am force-stop ai.ouroboros.android
|
||||
adb logcat -c
|
||||
set +e
|
||||
timeout 60s adb shell am instrument -w ai.ouroboros.android.device.smoke/ai.ouroboros.android.device.DeviceSdkSmoke >"$RUNNER_TEMP/android-smoke/instrument.txt" 2>&1
|
||||
instrument_status=$?
|
||||
set -e
|
||||
cat "$RUNNER_TEMP/android-smoke/instrument.txt"
|
||||
adb logcat -d -s OuroborosDeviceSdkSmoke:I '*:S' | tee "$RUNNER_TEMP/android-smoke/device-log.txt"
|
||||
grep -q 'OBO_DEVICE_SDK_SMOKE=PASS' "$RUNNER_TEMP/android-smoke/instrument.txt" \
|
||||
|| grep -q 'OBO_DEVICE_SDK_SMOKE=PASS' "$RUNNER_TEMP/android-smoke/device-log.txt"
|
||||
test "$instrument_status" -eq 0
|
||||
|
||||
# The publisher key is used only on the trusted tag path. Root/boot/device
|
||||
# acceptance is separate from these exact-byte artifact checks.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,24 @@ Examples: {"method":"capabilities"}, {"method":"packages.inspect","params":{
|
|||
Package installs are asynchronous: a successful call proves bytes were staged and commit was submitted,
|
||||
not that Android completed the install. Query packages.install.status and packages.sessions. A lost response
|
||||
is unknown and is never retried automatically.
|
||||
source_uri must be readable by the Android app UID, not just Linux root.
|
||||
To pass a Linux download, use content.write to transfer bytes to a writable
|
||||
provider URI (an app-owned MediaStore entry or a granted document), then install
|
||||
that URI. mode='wt' replaces contents; mode='wa' appends a subsequent chunk.
|
||||
Root android-exec remains a separate native installation path.
|
||||
|
||||
location.state reports grants/providers. location.get accepts provider and
|
||||
timeout_ms (1000..30000); its result includes age_ms and mock when a fix exists.
|
||||
accessibility.windows accepts max_windows, max_nodes and max_depth; node_address
|
||||
identifies a path in that snapshot, so inspect the current UI before acting.
|
||||
accessibility.perform accepts action=click/set_text/scroll plus node_address;
|
||||
set_text also needs text; scroll uses direction=forward/backward. Global actions
|
||||
are back/home/notifications/quick_settings. action=gesture accepts gesture=tap/swipe,
|
||||
x/y, and for swipe x2/y2 and duration_ms.
|
||||
notifications.list defaults to metadata; include_text=true requests title/text.
|
||||
packages.inspect on the host package lists declared activities/services. A declared
|
||||
Quick Settings tile or wallpaper service is not proof that it is added or selected.
|
||||
|
||||
Intent params: action, data, type (MIME), package, component (package/class),
|
||||
categories, numeric flags, extras. Extras and ContentValues use {"type":...,"value":...}
|
||||
per key, e.g. {"length":{"type":"int","value":900}}. Bytes are base64, long values
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
|
@ -44,7 +46,7 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".CoreService" android:exported="false" android:process=":native"
|
||||
android:foregroundServiceType="specialUse">
|
||||
android:foregroundServiceType="specialUse|location">
|
||||
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Owner control and status for the phone-resident Ouroboros agent" />
|
||||
</service>
|
||||
|
|
@ -63,6 +65,7 @@
|
|||
</service>
|
||||
<service android:name=".OuroborosWallpaperService" android:exported="true"
|
||||
android:permission="android.permission.BIND_WALLPAPER">
|
||||
<meta-data android:name="android.service.wallpaper" android:resource="@xml/wallpaper_service" />
|
||||
<intent-filter>
|
||||
<action android:name="android.service.wallpaper.WallpaperService" />
|
||||
</intent-filter>
|
||||
|
|
@ -85,7 +88,6 @@
|
|||
android:exported="false" android:process=":native">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.USER_UNLOCKED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
|
|
|||
3
android/host/res/xml/wallpaper_service.xml
Normal file
3
android/host/res/xml/wallpaper_service.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:thumbnail="@drawable/icon" />
|
||||
|
|
@ -230,6 +230,7 @@ final class AndroidBridge implements Closeable {
|
|||
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL);
|
||||
params.setInstallReason(PackageManager.INSTALL_REASON_USER);
|
||||
params.setSize(digest.size);
|
||||
int sessionId = installer.createSession(params);
|
||||
PackageInstaller.Session session = installer.openSession(sessionId);
|
||||
boolean commitSubmitted = false;
|
||||
|
|
@ -295,8 +296,9 @@ final class AndroidBridge implements Closeable {
|
|||
.put("package", nullable(info.getAppPackageName())).put("label", nullable(info.getAppLabel()))
|
||||
.put("active", info.isActive()).put("sealed", info.isSealed())
|
||||
.put("staged", Build.VERSION.SDK_INT >= 29 && info.isStaged())
|
||||
.put("progress", info.getProgress()).put("size_bytes", info.getSize());
|
||||
if (Build.VERSION.SDK_INT >= 29) result.put("created_ms", info.getCreatedMillis());
|
||||
.put("progress", info.getProgress());
|
||||
result.put("size_bytes", Build.VERSION.SDK_INT >= 27 ? info.getSize() : JSONObject.NULL);
|
||||
if (Build.VERSION.SDK_INT >= 30) result.put("created_ms", info.getCreatedMillis());
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -330,6 +332,7 @@ final class AndroidBridge implements Closeable {
|
|||
return new JSONObject().put("protocol", 1).put("sdk", Build.VERSION.SDK_INT)
|
||||
.put("package", context.getPackageName()).put("uid", android.os.Process.myUid())
|
||||
.put("methods", new JSONArray(Arrays.asList(METHODS))).put("permissions", permissions(own, pm))
|
||||
.put("can_request_package_installs", pm.canRequestPackageInstalls())
|
||||
.put("typed_values", "null,string,boolean,int,long,float,double,uri,bytes,string[],int[],long[],bundle")
|
||||
.put("provider_authority", "host_app_uid; root caller does not bypass Android provider permissions")
|
||||
.put("root_commands", "android-exec").put("source_total_known", false)
|
||||
|
|
@ -351,6 +354,8 @@ final class AndroidBridge implements Closeable {
|
|||
android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED)
|
||||
.put("permission_coarse", context.checkSelfPermission(
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED)
|
||||
.put("permission_background", Build.VERSION.SDK_INT < 29 || context.checkSelfPermission(
|
||||
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED)
|
||||
.put("providers", providers).put("source_total_known", false);
|
||||
}
|
||||
|
||||
|
|
@ -382,9 +387,16 @@ final class AndroidBridge implements Closeable {
|
|||
manager.getCurrentLocation(provider, cancellation, calls, location -> {
|
||||
result.set(location); done.countDown();
|
||||
});
|
||||
boolean completed = done.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
if (!completed) cancellation.cancel();
|
||||
return locationResult(result.get(), completed, provider);
|
||||
boolean completed;
|
||||
try { completed = done.await(timeoutMs, TimeUnit.MILLISECONDS); }
|
||||
finally { cancellation.cancel(); }
|
||||
Location location = result.get();
|
||||
if (location == null) return new JSONObject().put("provider", provider)
|
||||
.put("available", false).put("fresh", false)
|
||||
.put("reason", completed ? "provider_returned_null" : "no_fix_within_timeout")
|
||||
.put("permission_background", Build.VERSION.SDK_INT < 29 || context.checkSelfPermission(
|
||||
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED);
|
||||
return locationResult(location, true, provider);
|
||||
}
|
||||
|
||||
private static JSONObject locationResult(Location location, boolean fresh, String provider) throws Exception {
|
||||
|
|
@ -393,7 +405,9 @@ final class AndroidBridge implements Closeable {
|
|||
if (location == null) return result.put("reason", fresh ? "no_fix_within_timeout" : "no_last_known_fix");
|
||||
return result.put("latitude", location.getLatitude()).put("longitude", location.getLongitude())
|
||||
.put("accuracy_m", location.hasAccuracy() ? location.getAccuracy() : JSONObject.NULL)
|
||||
.put("time_ms", location.getTime());
|
||||
.put("time_ms", location.getTime()).put("mock", location.isFromMockProvider())
|
||||
.put("age_ms", Math.max(0L, (android.os.SystemClock.elapsedRealtimeNanos()
|
||||
- location.getElapsedRealtimeNanos()) / 1000000L));
|
||||
}
|
||||
|
||||
private static JSONArray permissions(PackageInfo info, PackageManager pm) throws Exception {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ public final class CoreService extends Service {
|
|||
// A late automatic check must not cancel a queued owner Start or Panic.
|
||||
int request = ("start".equals(action) || "panic".equals(action))
|
||||
? operation.incrementAndGet() : operation.get();
|
||||
startForeground(1, notification("Проверяю состояние ядра"));
|
||||
startForegroundOwnerNotification();
|
||||
if (!"panic".equals(action)) {
|
||||
currentNetwork = connectivity.getActiveNetwork();
|
||||
updateNetworkDns(currentNetwork == null ? null : connectivity.getLinkProperties(currentNetwork));
|
||||
|
|
@ -159,6 +159,21 @@ public final class CoreService extends Service {
|
|||
return "panic".equals(action) ? START_NOT_STICKY : START_STICKY;
|
||||
}
|
||||
|
||||
private void startForegroundOwnerNotification() {
|
||||
Notification value = notification("Проверяю состояние ядра");
|
||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
||||
int type = android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE;
|
||||
boolean background = checkSelfPermission(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED;
|
||||
if (background && (checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|| checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== android.content.pm.PackageManager.PERMISSION_GRANTED))
|
||||
type |= android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION;
|
||||
startForeground(1, value, type);
|
||||
} else startForeground(1, value);
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
closed = true; dnsRevision.incrementAndGet();
|
||||
if (networkCallback != null) connectivity.unregisterNetworkCallback(networkCallback);
|
||||
|
|
|
|||
7
android/tests/device/AndroidManifest.xml
Normal file
7
android/tests/device/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ai.ouroboros.android.device.smoke">
|
||||
<uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" />
|
||||
<instrumentation android:name="ai.ouroboros.android.device.DeviceSdkSmoke"
|
||||
android:targetPackage="ai.ouroboros.android"
|
||||
android:label="Ouroboros Android SDK smoke" />
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package ai.ouroboros.android.device;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Instrumentation;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInstaller;
|
||||
import android.net.LocalSocket;
|
||||
import android.net.LocalSocketAddress;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import java.io.Closeable;
|
||||
import java.lang.reflect.Method;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Small on-device contract check for SDK-gated PackageInstaller readback.
|
||||
*
|
||||
* Own a bridge instance in the instrumented host process, create a session
|
||||
* under its UID and call the actual RPC over the authenticated local socket.
|
||||
* No APK bytes are committed or installed; no CoreService or root is needed.
|
||||
*/
|
||||
public final class DeviceSdkSmoke extends Instrumentation {
|
||||
private static final String TAG = "OuroborosDeviceSdkSmoke";
|
||||
private static final String PASS = "OBO_DEVICE_SDK_SMOKE=PASS";
|
||||
private static final String FAIL = "OBO_DEVICE_SDK_SMOKE=FAIL";
|
||||
|
||||
@Override public void onCreate(Bundle arguments) {
|
||||
super.onCreate(arguments);
|
||||
start();
|
||||
}
|
||||
|
||||
@Override public void onStart() {
|
||||
super.onStart();
|
||||
Bundle result = new Bundle();
|
||||
int code = Activity.RESULT_OK;
|
||||
int sessionId = -1;
|
||||
Closeable bridge = null;
|
||||
try {
|
||||
Context target = getTargetContext();
|
||||
Class<?> bridgeClass = target.getClassLoader().loadClass("ai.ouroboros.android.AndroidBridge");
|
||||
Method startBridge = bridgeClass.getDeclaredMethod("start", Context.class);
|
||||
startBridge.setAccessible(true);
|
||||
bridge = (Closeable) startBridge.invoke(null, target);
|
||||
PackageInstaller installer = target.getPackageManager().getPackageInstaller();
|
||||
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL);
|
||||
sessionId = installer.createSession(params);
|
||||
PackageInstaller.Session session = installer.openSession(sessionId);
|
||||
session.close();
|
||||
|
||||
JSONObject capabilities = rpc("capabilities");
|
||||
if (!capabilities.getJSONArray("methods").toString().contains("packages.sessions"))
|
||||
throw new AssertionError("capabilities omitted packages.sessions");
|
||||
if (!capabilities.has("can_request_package_installs"))
|
||||
throw new AssertionError("capabilities omitted install special-access state");
|
||||
JSONObject snapshot = rpc("packages.sessions");
|
||||
JSONArray rows = snapshot.getJSONArray("rows");
|
||||
boolean found = false;
|
||||
for (int i = 0; i < rows.length(); i++) {
|
||||
if (rows.getJSONObject(i).optInt("session_id", -1) == sessionId) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) throw new AssertionError("created session missing from packages.sessions");
|
||||
// The system picker parses WallpaperInfo; a declared service alone is insufficient.
|
||||
boolean wallpaperFound = false;
|
||||
for (android.content.pm.ResolveInfo info : target.getPackageManager().queryIntentServices(
|
||||
new android.content.Intent(android.service.wallpaper.WallpaperService.SERVICE_INTERFACE)
|
||||
.setPackage(target.getPackageName()), android.content.pm.PackageManager.GET_META_DATA)) {
|
||||
new android.app.WallpaperInfo(target, info);
|
||||
wallpaperFound = true;
|
||||
}
|
||||
if (!wallpaperFound) throw new AssertionError("host wallpaper metadata is unavailable");
|
||||
result.putString("obo_marker", PASS);
|
||||
result.putInt("session_id", sessionId);
|
||||
result.putInt("sdk", android.os.Build.VERSION.SDK_INT);
|
||||
} catch (Throwable error) {
|
||||
code = Activity.RESULT_CANCELED;
|
||||
result.putString("obo_marker", FAIL);
|
||||
result.putString("error", error.toString());
|
||||
Log.e(TAG, FAIL + " sdk=" + android.os.Build.VERSION.SDK_INT, error);
|
||||
} finally {
|
||||
if (sessionId >= 0) {
|
||||
try { getTargetContext().getPackageManager().getPackageInstaller().abandonSession(sessionId); }
|
||||
catch (Throwable error) {
|
||||
code = Activity.RESULT_CANCELED;
|
||||
result.putString("obo_marker", FAIL);
|
||||
result.putString("cleanup_error", error.toString());
|
||||
Log.e(TAG, FAIL + " could not abandon smoke session " + sessionId, error);
|
||||
}
|
||||
}
|
||||
if (bridge != null) {
|
||||
try { bridge.close(); }
|
||||
catch (Throwable error) {
|
||||
code = Activity.RESULT_CANCELED;
|
||||
result.putString("obo_marker", FAIL);
|
||||
result.putString("bridge_cleanup_error", error.toString());
|
||||
Log.e(TAG, FAIL + " could not close smoke bridge", error);
|
||||
}
|
||||
}
|
||||
if (code == Activity.RESULT_OK) Log.i(TAG, PASS + " sdk=" + android.os.Build.VERSION.SDK_INT
|
||||
+ " session=" + sessionId);
|
||||
sendStatus(2, result);
|
||||
finish(code, result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Call the running host bridge over its authenticated local socket. */
|
||||
private static JSONObject rpc(String method) throws Exception {
|
||||
LocalSocket socket = new LocalSocket();
|
||||
try {
|
||||
socket.setSoTimeout(10000);
|
||||
socket.connect(new LocalSocketAddress("ai.ouroboros.android.rpc",
|
||||
LocalSocketAddress.Namespace.ABSTRACT));
|
||||
socket.getOutputStream().write((new JSONObject().put("id", 1).put("method", method)
|
||||
.put("params", new JSONObject()).toString() + "\n").getBytes("UTF-8"));
|
||||
socket.getOutputStream().flush();
|
||||
StringBuilder response = new StringBuilder();
|
||||
int value;
|
||||
while ((value = socket.getInputStream().read()) != -1 && value != '\n') {
|
||||
if (response.length() >= 1024 * 1024) throw new AssertionError("oversized bridge response");
|
||||
response.append((char) value);
|
||||
}
|
||||
JSONObject envelope = new JSONObject(response.toString());
|
||||
if (!envelope.optBoolean("ok", false)) throw new AssertionError(envelope.opt("error"));
|
||||
return envelope.getJSONObject("result");
|
||||
} finally { socket.close(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,11 @@ class AndroidHostTest(unittest.TestCase):
|
|||
self.assertIn("android.intent.action.LOCKED_BOOT_COMPLETED", {
|
||||
row.get(A + "name") for row in receiver.findall("intent-filter/action")
|
||||
})
|
||||
wallpaper = services[".OuroborosWallpaperService"]
|
||||
metadata = wallpaper.find("meta-data")
|
||||
self.assertEqual(metadata.get(A + "name"), "android.service.wallpaper")
|
||||
self.assertEqual(metadata.get(A + "resource"), "@xml/wallpaper_service")
|
||||
self.assertEqual(ET.parse(HOST / "res/xml/wallpaper_service.xml").getroot().tag, "wallpaper")
|
||||
|
||||
def test_location_bridge_has_state_and_bounded_current_fix_methods(self):
|
||||
source = (HOST / "src/ai/ouroboros/android/AndroidBridge.java").read_text()
|
||||
|
|
@ -45,6 +50,8 @@ class AndroidHostTest(unittest.TestCase):
|
|||
self.assertIn('"location.get"', source)
|
||||
self.assertIn("getCurrentLocation", source)
|
||||
self.assertIn("no_fix_within_timeout", source)
|
||||
self.assertIn("provider_returned_null", source)
|
||||
self.assertIn("permission_background", source)
|
||||
|
||||
def test_cleartext_is_loopback_only(self):
|
||||
manifest = ET.parse(HOST / "AndroidManifest.xml").getroot()
|
||||
|
|
@ -85,11 +92,28 @@ class AndroidHostTest(unittest.TestCase):
|
|||
self.assertIn("pending_user_action", callback)
|
||||
self.assertNotIn("startActivity", callback)
|
||||
|
||||
def test_device_sdk_smoke_harness_has_target_and_explicit_pass_marker(self):
|
||||
device = Path(__file__).resolve().parents[1] / "tests" / "device"
|
||||
manifest = ET.parse(device / "AndroidManifest.xml").getroot()
|
||||
instrumentation = manifest.find("instrumentation")
|
||||
self.assertIsNotNone(instrumentation)
|
||||
self.assertEqual(instrumentation.get(A + "targetPackage"), "ai.ouroboros.android")
|
||||
self.assertEqual(instrumentation.get(A + "name"),
|
||||
"ai.ouroboros.android.device.DeviceSdkSmoke")
|
||||
source = (device / "src/ai/ouroboros/android/device/DeviceSdkSmoke.java").read_text()
|
||||
self.assertIn("OBO_DEVICE_SDK_SMOKE=PASS", source)
|
||||
self.assertIn("packages.sessions", source)
|
||||
workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/ci.yml").read_text()
|
||||
self.assertIn("api-level: [26, 29, 30, 33, 36]", workflow)
|
||||
self.assertIn("OBO_DEVICE_SDK_SMOKE=PASS", workflow)
|
||||
|
||||
def test_alarm_and_initial_data_capabilities_are_declared(self):
|
||||
manifest = ET.parse(HOST / "AndroidManifest.xml").getroot()
|
||||
permissions = {row.get(A + "name"): row for row in manifest.findall("uses-permission")}
|
||||
self.assertIn("com.android.alarm.permission.SET_ALARM", permissions)
|
||||
self.assertIn("android.permission.ACCESS_NETWORK_STATE", permissions)
|
||||
self.assertIn("android.permission.ACCESS_BACKGROUND_LOCATION", permissions)
|
||||
self.assertIn("android.permission.FOREGROUND_SERVICE_LOCATION", permissions)
|
||||
self.assertEqual(permissions["android.permission.READ_EXTERNAL_STORAGE"].get(A + "maxSdkVersion"), "32")
|
||||
for permission in ("READ_CONTACTS", "WRITE_CONTACTS", "READ_CALENDAR", "WRITE_CALENDAR",
|
||||
"CAMERA", "RECORD_AUDIO", "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"):
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import pytest
|
|||
pytestmark = pytest.mark.serial
|
||||
|
||||
STUBS = {
|
||||
"android/Manifest.java": "package android; public final class Manifest { public static final class permission { public static final String ACCESS_BACKGROUND_LOCATION=\"android.permission.ACCESS_BACKGROUND_LOCATION\", ACCESS_COARSE_LOCATION=\"android.permission.ACCESS_COARSE_LOCATION\", ACCESS_FINE_LOCATION=\"android.permission.ACCESS_FINE_LOCATION\"; }}",
|
||||
"android/R.java": "package android; public final class R { public static class drawable { public static final int ic_menu_manage=1; }}",
|
||||
"android/content/Intent.java": """package android.content; public class Intent {
|
||||
private String action; public Intent() {} public Intent(Object c, Class<?> cls) {}
|
||||
|
|
@ -18,6 +19,8 @@ STUBS = {
|
|||
"android/os/Looper.java": "package android.os; public class Looper {}",
|
||||
"android/os/Handler.java": "package android.os; public class Handler { public Handler(Looper l) {} }",
|
||||
"android/os/Build.java": "package android.os; public class Build { public static class VERSION { public static int SDK_INT=36; }}",
|
||||
"android/content/pm/PackageManager.java": "package android.content.pm; public class PackageManager { public static final int PERMISSION_GRANTED=0; }",
|
||||
"android/content/pm/ServiceInfo.java": "package android.content.pm; public class ServiceInfo { public static final int FOREGROUND_SERVICE_TYPE_LOCATION=8, FOREGROUND_SERVICE_TYPE_SPECIAL_USE=1073741824; }",
|
||||
"android/os/SystemClock.java": """package android.os; public class SystemClock {
|
||||
private static long now; public static long elapsedRealtime() { now+=120000; return now; }}""",
|
||||
"android/net/Network.java": "package android.net; public class Network {}",
|
||||
|
|
@ -56,12 +59,15 @@ STUBS = {
|
|||
"android/app/Service.java": """package android.app; public class Service {
|
||||
public static final int START_STICKY=1, START_NOT_STICKY=2, STOP_FOREGROUND_REMOVE=1, STOP_FOREGROUND_DETACH=2;
|
||||
public final NotificationManager notifications=new NotificationManager();
|
||||
public final java.util.Set<String> grants=new java.util.HashSet<>(); public int foregroundType;
|
||||
public int checkSelfPermission(String permission) { return grants.contains(permission) ? 0 : -1; }
|
||||
public <T> T getSystemService(Class<T> cls) { return cls.cast(cls==NotificationManager.class
|
||||
? notifications : new android.net.ConnectivityManager()); }
|
||||
public android.os.Looper getMainLooper() { return new android.os.Looper(); }
|
||||
public void onCreate() {} public void onDestroy() {}
|
||||
public int onStartCommand(android.content.Intent i,int flags,int id) { return 0; }
|
||||
public void startForeground(int id,Notification n) { notifications.messages.add(n.text); }
|
||||
public void startForeground(int id,Notification n) { foregroundType=0; notifications.messages.add(n.text); }
|
||||
public void startForeground(int id,Notification n,int type) { foregroundType=type; notifications.messages.add(n.text); }
|
||||
public void stopForeground(int flags) {} public void stopSelf(int id) {}
|
||||
public android.os.IBinder onBind(android.content.Intent i) { return null; }}""",
|
||||
"org/json/JSONObject.java": """package org.json; public class JSONObject {
|
||||
|
|
@ -153,6 +159,16 @@ public class LifecycleTest {
|
|||
} else if (scenario.equals("sticky_status")) {
|
||||
send(service,null,1); work.drain();
|
||||
require(RuntimeClient.calls.stream().noneMatch(c->c.startsWith("start:")),"sticky restoration replayed Start");
|
||||
} else if (scenario.equals("foreground_special_only") || scenario.equals("foreground_location")) {
|
||||
if (scenario.equals("foreground_location")) {
|
||||
service.grants.add("android.permission.ACCESS_BACKGROUND_LOCATION");
|
||||
service.grants.add("android.permission.ACCESS_COARSE_LOCATION");
|
||||
}
|
||||
send(service,"status",1); work.drain();
|
||||
int special=android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE;
|
||||
require((service.foregroundType & special) != 0,"special-use foreground type missing");
|
||||
require(((service.foregroundType & android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) != 0)
|
||||
== scenario.equals("foreground_location"),"location foreground type did not follow background grant");
|
||||
} else {
|
||||
String[] actions=scenario.split("_"); send(service,actions[0],1); send(service,actions[1],2);
|
||||
urgent.drain(); work.drain();
|
||||
|
|
@ -212,3 +228,8 @@ def test_service_orders_owner_and_automatic_intents(lifecycle_java, scenario):
|
|||
"panic_during_start_observation"])
|
||||
def test_start_observation_is_distinct_from_action_failure(lifecycle_java, scenario):
|
||||
lifecycle_java(scenario)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["foreground_special_only", "foreground_location"])
|
||||
def test_foreground_service_type_discloses_background_location_grant(lifecycle_java, scenario):
|
||||
lifecycle_java(scenario)
|
||||
|
|
|
|||
|
|
@ -202,9 +202,21 @@ Three independent permissions are involved:
|
|||
setup and the native menu reopens it. Android settings retain denial, revocation
|
||||
and available limited-access choices. Root-side tools retain their separate,
|
||||
broader authority.
|
||||
- On Android 10 and later, unattended location needs the separate
|
||||
**Allow all the time** grant. After granting it, start or refresh native
|
||||
status so its foreground-service type includes location. Without that grant,
|
||||
Android may restrict background requests; foreground queries remain available.
|
||||
- **Provider/account authorization** permits model and service access through
|
||||
the accounts you configure. Neither root nor an APK certificate supplies it.
|
||||
|
||||
`location.get` reports the location's timestamp, age and mock-provider flag so a
|
||||
cached or test fix is distinguishable from a hardware observation. A timed-out
|
||||
current request reports `no_fix_within_timeout`; a completed request without a
|
||||
fix reports `provider_returned_null` and includes the background-grant state.
|
||||
Neither result diagnoses the sensor or platform policy. Older last-known-only
|
||||
reads can report `no_last_known_fix`. An SDK test-provider fix exercises callback
|
||||
delivery but does not qualify GPS reception on a physical device.
|
||||
|
||||
In Settings → Available subagents, each coding session has its own access choice:
|
||||
**Working files** (`workspace_write`, the default) or **Full system access** (`full`).
|
||||
The tested Android kernel cannot create Codex's native sandbox, so a Codex coding
|
||||
|
|
@ -233,6 +245,12 @@ notification that opens Android's original confirmation screen when tapped;
|
|||
it remains incomplete until the system reports success or failure. Keep
|
||||
Ouroboros notifications enabled for this handoff. A disabled or failed notification
|
||||
is reported in `confirmation_delivery`; it is not successful consent.
|
||||
For this path, enable **Install unknown apps** for Ouroboros in Android settings;
|
||||
`capabilities.can_request_package_installs` reads that special access separately
|
||||
from ordinary runtime permissions. The host can read only source URIs available
|
||||
to its app UID. A Linux download can be written to a writable Android provider
|
||||
URI with `content.write` and installed from that URI; a root-readable chroot path
|
||||
does not itself grant the Android app access.
|
||||
|
||||
The manifest intentionally declares `QUERY_ALL_PACKAGES` for general installed-app
|
||||
discovery and component inspection through Android's PackageManager. This is a
|
||||
|
|
|
|||
|
|
@ -3137,8 +3137,17 @@ Keep reusable large downloads in the installer's durable cache.
|
|||
`android-test` explicitly collects `android/tests`; ordinary `pytest tests/` does
|
||||
not cover that directory. Portable source/transport fixtures and host compilation
|
||||
are separate from physical root, boot, permissions, hardware and battery evidence.
|
||||
The same-key instrumentation under `android/tests/device` owns a temporary SDK
|
||||
bridge and an uncommitted PackageInstaller session. The emulator job executes
|
||||
session readback on API 26/29/30/33/36 and accepts its explicit PASS only after
|
||||
abandon and bridge cleanup. It requires neither root nor a provisioned Linux
|
||||
core; it does not certify the phone bootstrap or owner consent UI.
|
||||
Android release source/APK SBOMs describe those shipped bytes; installed dependency
|
||||
pins/package inventories describe the provisioned phone. Neither invents the other.
|
||||
The trusted tag-only `android-build` job reads `ANDROID_KEYSTORE_BASE64`,
|
||||
`ANDROID_KEYSTORE_PASSWORD`, and `ANDROID_KEY_ALIAS` from repository secrets;
|
||||
the branch/PR Android jobs use a disposable key and never publish it. A PR is
|
||||
therefore source/build evidence, not a publisher-signed release claim.
|
||||
|
||||
## Platform Abstraction Rule
|
||||
|
||||
|
|
@ -3322,8 +3331,11 @@ and `web/tests/chat_history_integration.test.js`.
|
|||
|
||||
The Project work pointer is a navigation component over the existing Chat card
|
||||
registry (`project_work_pointer.js`), updated inside the same viewport mutation
|
||||
transaction. Preserve its loaded-window coverage disclosure; a represented
|
||||
unfinished card is not independent proof of current execution. Its click changes
|
||||
transaction. Its label names the card on one line (`projectWorkLabel`: coined
|
||||
name, else title, capped; the `.project-work-pointer-label` CSS ellipsizes) and
|
||||
never restates the card's full status headline; without a represented root card it is
|
||||
hidden, not shown disabled. Preserve its loaded-window coverage disclosure; a
|
||||
represented unfinished card is not independent proof of current execution. Its click changes
|
||||
only the messages container's scroll position and existing reading intent, never
|
||||
message routing. Dispose it with the chat; do not add a second card tree, poller
|
||||
or task-state store for this navigation affordance.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ def _loop():
|
|||
of freezing whatever object a from-import saw at import time.
|
||||
"""
|
||||
from ouroboros import loop
|
||||
|
||||
return loop
|
||||
|
||||
|
||||
|
|
@ -100,7 +99,6 @@ def announce_acceptance_settlement(usage_ctx: Any, request: Any, wave: dict) ->
|
|||
if usage_ctx is None or not getattr(usage_ctx, "drive_root", None):
|
||||
return
|
||||
from ouroboros.owner_mailbox import write_task_message
|
||||
|
||||
try:
|
||||
slots = wave.get("slots") or {}
|
||||
write_task_message(
|
||||
|
|
@ -117,7 +115,6 @@ def announce_acceptance_settlement(usage_ctx: Any, request: Any, wave: dict) ->
|
|||
def prepare_acceptance_observation(ctx: Any, trace: dict, incoming: Any, messages: list, tool_schemas: list) -> None:
|
||||
"""Present the current owner-source selector immediately before Main's send."""
|
||||
from ouroboros.loop_acceptance import capture_acceptance_observation, acceptance_observation_prompt
|
||||
|
||||
observed = capture_acceptance_observation(ctx, trace, incoming)
|
||||
if (_loop().get_task_review_mode() not in {"auto", "required"}
|
||||
or not any(row.get("function", {}).get("name") == "task_acceptance_review" for row in tool_schemas)):
|
||||
|
|
@ -128,7 +125,6 @@ def prepare_acceptance_observation(ctx: Any, trace: dict, incoming: Any, message
|
|||
# successful cognitive tool call it can otherwise answer the selector itself,
|
||||
# replacing the natural conversational response with acceptance bookkeeping.
|
||||
from ouroboros.task_results import resolve_task_lineage
|
||||
|
||||
meta = getattr(ctx, "task_metadata", {})
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
lineage = resolve_task_lineage(
|
||||
|
|
@ -171,7 +167,6 @@ def wait_for_acceptance_feedback(tools: Any, limit_ctx: Any, trace: dict,
|
|||
if not getattr(getattr(ctx, "_delivery_candidate", None), "control_episode_seen", False):
|
||||
_loop()._arm_delivery_control(tools, limit_ctx, trace)
|
||||
from ouroboros.owner_wait import wait_after_tools
|
||||
|
||||
wait_after_tools(ctx, limit_ctx.messages, trace, limit_ctx.accumulated_usage,
|
||||
limit_ctx.round_idx, tool_schemas, seen, review_binding=binding)
|
||||
|
||||
|
|
@ -184,7 +179,6 @@ def advance_explicit_acceptance(tools: Any, limit_ctx: Any, trace: dict,
|
|||
return
|
||||
tools._ctx._acceptance_request_pending = None
|
||||
from ouroboros.loop_delivery import apply_delivery_subject_decision
|
||||
|
||||
subject = request.get("acceptance_subject")
|
||||
if subject is not None:
|
||||
ok, reason = apply_delivery_subject_decision(tools, limit_ctx, trace, subject)
|
||||
|
|
@ -277,7 +271,6 @@ def _build_host_acceptance_evidence(ctx: _TaskAcceptanceContext) -> Dict[str, An
|
|||
"""Build the one bounded host packet shared by binding and reviewer input."""
|
||||
from ouroboros.review_evidence import build_task_acceptance_evidence
|
||||
from ouroboros.loop_delivery import delivery_subject_projection
|
||||
|
||||
committed_this_turn = any(
|
||||
isinstance(call, dict)
|
||||
and str(call.get("tool") or "") in ("commit_reviewed", "vcs_commit_reviewed")
|
||||
|
|
@ -312,7 +305,6 @@ def _total_paid_acceptance_cycles(ctx: _TaskAcceptanceContext) -> Any:
|
|||
SAME ledger the wallet claim counts (``claimed_cycles``); ``None`` when the
|
||||
projection is unavailable (a descendant that may observe but not initialize)."""
|
||||
from ouroboros.task_results import project_task_acceptance_review_capacity
|
||||
|
||||
return project_task_acceptance_review_capacity(
|
||||
ctx.tools._ctx, task_id=str(ctx.task_id or ""),
|
||||
).get("claimed_cycles")
|
||||
|
|
@ -328,7 +320,6 @@ _RETRIEVING_ACCESS_DISCLOSURE = (
|
|||
|
||||
def _retrieving_packet_projection(evidence: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from ouroboros.review_dispatch import retrieving_acceptance_packet
|
||||
|
||||
return retrieving_acceptance_packet(evidence)
|
||||
|
||||
|
||||
|
|
@ -350,7 +341,6 @@ def acceptance_retrieving_work_order(
|
|||
from ouroboros.artifacts import task_artifact_dir_path
|
||||
from ouroboros.outcome_receipt_store import verification_receipts_path
|
||||
from ouroboros.review_execution import ReviewRouteKind, _render_prompt_parts, review_output_contract
|
||||
|
||||
request.session_root = session_root
|
||||
request.policy["output_contract"] = review_output_contract(request)
|
||||
request.policy["native_data_root"] = str(data_root)
|
||||
|
|
@ -420,14 +410,12 @@ def _execute_task_acceptance_panel(ctx: _TaskAcceptanceContext) -> Any:
|
|||
run_zero_physical_task_acceptance as _free_dispatch,
|
||||
task_acceptance_preclaim_refusal,
|
||||
)
|
||||
|
||||
def _refused(reason: str) -> Any:
|
||||
return ReviewRunResult(
|
||||
request={"surface": "task_acceptance", "task_id": str(ctx.task_id)},
|
||||
actors=[], parsed_findings=[], aggregate_signal="DEGRADED", degraded=True,
|
||||
degraded_reasons=[reason],
|
||||
)
|
||||
|
||||
evidence = ctx.evidence or _build_host_acceptance_evidence(ctx)
|
||||
try:
|
||||
# R2: the SAME triad rows every other triad surface reads — each with
|
||||
|
|
@ -490,12 +478,10 @@ def _execute_task_acceptance_panel(ctx: _TaskAcceptanceContext) -> Any:
|
|||
# R52). The per-send wallet binding at dispatch still protects money.
|
||||
from ouroboros.review_execution import ReviewRouteKind, panel_delivery_class, slot_delivery
|
||||
from ouroboros.tools.review_helpers import review_wave_budget_gate
|
||||
|
||||
paid = [slot for slot in slots if getattr(slot, "route", None) is not ReviewRouteKind.AGENT_SESSION]
|
||||
if paid:
|
||||
try:
|
||||
from ouroboros.review_substrate import _messages_char_count, _request_messages
|
||||
|
||||
_prompt_chars = max(
|
||||
len(request.slot_session_tasks.get(slot.slot_id, "")) + len(request.policy["output_contract"])
|
||||
if getattr(slot, "retrieves", False)
|
||||
|
|
@ -539,7 +525,6 @@ def _execute_task_acceptance_panel(ctx: _TaskAcceptanceContext) -> Any:
|
|||
try:
|
||||
from ouroboros.review_cycles import review_max_cycles, review_max_cycles_source
|
||||
from ouroboros.utils import append_jsonl, utc_now_iso
|
||||
|
||||
# TELEMETRY ONLY (owner R52): a panel that just cost money says what
|
||||
# bounded it, how long it ran, how many panels the tree has bought and
|
||||
# which deliveries it ran on — "21 paid panels" was invisible until
|
||||
|
|
@ -597,7 +582,6 @@ def _record_host_acceptance_run(ctx: _TaskAcceptanceContext, result: Any) -> Dic
|
|||
run_record["task_attempt"] = ctx.tools._ctx.task_attempt
|
||||
run_record.update(ctx.review_binding or {})
|
||||
from ouroboros.review_substrate import task_acceptance_is_clean
|
||||
|
||||
run_record["enforcement_impact"] = (
|
||||
"allows_completion" if task_acceptance_is_clean(result) else "degrades_completion"
|
||||
)
|
||||
|
|
@ -622,7 +606,6 @@ def _set_applied_host_acceptance_impact(
|
|||
run_record["enforcement_impact"] = "requires_revision"
|
||||
return
|
||||
from ouroboros.review_substrate import task_acceptance_is_clean
|
||||
|
||||
run_record["enforcement_impact"] = (
|
||||
"allows_completion" if task_acceptance_is_clean(result) else "degrades_completion"
|
||||
)
|
||||
|
|
@ -634,7 +617,6 @@ def _finish_cyber_acceptance(ctx: _TaskAcceptanceContext, result: Any) -> bool:
|
|||
from ouroboros.loop_delivery import delivery_subject_hash
|
||||
from ouroboros.review_records import build_author_disposition
|
||||
from ouroboros.review_substrate import build_improvement_capsule, task_acceptance_is_clean
|
||||
|
||||
pending = acceptance_run_pending(result)
|
||||
if getattr(ctx.tools._ctx, "_acceptance_review_only", False):
|
||||
if not pending and (capsule := build_improvement_capsule(result, rails_line=ctx.rails_line)):
|
||||
|
|
@ -681,7 +663,6 @@ def _finish_advisory_author(ctx: _TaskAcceptanceContext) -> bool:
|
|||
and run.get("feedback_delivered")), None)
|
||||
disposition = str(stance.get("agent_disposition") or "")
|
||||
from ouroboros.loop_delivery import delivery_evidence_fingerprint
|
||||
|
||||
if (not feedback or not intent or disposition not in {"accepted", "rejected", "partial", "deferred"}
|
||||
or intent.get("review_binding_hash") != feedback.get("binding_hash")
|
||||
or intent.get("tool_count") != len(ctx.llm_trace.get("tool_calls") or [])
|
||||
|
|
@ -689,7 +670,6 @@ def _finish_advisory_author(ctx: _TaskAcceptanceContext) -> bool:
|
|||
or intent.get("evidence_fingerprint") != delivery_evidence_fingerprint(ctx.tools._ctx, ctx.llm_trace)):
|
||||
return False
|
||||
from ouroboros.review_records import build_author_disposition
|
||||
|
||||
author = build_author_disposition(
|
||||
disposition=disposition, rationale=str(stance.get("agent_rationale") or ""),
|
||||
subject_hash=ctx.review_binding["binding_hash"],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ BASELINE_SOURCE_SHA = "77d6827b7a72a632899bb6cc64a7e759aabcfaa6"
|
|||
GIANT_PATHS = (
|
||||
"devtools/benchmarks/osworld/run_cu_bridge_agent.py",
|
||||
"devtools/benchmarks/osworld/run_step_agent.py",
|
||||
"ouroboros/loop_acceptance_review.py",
|
||||
"ouroboros/tools/git.py",
|
||||
"server.py",
|
||||
"skills/unix_computer_use/plugin.py",
|
||||
|
|
@ -99,6 +100,7 @@ BAND_BASELINE_PATHS = (
|
|||
)
|
||||
|
||||
BAND_PATHS = {
|
||||
"ouroboros/tools/delegate.py": "D07 finisher DEL1 split brought the nanny-verb monolith DOWN from the 1600 hard cap into the band (1600->1263); terminal-evidence family extracted to tools/delegate_terminal_evidence.py, shrink-only direction",
|
||||
"devtools/benchmarks/cybergym/cybergym_adapter.py": "Stateful campaign layer after the protocol split (ratchet heal); shrink next touch.",
|
||||
"devtools/benchmarks/cybergym/cybergym_docker.py": "Docker runtime layer of the executor split: one container-machinery seam.",
|
||||
"devtools/benchmarks/cybergym/cybergym_executor.py": "Executor assembly after docker/lifecycle/wire splits (ratchet heal); shrink next touch.",
|
||||
|
|
@ -155,7 +157,6 @@ BAND_PATHS = {
|
|||
"ouroboros/tools/browser.py": None,
|
||||
"ouroboros/tools/commit_gate.py": "Grew INTO the band by the review-wave fix binding the actor reference (delivery class) into the commit review contract fingerprint \u2014 same-module contract identity, splitting it would separate the fingerprint from its gate.",
|
||||
"ouroboros/tools/core.py": "D05 ledger split (rows 311-349): read/list and owner-chat delivery spans moved to core_file_tools/core_artifacts; facade re-enters the band from above (2283 -> 1373) and shrinks further when the residual catalog split lands",
|
||||
"ouroboros/tools/delegate.py": "D07 finisher DEL1 split brought the nanny-verb monolith DOWN from the 1600 hard cap into the band (1600->1263); terminal-evidence family extracted to tools/delegate_terminal_evidence.py, shrink-only direction",
|
||||
"ouroboros/tools/plan_review_runtime.py": "Entered the band from 986 lines: timeout custody synthesis joined the existing plan-review runtime owner while preserving profile-continuity disclosures and typed health facts during target integration.",
|
||||
"ouroboros/tools/registry_core.py": "F3.1 typed-organ re-split (D04 rows 156/167/170/171/174/175): the tip ToolRegistry class body re-homed whole from the protected registry facade; the guard/dispatch surface already left for its sibling leaves, and the class shrinks further only with the ABI-8 post-release handler conversion.",
|
||||
"ouroboros/tools/review.py": "D06 F2.3a re-entry by extraction: the multi-model fan-out moved to review_multi_model.py (1550->1269); the remaining single-owner review cycle machinery lands in the 1001-1500 band with headroom",
|
||||
|
|
@ -225,9 +226,9 @@ BAND_PATHS = {
|
|||
"web/modules/api_types.js": "The shared browser contract module now includes issue 265 publication-preflight types alongside the target settings and subagent contracts.",
|
||||
"web/modules/chat_activity.js": "Existing task activity renderer consumes the shared quota/auth wait state; no parallel task card or lifecycle.",
|
||||
"web/modules/harness_accounts.js": None,
|
||||
"web/modules/subagents_settings.js": "The shared Available subagents editor now carries access choice and grouped source selection; keep this UI seam together while the next extraction shrinks it.",
|
||||
"web/tests/reviewer_slots.test.js": "The reviewer-slot tests keep source selection and access serialization coverage together with their UI contract.",
|
||||
"web/modules/log_events.js": None,
|
||||
"web/modules/onboarding_wizard.js": "Multi-surface onboarding keeps its step projection and completion payload together.",
|
||||
"web/modules/reviewer_slots.js": "Owner-approved 5A editor: per-row Direct model / Configured subagent source picker with read-only derived disclosure replaces the legacy Claude-SDK advisory input in the same module that owns reviewer-row editing.",
|
||||
"web/modules/settings.js": None,
|
||||
"web/modules/skills.js": "One installed-skill page controller owns independently settling primary/optional reads and current-generation menu, identity and badge updates; domain lifecycle, cards, hub truth and shared interactions remain separate owners.",
|
||||
"web/tests/chat_instance_dom.test.js": "Entered the band from 1000 lines with the alias-free subagent cost pin (stage-2 fix wave): that regression reproduces only through the real createChatInstance card path, and this file owns the DOM harness that drives it; split when the next createChatInstance face lands.",
|
||||
|
|
@ -245,5 +246,5 @@ BYTE_BASELINE_DEBT = {
|
|||
|
||||
BYTE_DEBT = {
|
||||
"tests/test_devtools_benchmarks.py": 327840,
|
||||
"web/modules/chat.js": 203362,
|
||||
"web/modules/chat.js": 204824,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -441,9 +441,6 @@ def _delegate_start(ctx: ToolContext, prompt: str, max_seconds: Optional[int] =
|
|||
invocation_id = custody.new_invocation_id()
|
||||
root = record_auth["target_root"]
|
||||
if is_mutating_delegated_access(authority.access):
|
||||
# C1: the run executes in a private snapshot of the authority target.
|
||||
# Git/payload snapshots are registered before POST; directory copies
|
||||
# belong to the engine, with the stable target kept separate.
|
||||
target_root = record_auth["target_root"]
|
||||
authority_source = record_auth["source"]
|
||||
if authority_source == "skill_payload":
|
||||
|
|
@ -472,13 +469,9 @@ def _delegate_start(ctx: ToolContext, prompt: str, max_seconds: Optional[int] =
|
|||
scope_root = target_root if execution_root or directory_options else root
|
||||
(project_id, owned_project_id, project_persistent) = resolve_registration(
|
||||
gateway, scope_root, execution_root, getattr(authority, "access", ""))
|
||||
if directory_options:
|
||||
project_persistent = True
|
||||
project_persistent |= bool(directory_options)
|
||||
if authority.access == "full":
|
||||
gateway.ensure_full_access(scope_root)
|
||||
# Assignment plus host-authored instructions identify the invocation;
|
||||
# retries replay the stored body byte-identically.
|
||||
|
||||
seconds = _bounded_max_seconds(ctx, max_seconds)
|
||||
request_body = _start_request(ctx, route, authority, scope_root, text,
|
||||
seconds, instructions, execution_root,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ def test_android_ci_is_fork_safe_and_required_for_publication():
|
|||
def test_android_ci_has_representative_emulator_matrix_without_calling_it_device_qualification():
|
||||
workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8")
|
||||
smoke = workflow.split(" android-emulator-smoke:", 1)[1].split(" # The publisher key", 1)[0]
|
||||
assert "api-level: [26, 30, 33, 36]" in smoke
|
||||
assert "api-level: [26, 29, 30, 33, 36]" in smoke
|
||||
assert "adb install -r" in smoke
|
||||
assert "dumpsys package ai.ouroboros.android" in smoke
|
||||
assert "SELinux" not in smoke
|
||||
|
|
|
|||
|
|
@ -462,7 +462,6 @@ export function createChatInstance({
|
|||
let lastLoadedHistoryRevision = 0;
|
||||
// one-shot idle gate for Main's deferred first hydration.
|
||||
let hydrationGatePromise = null;
|
||||
// The server retains whole-history coverage independently of the DOM window.
|
||||
let historyWindow = null;
|
||||
let welcomeShown = false;
|
||||
// Cross-instance hide/show position; visible mutations use live geometry.
|
||||
|
|
@ -521,8 +520,6 @@ export function createChatInstance({
|
|||
// Local user submissions awaiting server confirmation (clientMessageId
|
||||
// -> { clientMessageId, timestamp }).
|
||||
const pendingSubmissions = new Map();
|
||||
// Bounded conclusions block late root typing and stale state snapshots; reusable
|
||||
// logical task slots are cleared whenever their cycle settles.
|
||||
const concludedDirectActivities = new Map();
|
||||
const CONCLUDED_ACTIVITY_LEDGER_MAX = 200;
|
||||
// Retryable queue-loss candidates plus process-local single-flight reads.
|
||||
|
|
@ -560,9 +557,6 @@ export function createChatInstance({
|
|||
else recordConcludedActivity(id);
|
||||
settleTerminalRootChildren(id);
|
||||
}
|
||||
// A root proven terminal settles descendant cards a lost child terminal left
|
||||
// open (#300): one single-flight durable read each, through the ordinary
|
||||
// child-terminal path; no proven terminal fact = the child keeps its state.
|
||||
function settleTerminalRootChildren(rootId) {
|
||||
for (const [childId, info] of subagentChildParents) {
|
||||
if (info.parentId !== rootId) continue;
|
||||
|
|
@ -576,10 +570,7 @@ export function createChatInstance({
|
|||
}).catch(() => {}).finally(() => managedTaskDetailReads.delete(childId));
|
||||
}
|
||||
}
|
||||
// Finished task ids hidden from routine syncs until reload/reconnect rebuilds history.
|
||||
const retiredTaskIds = new Set();
|
||||
// The owner's last main-chat request, handed to the next live card it spawns so a
|
||||
// "turn into project" conversion can name the project from it (P1).
|
||||
let _pendingCardObjective = '';
|
||||
let activeLiveGroupId = '';
|
||||
let pendingReconnectSync = false; // Set when a fromReconnect sync arrives while one is already in-flight.
|
||||
|
|
@ -1155,7 +1146,6 @@ export function createChatInstance({
|
|||
});
|
||||
}
|
||||
|
||||
// Durable cancel state wins over legacy status; only settled truth closes the card.
|
||||
function reconcileCancelCardFromDetail(record, taskId, stored) {
|
||||
return withStableViewport(() => {
|
||||
if (!stored || !record || record.finished) return false;
|
||||
|
|
@ -1243,10 +1233,6 @@ export function createChatInstance({
|
|||
return record ? syncCancelRunButton(record) : false;
|
||||
}
|
||||
|
||||
// One-way conversion (P3): the WHOLE card becomes a calm "project identity"
|
||||
// chip. The live task is now owned by the project panel (it's bound there),
|
||||
// so the main chat is freed — the card stops being a busy red task and
|
||||
// recolors to the project fuchsia. Plain wording (no "ack"); click opens the panel.
|
||||
function markCardConverted(record, project) {
|
||||
return withStableViewport(() => markCardConvertedMutation(record, project));
|
||||
}
|
||||
|
|
@ -1405,9 +1391,6 @@ export function createChatInstance({
|
|||
? explicitCardExpansion.get(normalizedGroupId)
|
||||
: Boolean(options.isSubagent && nestedSubagentsExpanded);
|
||||
root.dataset.expanded = initialExpanded ? '1' : '0';
|
||||
// No "Turn into project" for: subagent cards, non-main panels, or a task that
|
||||
// is ALREADY bound to a project (a project-chat follow-up) — see task_bindings
|
||||
// from /api/state, surfaced on window.__ouroTaskBindings (P2).
|
||||
const alreadyBound = !!(window.__ouroTaskBindings || {})[normalizedGroupId];
|
||||
const projectActionHtml = (
|
||||
isMain
|
||||
|
|
@ -1511,9 +1494,6 @@ export function createChatInstance({
|
|||
if (nowExpanded) record.expandedLineKeys.add(lineKey);
|
||||
else record.expandedLineKeys.delete(lineKey);
|
||||
renderLiveCardTimeline(record);
|
||||
// P3: on expand, lazily fetch the genuinely-full output for a server-truncated
|
||||
// line (the WS preview was capped at 4000); cached on the item so a re-render
|
||||
// keeps it. Best-effort — the capped preview stays on failure.
|
||||
if (nowExpanded) {
|
||||
const item = record.items.find((it) => it.lineKey === lineKey);
|
||||
if (item && item.truncated && item.fullRef && !item.fetchedFull && !item._fetchingFull) {
|
||||
|
|
@ -1734,19 +1714,12 @@ export function createChatInstance({
|
|||
window.addEventListener('ouro:page-shown', handlePageShown);
|
||||
document.addEventListener('visibilitychange', handlePageShown);
|
||||
|
||||
// P3: fetch the genuinely-full text of a server-truncated timeline line (the WS
|
||||
// preview is capped at 4000 chars) on demand, not over the socket; cache it on
|
||||
// the item, re-render if the line is still expanded, and show it in a
|
||||
// bounded-scroll box. Best-effort: the capped preview stays on failure.
|
||||
async function fetchFullLineOutput(item, record) {
|
||||
item._fetchingFull = true;
|
||||
let changed = false;
|
||||
try {
|
||||
const resp = await apiFetch(`/api/tasks/${encodeURIComponent(item.fullRef)}`, { cache: 'no-store' });
|
||||
const data = resp && typeof resp.json === 'function' ? await resp.json() : resp;
|
||||
// Compose ALL available full fields — a subagent line can carry both a result AND a
|
||||
// (separately truncated) trace_summary, so `result || trace_summary` would hide the
|
||||
// full trace. Label each section when both are present.
|
||||
const result = String((data && data.result) || '').trim();
|
||||
const trace = String((data && data.trace_summary) || '').trim();
|
||||
let full = '';
|
||||
|
|
@ -1854,8 +1827,6 @@ export function createChatInstance({
|
|||
|
||||
const desiredPhase = desiredLiveCardPhase(record, activePhase);
|
||||
setLiveCardPhase(record, desiredPhase.phase, desiredPhase.text, desiredPhase.className);
|
||||
// A coined project name takes the title slot (the activity headline stays in the
|
||||
// timeline); a child's title is its lineage identity; otherwise the activity headline.
|
||||
const title = record.suggestedName || (record.isSubagent ? childTitle(record)
|
||||
: (record.finished ? record.lastHumanHeadline || 'Task activity' : activeHeadline));
|
||||
if (record.titleEl.textContent !== title) record.titleEl.textContent = title;
|
||||
|
|
@ -2079,9 +2050,6 @@ export function createChatInstance({
|
|||
return summary ? withTaskCostMeta(summary, evt, { rawTs }) : null;
|
||||
}
|
||||
|
||||
// A child's title is its lineage identity plus, for twins (same displayed identity
|
||||
// under one parent), the short id; re-projected on every title write and lineage
|
||||
// change (terminal children included).
|
||||
function childTitle(record) {
|
||||
const twin = subagentTwin(subagentChildParents, record.groupId);
|
||||
return subagentIdentityTitle(subagentChildParents.get(record.groupId))
|
||||
|
|
@ -2501,7 +2469,6 @@ export function createChatInstance({
|
|||
addMessage('Ouroboros has awakened', 'assistant', false, null, false, { ephemeral: true });
|
||||
}
|
||||
|
||||
// Hydration triggers share one sticky request; reconnect/resync still refetch.
|
||||
function awaitInitialHydration({ includeUser = false } = {}) {
|
||||
if (initialHydrationPromise) return initialHydrationPromise;
|
||||
initialHydrationPromise = syncHistory({ includeUser });
|
||||
|
|
@ -2633,7 +2600,6 @@ export function createChatInstance({
|
|||
}
|
||||
} } finally { _syncPass1Active = false; _historyRow = null; }
|
||||
|
||||
// Pass 2 inserts cards at the first visible task message, then finishes them.
|
||||
const insertedCardTaskIds = new Set();
|
||||
function reorderDirtyCardIfNeeded(rec) {
|
||||
if (!rec?._anchorOrderDirty || rec.isSubagent || !rec.root?.isConnected) return;
|
||||
|
|
@ -2803,7 +2769,6 @@ export function createChatInstance({
|
|||
finishLiveCard(tid, taskTerminalPhase(terminalRecord));
|
||||
}
|
||||
|
||||
// Append disconnected visible cards after mid-task reload; skip trivial placeholders.
|
||||
for (const [tid, rec] of liveCardRecords) {
|
||||
reorderDirtyCardIfNeeded(rec);
|
||||
if (rec && rec.root && !rec.root.isConnected && !retiredTaskIds.has(tid)) {
|
||||
|
|
@ -3285,7 +3250,6 @@ export function createChatInstance({
|
|||
});
|
||||
}
|
||||
|
||||
// Ignore hidden/restoring scroll events so browser resets cannot corrupt saved intent.
|
||||
messagesDiv?.addEventListener('scroll', () => {
|
||||
if (!isInstanceVisible()) return;
|
||||
if (_restoring) { updateScrollButton(); return; }
|
||||
|
|
@ -3496,8 +3460,6 @@ export function createChatInstance({
|
|||
}
|
||||
|
||||
const typingEl = document.createElement('div');
|
||||
// Per-instance id (main stays 'typing-indicator'; panels get a unique id) so
|
||||
// multiple open chat columns never collide on a duplicate DOM id.
|
||||
typingEl.id = idPrefix === 'chat' ? 'typing-indicator' : `${idPrefix}-typing-indicator`;
|
||||
typingEl.className = 'chat-bubble assistant typing-bubble';
|
||||
typingEl.style.display = 'none';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue