diff --git a/services/android/README.md b/services/android/README.md index 243a3978..1985427a 100644 --- a/services/android/README.md +++ b/services/android/README.md @@ -283,6 +283,12 @@ new turn in that same conversation, including questions and follow-ups about pri There is no automatic reflection, skill-writing instruction, task recipe, or result schema. Bundled and host skill discovery are disabled. The only task prompt is the phone location, owner-agent role and caller goal/context. Existing dedicated native owner preferences remain. +The nonsecret caller input preserves `goal`, `owner_instruction` verbatim (including its +restrictions), typed `params`, `apps` and requested `outputs` in a small JSON envelope. These +are caller-supplied instructions, not an independent owner authorization. Principal/device/request +provenance and enforced run limits stay in transport/storage; lease/auth tokens and secret +declarations are excluded from model input. Natural replies remain valid, without a required +output format. The caller still acquires `android_session`, submits a durable `request_id` with `start`, and follows that request with `get`. An idle conversation holds no phone lease or device lock. diff --git a/services/android/src/deedkit_android/agent_bare.py b/services/android/src/deedkit_android/agent_bare.py index ef85cd13..3b085f4c 100644 --- a/services/android/src/deedkit_android/agent_bare.py +++ b/services/android/src/deedkit_android/agent_bare.py @@ -39,14 +39,15 @@ def __init__(self, *args): def prompt(self): host = self.agents.read_config() + request = self.request + # Use the validated request, not executor substitutions: those stringify bools/ints. + # Lease/auth tokens and secret declarations have no place in model input. + body = {name: getattr(request, name) for name in ( + 'goal', 'owner_instruction', 'params', 'apps', 'outputs')} prompt = (f'Android phone on this host: {host.adb} -s {host.devices[self.device]}.\n' "You are the owner's agent that does orders and tasks on this phone.\n" - f'Goal: {self.request.goal}\n') - if self.params: - prompt += 'Parameters: ' + json.dumps(self.params) + '\n' - if self.request.apps: - prompt += 'Apps: ' + ', '.join(self.request.apps) + '\n' - return prompt + 'Caller input (nonsecret JSON):\n') + return prompt + json.dumps(body, ensure_ascii=False) + '\n' def skill_files(self): """Change metadata only. Codex authors and discovers ordinary skills itself.""" @@ -201,7 +202,7 @@ async def final(self, state, extra): 'model_ms': max(0, self.engine_ms - self.device_ms)}, 'tokens': self.stream.tokens(), 'token_usage_observed': observed, 'engine': 'codex', 'model': self.config.model, - 'effort': self.config.reasoning_effort, 'mode': 'bare', 'prompt_version': 1, + 'effort': self.config.reasoning_effort, 'mode': 'bare', 'prompt_version': 2, 'skills_updated': self.skills_updated, 'learning_error': self.learning_error, 'engine_errors': agent_diagnostics.receipt(self.stream.errors, bytes(self.stderr), [self.request.goal, self.request.owner_instruction, *self.params.values()]), **extra} diff --git a/services/android/src/deedkit_android/agent_run.py b/services/android/src/deedkit_android/agent_run.py index 17663b7d..b729e017 100644 --- a/services/android/src/deedkit_android/agent_run.py +++ b/services/android/src/deedkit_android/agent_run.py @@ -726,7 +726,10 @@ async def start(self, owner, call): identity = call.identity() existing = self.db.row(owner, call.request_id) if existing is not None: - if existing['identity'] != identity: + # Keep legacy identities usable while distinguishing exact typed model input. + saved_params = json.loads(existing['request']).get('params', {}) + if existing['identity'] != identity or json.dumps(saved_params, sort_keys=True) != json.dumps( + call.params, sort_keys=True): raise Refused('request_id_conflict') return self.view(owner, call.request_id, 0) config = self.read_config() diff --git a/services/android/tests/test_agent_bare.py b/services/android/tests/test_agent_bare.py index 4493ad54..33a36302 100644 --- a/services/android/tests/test_agent_bare.py +++ b/services/android/tests/test_agent_bare.py @@ -101,7 +101,7 @@ def test_bare_default_launch_result_and_mandatory_skill_reuse(tmp_path): assert 'sandbox_workspace_write.network_access=true' in argv assert 'approval_policy=never' in argv assert not any('mcp_servers' in item or 'dangerously' in item or 'ignore-rules' in item for item in argv) - assert 'SERIAL' in prompt and 'Order check for me' not in prompt + assert 'SERIAL' in prompt and 'Order check for me' in prompt assert service.phone.commands == [] # no guard, hierarchy, or phone MCP home = Path(seen['env']['CODEX_HOME']) skill = home / 'skills' / 'example-food' / 'SKILL.md' diff --git a/services/android/tests/test_session_contract.py b/services/android/tests/test_session_contract.py new file mode 100644 index 00000000..b0359350 --- /dev/null +++ b/services/android/tests/test_session_contract.py @@ -0,0 +1,55 @@ +"""Exact caller input at the engine protocol boundary; synthetic engine and phone only.""" + +import json +import sqlite3 + +import pytest + +from agent_harness import Service, recorded +from test_agent_bare import bare_host +from test_codex_session import calls, live_host + + +# spec: ANYM-993 +@pytest.mark.parametrize('persistent', [False, True]) +def test_exact_nonsecret_input_and_provenance_reach_engine(tmp_path, persistent): + config, record = (live_host if persistent else bare_host)(tmp_path) + fields = dict(goal='Read basket\nthen ask which address.', + owner_instruction='Не выбирай товар до уточнения.\nKeep courier contact until received.', + params={'COUNT': 2, 'CONFIRMED': False, 'NOTE': 'Keep "original"\nwords'}, + apps=['example.food'], outputs=['total'], max_minutes=3, max_actions=20) + with Service(config) as service: + session = service.lease() + start = service.start(session, **fields) + assert service.finished(start['request_id'])['state'] == 'succeeded' + if persistent: + turn, = [row['message'] for row in calls(record) if row['message']['method'] == 'turn/start'] + prompt = turn['params']['input'][0]['text'] + assert turn['params']['approvalPolicy'] == 'never' + else: + prompt = recorded(record)['stdin'] + body = json.loads(prompt.split('Caller input (nonsecret JSON):\n', 1)[1]) + assert body == {key: value for key, value in fields.items() if key not in {'max_minutes', 'max_actions'}} + assert type(body['params']['COUNT']) is int and type(body['params']['CONFIRMED']) is bool + assert session not in prompt # device lease token is transport-only + with sqlite3.connect(tmp_path / 'state' / 'state.sqlite3') as db: + db.row_factory = sqlite3.Row + row = db.execute('SELECT * FROM agent_runs WHERE owner=? AND id=?', ('one', start['request_id'])).fetchone() + saved = json.loads(row['request']) + assert row['owner'] == 'one' and row['device'] == 'home' + assert saved['request_id'] == start['request_id'] + assert all(saved[key] == value for key, value in fields.items()) + + +# spec: ANYM-993 +def test_changed_parameter_type_conflicts_without_another_native_turn(tmp_path): + config, record = live_host(tmp_path) + with Service(config) as service: + session = service.lease() + first = service.start(session, params={'COUNT': 2}) + service.finished(first['request_id']) + replay = service.start(session, request_id=first['request_id'], params={'COUNT': 2}) + assert replay['request_id'] == first['request_id'] + refused = service.start(session, request_id=first['request_id'], params={'COUNT': '2'}, code=409) + assert refused['error'] == 'request_id_conflict' + assert len([row for row in calls(record) if row['message']['method'] == 'turn/start']) == 1 diff --git a/tests/test_backup_operations.py b/tests/test_backup_operations.py index 11de62aa..3029d0fe 100644 --- a/tests/test_backup_operations.py +++ b/tests/test_backup_operations.py @@ -383,8 +383,11 @@ def test_concurrent_cli_cannot_recover_or_stop_lock_owners_job(tmp_path,monkeypa monkeypatch.setattr(sys,'argv',['backup_runner','--config','unused']) monkeypatch.setattr(backup_runner.signal,'signal',lambda *_:None) before=(tmp_path/'journal.json').read_bytes() + previous_mask=os.umask(0o077) try: with pytest.raises(SystemExit):backup_runner.main() - finally:os.close(lock) + finally: + os.umask(previous_mask) + os.close(lock) assert called==[] and docker.calls==[] assert (tmp_path/'journal.json').read_bytes()==before diff --git a/tests/test_backup_recordings_staging.py b/tests/test_backup_recordings_staging.py index 032b8c46..bd64a93c 100644 --- a/tests/test_backup_recordings_staging.py +++ b/tests/test_backup_recordings_staging.py @@ -563,10 +563,13 @@ def run_main(monkeypatch, tmp_path, runner_cfg, *argv): config.chmod(0o600) monkeypatch.setattr(sys, 'argv', ['backup_runner', '--config', str(config), *argv]) monkeypatch.setattr(backup_runner.signal, 'signal', lambda *_: None) + previous_mask = os.umask(0o077) try: backup_runner.main() except SystemExit as stopped: return stopped.code + finally: + os.umask(previous_mask) return 0 diff --git a/tests/test_inference_egress.py b/tests/test_inference_egress.py index fd99415d..27c8e397 100644 --- a/tests/test_inference_egress.py +++ b/tests/test_inference_egress.py @@ -143,7 +143,9 @@ def namespace_ready(): def in_namespace(script, timeout=120): - return subprocess.run(['sudo', '-n', 'unshare', '-n', 'sh', '-ec', script], text=True, capture_output=True, + # Only synthetic results in the caller's private tmp_path: sudo may inherit a caller's 0077, + # which makes root-created captures/state unreadable to that caller. Leave the parent mask alone. + return subprocess.run(['sudo', '-n', 'unshare', '-n', 'sh', '-ec', 'umask 022; ' + script], text=True, capture_output=True, timeout=timeout) @@ -289,6 +291,24 @@ def test_ufw_form_passes_iptables_restore_test_for_syntax(tmp_path): assert result.returncode == 0, result.stderr +# spec: ANYM-693 +@needs_namespace +def test_namespace_outputs_ignore_caller_umask_without_hiding_exit_or_errors(tmp_path): + output, state = tmp_path / 'namespace-output', tmp_path / 'raw-state.json' + hook = after_init(tmp_path) + previous = os.umask(0o077) + try: + result = in_namespace(f'umask; printf fixture > {output}; DEEDKIT_RAW_STATE={state} sh {hook} start; ' + 'printf fixture-error >&2; exit 17') + parent_mask = os.umask(0o077) + finally: + os.umask(previous) + assert result.returncode == 17 and result.stderr == 'fixture-error' + assert result.stdout.strip() == '0022' and parent_mask == 0o077 + assert output.stat().st_mode & 0o777 == state.stat().st_mode & 0o777 == 0o644 + assert output.read_text() == 'fixture' and json.loads(state.read_text())['ok'] is True + + # spec: ANYM-693 @needs_namespace def test_ufw_block_restores_once_in_a_disposable_namespace(tmp_path):