okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnkernel: a job_stop frame lets /jobs stop remote background jobs57a82be

main
44.9 KiB1081 linesraw
1"""Sessions whose kernel lives on another host, reached over ssh.
2
3The daemon's PATH holds a loopback ssh that runs each command locally under
4its own "remote" HOME, so remote paths, the staged bundle and the run
5directories are all distinct from the daemon's. Hosts named `oldhost`,
6`locked` and `gone` stand for a python that is too old, a host that needs a
7person to sign in, and one that cannot be reached.
8"""
9
10import json
11import os
12import re
13import shlex
14import shutil
15import signal
16import subprocess
17import sys
18import threading
19import time
20import unittest
21import urllib.error
22import urllib.parse
23from pathlib import Path
24
25from harness import Albedo, Provider, exclusive, operation_id, python, text
26
27FAKE_SSH = """#!/bin/sh
28printf '%s\\n' "$*" >> {log}
29query=
30while [ $# -gt 0 ]; do
31  case "$1" in
32    -o) shift 2 ;;
33    -G) query=1; shift ;;
34    -*) shift ;;
35    *) break ;;
36  esac
37done
38target=$1
39shift
40if [ -n "$query" ]; then
41  # ssh -G only reads the config: muxed's names a ControlPath, no other does
42  if [ "$target" = muxed ]; then
43    echo "controlmaster auto"
44    echo "controlpath {mux}"
45    echo "controlpersist 1800"
46  fi
47  exit 0
48fi
49if [ "$target" = fakehost ] && [ -e {down} ]; then
50  echo "ssh: connect to host fakehost port 22: Operation timed out" >&2
51  exit 255
52fi
53case "$target" in
54  gone) echo "ssh: connect to host gone port 22: Connection refused" >&2; exit 255 ;;
55  locked) echo "locked: Permission denied (publickey)." >&2; exit 255 ;;
56  slow-*) sleep 3; echo "ssh: connect to host $target port 22: Connection refused" >&2; exit 255 ;;
57esac
58case "$*" in
59  *.part.*) [ -e {slow} ] && sleep 3 ;;
60esac
61path={path}
62[ "$target" = oldhost ] && path={old}:$path
63exec env -i HOME={home} PATH="$path" SHELL={bin}/loginsh /bin/sh -c "$*"
64"""
65
66# A login shell whose "profile" greets on stdout and stderr, as many do, and
67# sources nothing else: /etc/profile would put the system python ahead of the
68# fake host's. Every scenario runs through it, so a greeting must never reach
69# a framed stream, nor the daemon's log.
70LOGIN_SHELL = """#!/bin/sh
71if [ "$1" = -l ]; then
72  shift
73  echo "welcome to the fake host"
74  echo "you have no mail" >&2
75fi
76exec /bin/sh "$@"
77"""
78
79# The daemon's own python3, logging what it runs, so a test can tell that a
80# termination ladder never ran on this machine.
81LOCAL_PYTHON = """#!/bin/sh
82printf '%s\\n' "$*" >> {log}
83exec {python} "$@"
84"""
85
86
87def write(path, content):
88    path.write_text(content)
89    path.chmod(0o755)
90
91
92def processes(*needles):
93    """Pids whose command line holds every needle."""
94    listing = subprocess.run(
95        ["ps", "-axo", "pid=,command="], capture_output=True, text=True, check=True
96    ).stdout
97    return [
98        int(line.split(None, 1)[0])
99        for line in listing.splitlines()
100        if all(needle in line for needle in needles)
101    ]
102
103
104def wait_for(predicate, timeout=15, message="condition not met"):
105    deadline = time.monotonic() + timeout
106    while time.monotonic() < deadline:
107        result = predicate()
108        if result:
109            return result
110        time.sleep(0.1)
111    raise AssertionError(message)
112
113
114# exclusive: sets daemon PATH for loopback SSH and local Python wrappers
115@exclusive
116class RemoteKernelTests(unittest.TestCase):
117    remote_home: Path
118    ssh_log: Path
119    mux: Path
120    python_log: Path
121    down: Path
122    slow_stage: Path
123
124    def setUp(self):
125        self.cells = []
126
127        def script(request):
128            if request["input"][-1].get("role") == "user" and self.cells:
129                return python(self.cells.pop(0))
130            return text("ok")
131
132        def prepare(app):
133            root = app.root
134            self.remote_home = root / "remote-home"
135            self.remote_home.mkdir()
136            self.ssh_log = root / "ssh.log"
137            self.python_log = root / "local-python.log"
138            self.down = root / "fakehost-down"
139            self.slow_stage = root / "slow-stage"
140            self.mux = root / "cm-muxed"
141            real = shutil.which("python3") or sys.executable
142            local, remote, old = (
143                root / "local-bin",
144                root / "remote-bin",
145                root / "old-bin",
146            )
147            for directory in (local, remote, old):
148                directory.mkdir()
149            write(
150                local / "ssh",
151                FAKE_SSH.format(
152                    log=self.ssh_log,
153                    bin=remote,
154                    old=old,
155                    home=self.remote_home,
156                    down=self.down,
157                    slow=self.slow_stage,
158                    mux=self.mux,
159                    path=shlex.quote(f"{remote}{os.pathsep}{app.daemon.env['PATH']}"),
160                ),
161            )
162            write(
163                local / "python3", LOCAL_PYTHON.format(log=self.python_log, python=real)
164            )
165            write(remote / "loginsh", LOGIN_SHELL)
166            os.symlink(real, remote / "python3")
167            ssh_config = root / "user-home" / ".ssh"
168            ssh_config.mkdir()
169            (ssh_config / "config").write_text(
170                "Host configured-box\n  User someone\nHost *\n  ServerAliveInterval 30\n"
171                "Include extra.conf\n"
172            )
173            (ssh_config / "extra.conf").write_text("Host included-box other-*\n")
174            write(old / "python3", "#!/bin/sh\necho 3.9\n")
175            app.daemon.env["PATH"] = f"{local}{os.pathsep}{app.daemon.env['PATH']}"
176
177        self.provider = Provider(script)
178        self.addCleanup(self.provider.close)
179        self.app = Albedo(self.provider, protocol="responses", prepare=prepare)
180        self.app.__enter__()
181        self.addCleanup(self.app.__exit__, None, None, None)
182        self.project = self.remote_home / "proj"
183        self.project.mkdir()
184
185    def create(self, workspace):
186        session = operation_id()
187        with self.app.api(
188            f"/sessions/{session}",
189            {
190                "kind": "new",
191                "workspace": workspace,
192                "provider_profile": self.app.profile,
193            },
194            method="PUT",
195            headers={"If-None-Match": "*"},
196        ) as response:
197            return json.load(response)
198
199    def host(self, target):
200        query = urllib.parse.urlencode({"target": target})
201        with self.app.api("/hosts?" + query) as response:
202            [host] = json.load(response)["items"]
203        self.assertEqual(host["target"], target)
204        return host
205
206    def group(self, workspace):
207        query = urllib.parse.urlencode({"workspace": str(workspace)})
208        with self.app.api(f"/extensions/links/groups?{query}") as response:
209            return json.load(response)
210
211    def merge(self, workspace, other):
212        group = self.group(workspace)["configuration_resource"]
213        target = self.group(other)["configuration_resource"]
214        with self.app.api(
215            group["url"],
216            {"other_workspace": str(other), "other_etag": target["etag"]},
217            headers={"If-Match": group["etag"]},
218        ) as response:
219            return json.load(response)
220
221    def cell(self, session, code, *, during=None):
222        """Run one cell through a model turn and return its tool result."""
223        self.cells.append(code)
224        self.app.prompt(session, "run it").close()
225        if during:
226            during()
227        self.app.idle(session, timeout=60)
228        results = [
229            json.loads(part["value"])
230            for entry in self.app.history(session)["items"]
231            if entry["kind"] == "tool_result" and entry["tool"]["name"] == "python"
232            for part in entry["content"]
233            if part["kind"] == "json" and part["field"] == "result"
234        ]
235        return results[-1]
236
237    def test_a_remote_kernel_runs_in_the_remote_folder_from_the_staged_bundle(self):
238        session = self.create(f"fakehost:{self.project}")["id"]
239        result = self.cell(
240            session,
241            "import os, albedo_bundle\n(os.getcwd(), os.environ['HOME'], str(albedo_bundle.ROOT))",
242        )
243        self.assertEqual(result["status"], "ok", result)
244        cwd, home, bundle = eval(result["value"])
245        self.assertEqual(os.path.realpath(cwd), os.path.realpath(self.project))
246        self.assertEqual(home, str(self.remote_home))
247        self.assertTrue(
248            bundle.startswith(f"{os.path.realpath(self.remote_home)}/.albedo-remote/")
249            or bundle.startswith(f"{self.remote_home}/.albedo-remote/"),
250            bundle,
251        )
252        # The model is told where it runs.
253        system = json.dumps(self.provider.requests[0]["request"])
254        self.assertIn("execute on fakehost (", system)
255        log = (self.app.home / "daemon.log").read_text(errors="replace")
256        self.assertNotIn("welcome to the fake host", log)
257        self.assertNotIn("you have no mail", log)
258
259    def test_remote_job_output_is_not_a_silently_shortened_patch(self):
260        session = self.create(str(self.app.workspace))["id"]
261        result = self.cell(
262            session,
263            r"""import sys
264rem = await remote.connect('fakehost')
265fallback = await remote.connect('oldhost')
266starts = [('local', run), ('remote', rem.run), ('fallback', fallback.run)]
267expected = ('first file\n' + 'line αβ\n' * 5000 + 'second file\n' + 'other line\n' * 8000).encode()
268program = "import sys; sys.stdout.buffer.write(('first file\\n' + 'line αβ\\n' * 5000 + 'second file\\n' + 'other line\\n' * 8000).encode())"
269for label, start in starts:
270    job = start(sys.executable, '-c', program)
271    await job
272    assert job.tail(60000) == expected.decode()[-60000:], label
273    assert await job.head(40000) == expected.decode()[:40000], label
274    assert await job.read() == expected.decode(), label
275    assert job.tail(65536) == expected.decode()[-65536:], label
276    assert await job.read(offset=17, limit=4000) == expected[17:4017].decode(errors='ignore'), label
277    # Byte offsets remain byte offsets, even when a page cuts through UTF-8.
278    offset = expected.index('α'.encode()) + 1
279    assert await job.read(offset=offset, limit=9) == expected[offset:offset+9].decode(errors='ignore'), label
280    for preview in (job.head, job.tail):
281        try:
282            await preview(3_000_000)
283        except Exception as error:
284            assert 'job.read()' in str(error) and 'job.save' in str(error), str(error)
285        else:
286            raise AssertionError('silently shortened a preview: ' + label)
287    path = await job.save(label + '.patch')
288    actual = await rem.read(path) if label == 'remote' else Path(path).read_bytes()
289    if isinstance(actual, str):
290        actual = actual.encode()
291    assert actual == expected, label
292
293async def refuses(call, *args, message, **kwargs):
294    try:
295        await call(*args, **kwargs)
296    except Exception as error:
297        assert message in str(error), str(error)
298    else:
299        raise AssertionError('accepted incomplete output')
300
301for label, start in starts:
302    job = start(sys.executable, '-c', "import sys; sys.stdout.buffer.write(b'begin\\xff\\x00' + b'x' * 140000 + b'end')")
303    await job
304    assert '\ufffd' in await job.read(), label
305    assert await job.tail(0) == '' and await job.head(lines=0) == '', label
306    await refuses(job.read, limit=65537, message='65536')
307    # No newline in the window: a line request must not return a fragment.
308    await refuses(job.tail, lines=1, message='job.read()')
309    await refuses(job.head, lines=1, message='job.read()')
310    path = await job.save(label + '.bin')
311    actual = await rem.read(path) if label == 'remote' else Path(path).read_bytes()
312    assert actual == b'begin\xff\x00' + b'x' * 140000 + b'end', label
313    overflow = start(sys.executable, '-c', "import sys; sys.stdout.buffer.write(b'z' * (2097152 + 10) + b'\\xff\\x00end')")
314    await overflow
315    await refuses(overflow.read, message='paginate with')
316    assert await overflow.read(offset=1048576, limit=10) == 'z' * 10, label
317    assert await overflow.read(offset=1048576-2, limit=10) == 'z' * 10, label
318    page_reader = rem.output.read if label == 'remote' else output.read
319    assert await page_reader(overflow.id, offset=1048576, limit=10) == 'z' * 10, label
320    pages = [await overflow.read(offset=offset, limit=65536)
321             for offset in range(0, 2097152 + 15, 65536)]
322    assert ''.join(pages) == 'z' * (2097152 + 10) + '\x00end', label
323    assert await overflow.read(offset=2097152+15, limit=65536) == '', label
324    await overflow.save(path)
325    saved = await rem.read(path) if label == 'remote' else Path(path).read_bytes()
326    assert saved == b'z' * (2097152 + 10) + b'\xff\x00end', label
327    # The disk cap is explicit; stored pages remain accessible, lost pages refuse.
328    capped = start(sys.executable, '-c', "import sys; sys.stdout.buffer.write(b'c' * (16777216 + 1))")
329    await capped
330    assert await capped.read(offset=16777217, limit=0) == '', label
331    await refuses(capped.read, message='refusing incomplete output')
332    await refuses(capped.save, path, message='refusing incomplete output')
333    preserved = await rem.read(path) if label == 'remote' else Path(path).read_bytes()
334    assert preserved == saved, 'overwrote destination with partial output: ' + label
335    assert await capped.read(offset=16777216-10, limit=10) == 'c' * 10, label
336    await refuses(capped.read, offset=16777216, limit=1, message='cannot be recovered by pagination')
337    await refuses(page_reader, capped.id, offset=16777216-2, limit=10, message='cannot be recovered by pagination')
338    unicode = start(sys.executable, '-c', "print('é' * 70000, end='')")
339    await unicode
340    await refuses(unicode.tail, 60000, message='job.read()')
341    await refuses(unicode.head, 60000, message='job.read()')
342    assert await unicode.tail(100) == 'é' * 100, label
343
344# A running job must not overwrite a completed artifact.
345protected = Path('protected.bin')
346protected.write_bytes(b'keep me')
347running = run(sys.executable, '-c', 'import time; time.sleep(0.5)')
348await refuses(running.read, message='await job')
349await refuses(running.save, protected, message='await job')
350assert protected.read_bytes() == b'keep me'
351await running
352# Streaming to a file recovers output larger than the retained buffers.
353streamed = run(sys.executable, '-c', "print('s' * 2000000, end='')").pipe('tee', 'whole.txt')
354await streamed
355assert Path('whole.txt').read_bytes() == b's' * 2000000
356# A failed atomic rename leaves the destination and no temporary output file.
357directory = Path('not-a-file')
358directory.mkdir()
359(directory / 'keep').write_bytes(b'preserved')
360before = set(Path('.').iterdir())
361await refuses(running.save, directory, message='directory')
362assert set(Path('.').iterdir()) == before
363assert (directory / 'keep').read_bytes() == b'preserved'
364print('refusals preserve destinations and exact binary output')
365
366await rem.close()
367await fallback.close()
368print('complete output preserved for all three job kinds')
369""",
370        )
371        self.assertEqual(result["status"], "ok", result)
372        self.assertIn("complete output preserved", result["output"])
373        self.assertIn("refusals preserve destinations", result["output"])
374
375    def test_job_spill_is_live_and_failures_preserve_artifacts(self):
376        session = self.create(str(self.app.workspace))["id"]
377        result = self.cell(
378            session,
379            r"""import albedo_capture
380import tempfile
381import time
382from unittest.mock import patch
383
384async def refuses(call, *args, message, **kwargs):
385    try:
386        await call(*args, **kwargs)
387    except Exception as error:
388        assert message in str(error), str(error)
389    else:
390        raise AssertionError('accepted incomplete output')
391
392protected = Path('protected-spill.bin')
393protected.write_bytes(b'keep me')
394release = Path('release-writer').absolute()
395program = f'''import sys, time
396from pathlib import Path
397sys.stdout.buffer.write(b'x' * 2097152)
398sys.stdout.flush()
399while not Path({str(release)!r}).exists():
400    time.sleep(0.01)
401sys.stdout.buffer.write(b'end')
402sys.stdout.flush()
403'''
404live = run(sys.executable, '-c', program)
405async def ready():
406    while live.capture.seen < 2097152:
407        await asyncio.sleep(0.01)
408await asyncio.wait_for(ready(), 5)
409assert live.duration is None
410assert live.read(offset=1048576-2, limit=10) == 'x' * 10
411assert output.read(live.id, offset=2097152-10, limit=10) == 'x' * 10
412await refuses(live.save, protected, message='await job')
413assert protected.read_bytes() == b'keep me'
414assert Path(live.capture.spill).stat().st_mode & 0o077 == 0
415
416# Simulate a disk write failure after a valid spill prefix has been captured.
417with patch.object(live.capture._spill_file, 'write', side_effect=OSError('disk full')):
418    release.touch()
419    await live
420assert live.capture._spill_file is None
421assert live.tail(3) == 'end'
422await refuses(live.read, offset=1048576, limit=1, message='refusing incomplete output')
423await refuses(live.save, protected, message='refusing incomplete output')
424assert protected.read_bytes() == b'keep me'
425
426# Creation failure keeps previews usable, and never retries from a lost prefix.
427blocked = Path('not-a-spill-directory')
428blocked.write_text('blocked')
429with patch.object(albedo_capture, 'SPILL_DIR', str(blocked)):
430    failed = run(sys.executable, '-c', "print('f' * 2097152, end='')")
431    await failed
432assert failed.tail(10) == 'f' * 10
433assert failed.capture.spill is None
434await refuses(failed.read, offset=1048576, limit=1, message='refusing incomplete output')
435await refuses(failed.save, protected, message='refusing incomplete output')
436assert protected.read_bytes() == b'keep me'
437
438# A missing or shortened spill must not become a short page or a partial save.
439for damage in ('shorten', 'remove'):
440    job = run(sys.executable, '-c', "print('s' * 2097152, end='')")
441    await job
442    assert job.capture._spill_file is None
443    spill = Path(job.capture.spill)
444    if damage == 'shorten':
445        spill.write_bytes(b'short')
446    else:
447        spill.unlink()
448    before = set(Path('.').iterdir())
449    await refuses(job.read, offset=1048576, limit=10,
450                  message='incomplete' if damage == 'shorten' else 'No such file')
451    await refuses(job.save, protected,
452                  message='incomplete' if damage == 'shorten' else 'No such file')
453    assert protected.read_bytes() == b'keep me'
454    assert set(Path('.').iterdir()) == before
455
456# Pruning deletes only expired spill files, not fresh files or directories.
457with tempfile.TemporaryDirectory() as directory:
458    old = Path(directory, 'old.txt')
459    old.write_text('old')
460    fresh = Path(directory, 'fresh.txt')
461    fresh.write_text('fresh')
462    nested = Path(directory, 'nested')
463    nested.mkdir()
464    (nested / 'keep.txt').write_text('keep')
465    stale = time.time() - albedo_capture.SPILL_KEEP - 1
466    os.utime(old, (stale, stale))
467    os.utime(nested, (stale, stale))
468    previous = albedo_capture.SPILL_DIR
469    try:
470        albedo_capture.prune_spills(directory)
471    finally:
472        albedo_capture.SPILL_DIR = previous
473    assert not old.exists() and fresh.read_text() == 'fresh'
474    assert (nested / 'keep.txt').read_text() == 'keep'
475print('live spill pages, disk failures, atomic saves, and pruning verified')
476""",
477        )
478        self.assertEqual(result["status"], "ok", result)
479        self.assertIn("pruning verified", result["output"])
480
481    def test_a_first_visit_says_it_is_copying_the_kernel(self):
482        self.slow_stage.touch()  # each copy of the bundle takes 3 s
483        session = self.create(f"fakehost:{self.project}")["id"]
484        seen = set()
485
486        def watch():
487            deadline = time.monotonic() + 15
488            while time.monotonic() < deadline:
489                with self.app.api(f"/sessions/{session}?tail=0") as response:
490                    kernel = json.load(response)["kernel"]
491                seen.add(kernel["stage"])
492                if kernel["state"] == "attached":
493                    return
494                time.sleep(0.2)
495
496        result = self.cell(session, "1", during=watch)
497        self.assertEqual(result["status"], "ok", result)
498        self.assertIn("staging", seen)
499
500    def test_a_bridge_killed_mid_cell_reattaches_over_ssh(self):
501        session = self.create(f"fakehost:{self.project}")["id"]
502        first = self.cell(session, "import os\nos.getpid()")
503        (bridge,) = processes(f"{self.remote_home}/.albedo-remote/", "albedo_bridge.py")
504
505        def kill_bridge():
506            time.sleep(1)
507            os.kill(bridge, signal.SIGKILL)
508
509        slept = self.cell(
510            session,
511            "import threading\nthreading.Event().wait(3)\nprint('woke')\nos.getpid()",
512            during=kill_bridge,
513        )
514        self.assertEqual(slept["status"], "ok", slept)
515        self.assertEqual(slept["output"].strip(), "woke")
516        self.assertEqual(slept["value"], first["value"])
517        bridges = [
518            line
519            for line in self.ssh_log.read_text().splitlines()
520            if "albedo_bridge.py" in line
521        ]
522        self.assertGreaterEqual(len(bridges), 2, "the reattach did not go over ssh")
523
524    def test_a_remote_kernel_outlives_a_daemon_restart(self):
525        session = self.create(f"fakehost:{self.project}")["id"]
526        first = self.cell(session, "import os\nsurvivor = 41\nos.getpid()")
527        self.app.restart(crash=True)
528        after = self.cell(session, "survivor += 1\n(survivor, os.getpid())")
529        self.assertEqual(after["value"], f"(42, {first['value']})")
530
531    def test_a_kernel_survives_ssh_being_down_while_the_daemon_restarts(self):
532        session = self.create(f"fakehost:{self.project}")["id"]
533        first = self.cell(session, "import os\nsurvivor = 41\nos.getpid()")
534        self.down.touch()
535        # Back a few seconds after the daemon came up, while its startup
536        # attach and the turn's are still failing.
537        threading.Timer(4, self.down.unlink).start()
538        self.app.restart(crash=True)
539        after = self.cell(session, "survivor += 1\n(survivor, os.getpid())")
540        self.assertEqual(after["value"], f"(42, {first['value']})")
541
542    def test_a_remote_sessions_memory_is_the_daemons_and_links_with_a_local_one(self):
543        remote = f"fakehost:{self.project}"
544        session = self.create(remote)["id"]
545        self.cell(session, "memory.append('the gate runs on fakehost')")
546        slug = re.sub(r"[^A-Za-z0-9]", "-", remote)
547        written = self.app.home / "memories" / slug / "memory.md"
548        self.assertEqual(written.read_text(), "the gate runs on fakehost\n")
549        self.assertFalse((self.remote_home / ".albedo").exists())
550
551        # The same project checked out here, linked: it reads the remote one.
552        checkout = self.app.root / "checkout"
553        checkout.mkdir()
554        local = self.create(str(checkout))["id"]
555        merged = self.merge(checkout, remote)
556        self.assertIn(remote, merged["resource"]["value"]["members"])
557        found = self.cell(local, "print(memory.grep('gate'))")
558        self.assertIn(f"[{remote}] memory.md:1: the gate runs", found["output"])
559
560    def test_a_linked_remote_folder_is_checked_on_its_host(self):
561        checkout = self.app.root / "checkout"
562        checkout.mkdir()
563        self.create(str(checkout))
564
565        def presence():
566            return {
567                item["workspace"]: item for item in self.group(checkout)["presence"]
568            }
569
570        present = f"fakehost:{self.project}"
571        missing = f"fakehost:{self.remote_home}/missing"
572        for member in (present, missing, "gone:/srv/app"):
573            self.merge(checkout, member)
574        found = presence()
575        self.assertTrue(found[present]["exists"])
576        self.assertFalse(found[missing]["exists"])
577        self.assertEqual(found["gone:/srv/app"]["host"]["state"], "unreachable")
578        self.assertIn(
579            "Connection refused", found["gone:/srv/app"]["host"]["detail"]["detail"]
580        )
581
582        # A host that stops answering is not a gone folder.
583        self.down.touch()
584        found = presence()
585        self.assertIsNone(found[present]["exists"])
586        self.assertIsNone(found[missing]["exists"])
587        with self.app.api("/hosts/fakehost/probe", {}) as response:
588            self.assertEqual(response.status, 202)
589        deadline = time.monotonic() + 15
590        while (
591            self.host("fakehost")["state"] == "probing" and time.monotonic() < deadline
592        ):
593            time.sleep(0.05)
594        found = presence()
595        self.assertEqual(found[present]["host"]["state"], "unreachable")
596        self.assertEqual(found[missing]["host"]["state"], "unreachable")
597        self.down.unlink()
598        with self.app.api("/hosts/fakehost/probe", {}) as response:
599            self.assertEqual(response.status, 202)
600        deadline = time.monotonic() + 15
601        while (
602            self.host("fakehost")["state"] == "probing" and time.monotonic() < deadline
603        ):
604            time.sleep(0.05)
605        self.assertEqual(self.host("fakehost")["state"], "ready")
606
607        # A session opening with the host's probe fresh is told what is gone.
608        self.cell(self.create(str(checkout))["id"], "1")
609        instructions = self.provider.requests[-1]["request"]["instructions"]
610        self.assertIn(f"{missing} (its folder is gone)", instructions)
611        self.assertNotIn(f"{present} (its folder is gone)", instructions)
612
613    def test_linked_hosts_are_checked_side_by_side(self):
614        checkout = self.app.root / "slow-checkout"
615        checkout.mkdir()
616        self.create(str(checkout))
617
618        for host in ("slow-a", "slow-b"):
619            self.merge(checkout, f"{host}:/srv")
620        # Each host takes 3 s to refuse; one after the other would be 6.
621        began = time.monotonic()
622        rows = [
623            item
624            for item in self.group(checkout)["presence"]
625            if item["workspace"] != str(checkout)
626        ]
627        self.assertLess(time.monotonic() - began, 5.5)
628        self.assertEqual([row["host"]["state"] for row in rows], ["unreachable"] * 2)
629
630    def test_a_remote_home_is_resolved_and_stored_absolute(self):
631        created = self.create("fakehost:~/proj")
632        self.assertEqual(created["workspace"], f"fakehost:{self.project}")
633        with self.assertRaises(urllib.error.HTTPError) as refused:
634            self.create("gone:~/proj")
635        self.assertEqual(refused.exception.code, 503)
636        self.assertIn("Connection refused", refused.exception.read().decode())
637
638    def test_hosts_report_what_ssh_found(self):
639        def warm(host):
640            with self.app.api(f"/hosts/{host}/probe", {}) as response:
641                self.assertEqual(response.status, 202)
642                observation = json.load(response)
643            deadline = time.monotonic() + 15
644            while observation["state"] == "probing" and time.monotonic() < deadline:
645                time.sleep(0.05)
646                observation = self.host(host)
647            self.assertNotEqual(observation["state"], "probing", observation)
648            return observation
649
650        ready = warm("fakehost")
651        self.assertEqual(ready["state"], "ready", ready)
652        self.assertEqual(ready["home"], str(self.remote_home))
653        self.assertTrue(ready["os"] and ready["architecture"])
654        old = warm("oldhost")
655        self.assertEqual(old["state"], "unsupported", old)
656        self.assertIn("python >= 3.11", old["detail"]["detail"])
657        self.assertEqual(warm("locked")["state"], "needs_auth")
658        self.assertEqual(warm("gone")["state"], "unreachable")
659        # A host whose ssh config names a ControlPath rides that master, so the
660        # user's own ssh and albedo share one sign-in.
661        self.assertEqual(warm("muxed")["state"], "ready")
662        muxed = [
663            line
664            for line in self.ssh_log.read_text().splitlines()
665            if " muxed " in line and "-G" not in line
666        ]
667        self.assertTrue(muxed)
668        for line in muxed:
669            self.assertIn(f"ControlPath={self.mux} ", line)
670            self.assertIn("ControlPersist=1800 ", line)
671        self.assertEqual(self.host("fakehost")["state"], "ready")
672
673        # A bracketed IPv6 host travels percent-encoded in the probe's path and
674        # is probed as itself, the same host a target query names.
675        with self.app.api("/hosts/%5B::1%5D/probe", {}) as response:
676            self.assertEqual(response.status, 202)
677            self.assertEqual(json.load(response)["target"], "[::1]")
678        self.host("[::1]")
679
680        # A turn at a host that needs a sign-in waits with ssh's words, and
681        # the host names the control path the tui's sign-in opens.
682        session = self.create("locked:/srv/app")["id"]
683        with self.app.prompt(session, "hello") as response:
684            input_id = json.load(response)["id"]
685        deadline = time.monotonic() + 10
686        while time.monotonic() < deadline:
687            with self.app.api(f"/sessions/{session}/inputs/{input_id}") as response:
688                receipt = json.load(response)
689            if receipt["blocking_reason"]:
690                break
691            time.sleep(0.05)
692        self.assertEqual(receipt["delivery"], "pending", receipt)
693        self.assertIn("Permission denied", receipt["blocking_reason"]["detail"])
694        host = self.host("locked")
695        self.assertEqual(host["state"], "needs_auth", host)
696        self.assertTrue(host["authentication"]["control_path"].endswith("/%C"), host)
697
698    def test_a_remote_kernel_is_ended_on_its_host_never_here(self):
699        session = self.create(f"fakehost:{self.project}")["id"]
700        started = self.cell(
701            session,
702            "import asyncio\njob = run('sleep', '7777')\nawait asyncio.sleep(0.5)\njob.exit_code",
703        )
704        self.assertEqual(started["status"], "ok", started)
705        self.assertTrue(processes("sleep 7777"))
706        elsewhere = self.remote_home / "elsewhere"
707        elsewhere.mkdir()
708        with self.app.api(f"/sessions/{session}?view=configuration") as response:
709            configuration = json.load(response)
710            revision = response.headers["ETag"]
711        self.app.api(
712            f"/sessions/{session}?view=configuration",
713            {
714                "workspace": f"fakehost:{elsewhere}",
715                "family_revision": configuration["family_revision"],
716            },
717            method="PATCH",
718            headers={"If-Match": revision},
719        ).close()
720        deadline = time.monotonic() + 15
721        while processes("sleep 7777") and time.monotonic() < deadline:
722            time.sleep(0.1)
723        self.assertFalse(processes("sleep 7777"))
724        remote_ladder = [
725            line
726            for line in self.ssh_log.read_text().splitlines()
727            if "albedo_signal.py" in line
728        ]
729        self.assertTrue(remote_ladder, "the kernel was not ended over ssh")
730        local = self.python_log.read_text() if self.python_log.exists() else ""
731        self.assertNotIn("albedo_signal.py", local)
732
733    def test_jobs_started_over_a_remote_connection_are_listed_like_local_ones(self):
734        session = self.create(str(self.app.workspace))["id"]
735        started = self.cell(
736            session,
737            "import asyncio\n"
738            "rem = await remote.connect('fakehost')\n"
739            "fallback = await remote.connect('oldhost')\n"
740            "rem.run('sleep', '8881')\n"
741            "fallback.run('sleep', '6')\n"
742            "await asyncio.sleep(1)",
743        )
744        self.assertEqual(started["status"], "ok", started)
745        kernel = json.load(self.app.api(f"/sessions/{session}?tail=0"))["kernel"]
746        self.assertEqual(kernel["live_job_count"], 2, kernel)
747        running = {job["command"]: job for job in kernel["running_jobs"]}
748        self.assertEqual(sorted(running), ["sleep 6", "sleep 8881"], kernel)
749        for job in running.values():
750            self.assertLess(abs(job["started_at"] - time.time() * 1000), 60_000)
751        with self.app.api(f"/extensions/run/sessions/{session}/jobs") as response:
752            page = json.load(response)["page"]
753        self.assertEqual(page["summary"], "2 background jobs running")
754        self.assertEqual(
755            sorted(row["text"] for row in page["rows"]), ["sleep 6", "sleep 8881"]
756        )
757        ended = self.cell(session, "await rem.close()\nawait asyncio.sleep(1)")
758        self.assertEqual(ended["status"], "ok", ended)
759        kernel = json.load(self.app.api(f"/sessions/{session}?tail=0"))["kernel"]
760        self.assertNotIn(
761            "sleep 8881", [job["command"] for job in kernel["running_jobs"]]
762        )
763
764    def test_remote_background_jobs_can_be_stopped_through_run_api(self):
765        session = self.create(str(self.app.workspace))["id"]
766        started = self.cell(
767            session,
768            "import asyncio\n"
769            "rem = await remote.connect('fakehost')\n"
770            "fallback = await remote.connect('oldhost')\n"
771            "rem_job = rem.run('sleep', '8892')\n"
772            "fallback_job = fallback.run('sleep', '8893')\n"
773            "await asyncio.sleep(1)",
774        )
775        self.assertEqual(started["status"], "ok", started)
776
777        def cleanup():
778            try:
779                self.cell(
780                    session,
781                    "try:\n"
782                    "    await rem_job.stop()\n"
783                    "except Exception:\n"
784                    "    pass\n"
785                    "try:\n"
786                    "    await fallback_job.stop()\n"
787                    "except Exception:\n"
788                    "    pass\n"
789                    "try:\n"
790                    "    await rem.close()\n"
791                    "except Exception:\n"
792                    "    pass\n"
793                    "try:\n"
794                    "    await fallback.close()\n"
795                    "except Exception:\n"
796                    "    pass\n",
797                )
798            except Exception:
799                pass
800            for pid in processes("sleep 8892") + processes("sleep 8893"):
801                try:
802                    os.kill(pid, signal.SIGKILL)
803                except OSError:
804                    pass
805
806        self.addCleanup(cleanup)
807
808        kernel = json.load(self.app.api(f"/sessions/{session}?tail=0"))["kernel"]
809        self.assertEqual(kernel["live_job_count"], 2, kernel)
810        running = {job["command"]: job for job in kernel["running_jobs"]}
811        self.assertEqual(sorted(running), ["sleep 8892", "sleep 8893"], kernel)
812        rem_job_id = running["sleep 8892"]["id"]
813        fallback_job_id = running["sleep 8893"]["id"]
814        self.assertTrue(wait_for(lambda: processes("sleep 8892"), timeout=5))
815
816        with self.app.api(f"/extensions/run/sessions/{session}/jobs") as response:
817            page = json.load(response)["page"]
818        self.assertEqual(page["summary"], "2 background jobs running")
819        self.assertEqual(
820            sorted(row["text"] for row in page["rows"]), ["sleep 8892", "sleep 8893"]
821        )
822
823        # 3. Stopping an unknown job id still fails with 404
824        with self.assertRaises(urllib.error.HTTPError) as missing:
825            self.app.api(
826                f"/extensions/run/sessions/{session}/jobs/missing/stop",
827                method="POST",
828            )
829        self.assertEqual(missing.exception.code, 404)
830
831        # 1. Stop the kernel-mode remote job through the run service API
832        with self.app.api(
833            f"/extensions/run/sessions/{session}/jobs/{rem_job_id}/stop",
834            method="POST",
835        ) as response:
836            self.assertEqual(response.status, 200)
837            self.assertEqual(json.load(response), {"stopped": rem_job_id})
838
839        wait_for(
840            lambda: not processes("sleep 8892"),
841            timeout=15,
842            message="remote sleep 8892 process did not terminate after stop",
843        )
844
845        def rem_cleared_from_kernel():
846            with self.app.api(f"/sessions/{session}?tail=0") as resp:
847                k = json.load(resp)["kernel"]
848            ids = [job["id"] for job in k["running_jobs"]]
849            return rem_job_id not in ids and k["live_job_count"] == 1
850
851        wait_for(
852            rem_cleared_from_kernel,
853            timeout=15,
854            message="kernel observation did not drop stopped remote job",
855        )
856
857        def rem_cleared_from_page():
858            with self.app.api(f"/extensions/run/sessions/{session}/jobs") as resp:
859                p = json.load(resp)["page"]
860            ids = [row["id"] for row in p["rows"]]
861            return rem_job_id not in ids and p["summary"] == "1 background job running"
862
863        wait_for(
864            rem_cleared_from_page,
865            timeout=15,
866            message="jobs page did not drop stopped remote job",
867        )
868
869        # 2. Stop the degraded fallback job through the run service API
870        with self.app.api(
871            f"/extensions/run/sessions/{session}/jobs/{fallback_job_id}/stop",
872            method="POST",
873        ) as response:
874            self.assertEqual(response.status, 200)
875            self.assertEqual(json.load(response), {"stopped": fallback_job_id})
876
877        def fallback_cleared_from_kernel():
878            with self.app.api(f"/sessions/{session}?tail=0") as resp:
879                k = json.load(resp)["kernel"]
880            ids = [job["id"] for job in k["running_jobs"]]
881            return fallback_job_id not in ids and k["live_job_count"] == 0
882
883        wait_for(
884            fallback_cleared_from_kernel,
885            timeout=15,
886            message="kernel observation did not drop stopped fallback job",
887        )
888
889        def fallback_cleared_from_page():
890            with self.app.api(f"/extensions/run/sessions/{session}/jobs") as resp:
891                p = json.load(resp)["page"]
892            ids = [row["id"] for row in p["rows"]]
893            return fallback_job_id not in ids and p["rows"] == []
894
895        wait_for(
896            fallback_cleared_from_page,
897            timeout=15,
898            message="jobs page did not drop stopped fallback job",
899        )
900
901        output = self.cell(session, "fallback_job.tail()")
902        self.assertEqual(output["status"], "ok", output)
903        self.assertIn(
904            "the remote command may still be running",
905            output.get("value", "") + output.get("output", ""),
906        )
907
908        ended = self.cell(
909            session,
910            "await rem.close()\nawait fallback.close()\nawait asyncio.sleep(0.5)",
911        )
912        self.assertEqual(ended["status"], "ok", ended)
913
914    def test_hosts_list_recent_and_configured_hosts_without_probing(self):
915        self.create(f"fakehost:{self.project}")
916        with self.app.api("/hosts") as response:
917            hosts = json.load(response)["items"]
918        by_host = {entry["target"]: entry for entry in hosts}
919        self.assertTrue({"fakehost", "configured-box", "included-box"} <= set(by_host))
920        self.assertNotIn("*", by_host)
921        self.assertFalse([host for host in by_host if "*" in host])
922        # Nothing was probed just to list them.
923        self.assertEqual(by_host["configured-box"]["state"], "unknown")
924        self.assertIsNone(by_host["configured-box"]["observed_at"])
925
926    def browse(self, location, *, preview=False):
927        query = {"location": str(location)}
928        if preview:
929            query["include"] = "preview"
930        with self.app.api("/workspaces?" + urllib.parse.urlencode(query)) as response:
931            return json.load(response)
932
933    def assert_same_answers(self, tree):
934        """Remote and local inspection preserve the same filesystem facts."""
935        remote = self.browse(f"fakehost:{tree}", preview=True)
936        local = self.browse(tree, preview=True)
937        self.assertEqual(remote["directory"], f"fakehost:{tree}")
938        self.assertEqual(remote["home"], f"fakehost:{self.remote_home}")
939        self.assertEqual(
940            [
941                {key: row[key] for key in ("name", "vcs", "hidden", "modified_at")}
942                for row in remote["items"]
943            ],
944            [
945                {key: row[key] for key in ("name", "vcs", "hidden", "modified_at")}
946                for row in local["items"]
947            ],
948        )
949        remote_preview = remote["preview"]
950        local_preview = local["preview"]
951        for key in ("languages", "more"):
952            self.assertEqual(remote_preview[key], local_preview[key])
953        self.assertEqual(len(remote_preview["tree"]), len(local_preview["tree"]))
954        for remote_row, local_row in zip(remote_preview["tree"], local_preview["tree"]):
955            self.assertEqual(
956                remote_row["location"], "fakehost:" + local_row["location"]
957            )
958            self.assertEqual(
959                {
960                    key: value
961                    for key, value in remote_row.items()
962                    if key not in {"location", "children"}
963                },
964                {
965                    key: value
966                    for key, value in local_row.items()
967                    if key not in {"location", "children"}
968                },
969            )
970            self.assertEqual(len(remote_row["children"]), len(local_row["children"]))
971            for remote_child, local_child in zip(
972                remote_row["children"], local_row["children"]
973            ):
974                self.assertEqual(
975                    remote_child["location"], "fakehost:" + local_child["location"]
976                )
977                self.assertEqual(
978                    {
979                        key: value
980                        for key, value in remote_child.items()
981                        if key != "location"
982                    },
983                    {
984                        key: value
985                        for key, value in local_child.items()
986                        if key != "location"
987                    },
988                )
989        remote_repo = remote_preview["repository"]
990        local_repo = local_preview["repository"]
991        self.assertEqual(remote_repo is None, local_repo is None)
992        if remote_repo is not None:
993            self.assertEqual(remote_repo["root"], "fakehost:" + local_repo["root"])
994            self.assertEqual(
995                {key: value for key, value in remote_repo.items() if key != "root"},
996                {key: value for key, value in local_repo.items() if key != "root"},
997            )
998
999    def make_tree(self, name):
1000        tree = self.remote_home / name
1001        (tree / "src" / "deep").mkdir(parents=True)
1002        (tree / "docs").mkdir()
1003        (tree / "src" / "main.py").write_text("print('hi')\n" * 40)
1004        (tree / "src" / "deep" / "util.go").write_text("package deep\n")
1005        (tree / "README.md").write_text("# tree\n")
1006        (tree / ".hidden").mkdir()
1007        return tree
1008
1009    def test_a_remote_git_repo_browses_like_a_local_one(self):
1010        if not shutil.which("git"):
1011            self.skipTest("git is not installed")
1012        tree = self.make_tree("git-tree")
1013        git = ["git", "-c", "user.name=t", "-c", "user.email=t@e", "-C", str(tree)]
1014        subprocess.run([*git, "init", "-q", "-b", "main"], check=True)
1015        subprocess.run([*git, "add", "src", "README.md"], check=True)
1016        subprocess.run([*git, "commit", "-qm", "one"], check=True)
1017        (tree / "docs" / "new.md").write_text("new\n")
1018        self.assertEqual(
1019            self.browse(f"fakehost:{tree}", preview=True)["preview"]["repository"][
1020                "kind"
1021            ],
1022            "git",
1023        )
1024        self.assert_same_answers(tree)
1025        self.assert_same_answers(tree / "src")
1026
1027    def test_a_remote_jj_repo_browses_like_a_local_one(self):
1028        if not shutil.which("jj"):
1029            self.skipTest("jj is not installed")
1030        tree = self.make_tree("jj-tree")
1031        env = {**os.environ, "JJ_USER": "t", "JJ_EMAIL": "t@e"}
1032        subprocess.run(["jj", "git", "init", "--quiet", str(tree)], check=True, env=env)
1033        subprocess.run(
1034            ["jj", "-R", str(tree), "bookmark", "create", "main", "-r", "@", "--quiet"],
1035            check=True,
1036            env=env,
1037        )
1038        self.assertEqual(
1039            self.browse(f"fakehost:{tree}", preview=True)["preview"]["repository"][
1040                "kind"
1041            ],
1042            "jj",
1043        )
1044        self.assert_same_answers(tree)
1045
1046    def test_a_remote_folder_answers_with_the_hosts_state(self):
1047        query = urllib.parse.urlencode({"location": "gone:/srv"})
1048        with self.assertRaises(urllib.error.HTTPError) as refused:
1049            self.app.api(f"/workspaces?{query}").close()
1050        self.assertEqual(refused.exception.code, 503)
1051        body = json.loads(refused.exception.read())
1052        self.assertEqual(body["status"], 503)
1053        self.assertIn("Connection refused", body["detail"])
1054        self.assertEqual(self.host("gone")["state"], "unreachable")
1055        # A session there still composes, its project files skipped.
1056        session = self.create("gone:/srv/app")["id"]
1057        with self.app.api(f"/sessions/{session}/catalog") as response:
1058            self.assertTrue(json.load(response)["discovery"]["candidates"])
1059        # A ~ path lists the remote home.
1060        listed = self.browse("fakehost:~")
1061        self.assertEqual(listed["directory"], f"fakehost:{self.remote_home}")
1062        self.assertIn("proj", [entry["name"] for entry in listed["items"]])
1063
1064    def test_project_instructions_and_skills_come_from_the_host(self):
1065        (self.project / "AGENTS.md").write_text("remote rule: always say banana\n")
1066        skill = self.project / ".agents" / "skills" / "far-skill"
1067        skill.mkdir(parents=True)
1068        (skill / "SKILL.md").write_text(
1069            "---\nname: far-skill\ndescription: a skill that lives on the host\n---\nbody\n"
1070        )
1071        session = self.create(f"fakehost:{self.project}")["id"]
1072        self.app.prompt(session, "hello").close()
1073        self.app.idle(session, timeout=60)
1074        request = json.dumps(self.provider.requests[-1]["request"])
1075        self.assertIn("always say banana", request)
1076        self.assertIn("a skill that lives on the host", request)
1077        self.assertNotIn("aren't available yet", request)
1078
1079
1080if __name__ == "__main__":
1081    unittest.main()