termverify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termverify/__init__.py +137 -0
- termverify/_conpty.py +586 -0
- termverify/_enforcement_tier_v1.py +30 -0
- termverify/_json.py +7 -0
- termverify/_key_encoding_v1.py +158 -0
- termverify/_key_v1.py +109 -0
- termverify/_language_tag.py +108 -0
- termverify/_negotiation.py +175 -0
- termverify/_protocol_v1.py +26 -0
- termverify/_timezone_v1.py +374 -0
- termverify/adapter.py +1124 -0
- termverify/comparator.py +290 -0
- termverify/conpty.py +968 -0
- termverify/cooperation.py +233 -0
- termverify/direct.py +414 -0
- termverify/evidence.py +426 -0
- termverify/py.typed +0 -0
- termverify/recorder.py +512 -0
- termverify/replay.py +262 -0
- termverify/schema.py +39 -0
- termverify/schemas/termverify.transcript/v1.schema.json +205 -0
- termverify/transcript.py +1115 -0
- termverify/vt.py +508 -0
- termverify-0.1.0.dist-info/METADATA +125 -0
- termverify-0.1.0.dist-info/RECORD +26 -0
- termverify-0.1.0.dist-info/WHEEL +4 -0
termverify/_conpty.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""Minimal Windows ConPTY binding boundary for the terminal adapter plan.
|
|
2
|
+
|
|
3
|
+
This module is the only place that touches ``pywinpty``. It owns the native
|
|
4
|
+
``winpty._winpty.PTY`` object directly instead of ``winpty.PtyProcess``: the
|
|
5
|
+
``PtyProcess`` wrapper routes output through an internal socket-relay reader
|
|
6
|
+
thread that swallows the native end-of-stream signal and can drop buffered
|
|
7
|
+
output once the child exits, and the accepted verification plan rejects
|
|
8
|
+
reader-thread state as evidence. Driving the native object keeps every
|
|
9
|
+
observable — output bytes, end-of-stream, liveness, exit status — a direct
|
|
10
|
+
native signal.
|
|
11
|
+
|
|
12
|
+
Process-tree containment uses a Windows job object created per spawn with
|
|
13
|
+
``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` and neither breakaway limit, so every
|
|
14
|
+
descendant the child starts inherits membership and cannot leave. Forced
|
|
15
|
+
close terminates the whole tree atomically with ``TerminateJobObject``;
|
|
16
|
+
releasing the binding closes the job handle, which makes the OS sweep any
|
|
17
|
+
survivors even if this process dies abruptly. Disclosed boundary: the child
|
|
18
|
+
is assigned to the job immediately after ``CreateProcess`` returns, so a
|
|
19
|
+
process the child manages to start within that microseconds-wide window
|
|
20
|
+
would fall outside the job; the binding does not claim pre-start assignment.
|
|
21
|
+
|
|
22
|
+
I/O is single-flight by contract: at most one native read or write may be in
|
|
23
|
+
flight, and overlap fails fast with ``ConptyConcurrentIOError``. The native
|
|
24
|
+
layer is not thread-safe for overlapped calls on one pseudoconsole —
|
|
25
|
+
concurrent ``pty.write`` against a blocked ``pty.read`` intermittently
|
|
26
|
+
crashes the interpreter with a native access violation — and the transcript
|
|
27
|
+
protocol is single-flight anyway, so the binding refuses the overlap rather
|
|
28
|
+
than risking the crash. ``close`` is the one concurrent-safe operation; it
|
|
29
|
+
cancels in-flight I/O and waits it out before releasing the native object,
|
|
30
|
+
because releasing during a native call is the same crash.
|
|
31
|
+
|
|
32
|
+
The binding stays deliberately thin: every future adapter behavior above it
|
|
33
|
+
must be testable cross-platform against an injected fake binding, so this
|
|
34
|
+
native boundary is excluded from the coverage ratchet with recorded rationale
|
|
35
|
+
in the developer guide. Cancellation and recovery are evidenced at this
|
|
36
|
+
binding level only — startup failure fails closed for both a missing command
|
|
37
|
+
and a command the OS refuses to start, forced close recovers from hostile
|
|
38
|
+
children (output flood, busy spin, in-flight write) without leaking threads,
|
|
39
|
+
with handle release proven by the release-only close evidence, and conin
|
|
40
|
+
writes showed no backpressure on the verified matrix. Classification into
|
|
41
|
+
the structured failure/abort taxonomy is adapter behavior and remains
|
|
42
|
+
unclaimed here.
|
|
43
|
+
|
|
44
|
+
``write`` intentionally returns ``None``: the ConPTY write return value is not
|
|
45
|
+
a reliable byte-count receipt, and exposing it would fabricate evidence.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import contextlib
|
|
51
|
+
import os
|
|
52
|
+
import shutil
|
|
53
|
+
import subprocess
|
|
54
|
+
import sys
|
|
55
|
+
import threading
|
|
56
|
+
import time
|
|
57
|
+
from collections.abc import Mapping, Sequence
|
|
58
|
+
from typing import Any, Final
|
|
59
|
+
|
|
60
|
+
_CHILD_EXIT_WAIT_MS = 30_000
|
|
61
|
+
_READ_CANCEL_TIMEOUT_SECONDS = 30.0
|
|
62
|
+
_READ_CANCEL_RETRY_SECONDS = 0.01
|
|
63
|
+
|
|
64
|
+
#: Exit code set on every process in the tree by a forced close. The value
|
|
65
|
+
#: keeps parity with the previous single-process termination convention.
|
|
66
|
+
FORCED_TERMINATION_EXIT_CODE: Final = 15
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ConptyUnsupportedError(RuntimeError):
|
|
70
|
+
"""Raised when the ConPTY binding is used on a host without ConPTY."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_supported() -> bool:
|
|
74
|
+
"""Report whether this host can create ConPTY pseudoconsoles.
|
|
75
|
+
|
|
76
|
+
This is the explicit support probe the adapter consults during
|
|
77
|
+
negotiation, answering the same precondition :meth:`ConptyChild.spawn`
|
|
78
|
+
checks so platform support is decidable before any spawn is attempted.
|
|
79
|
+
It inspects no session state and creates nothing.
|
|
80
|
+
"""
|
|
81
|
+
return os.name == "nt"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ConptyClosedError(RuntimeError):
|
|
85
|
+
"""Raised when an operation is attempted after the binding was closed."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ConptyConcurrentIOError(RuntimeError):
|
|
89
|
+
"""Raised when a read or write is attempted while another is in flight.
|
|
90
|
+
|
|
91
|
+
The native layer is not thread-safe for overlapped calls on one
|
|
92
|
+
pseudoconsole: concurrent ``pty.write`` against a blocked ``pty.read``
|
|
93
|
+
intermittently dies with a native access violation. The transcript
|
|
94
|
+
protocol is single-flight, so the binding forbids overlap outright and
|
|
95
|
+
fails fast instead of risking the crash or deadlocking behind an
|
|
96
|
+
indefinitely blocked read. ``close`` is the one concurrent-safe
|
|
97
|
+
operation; it cancels in-flight I/O.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ConptyEndOfStreamError(Exception):
|
|
102
|
+
"""Raised by ``read`` when the native output pipe reports end-of-stream.
|
|
103
|
+
|
|
104
|
+
Only raised while the binding is open: a read interrupted by ``close``
|
|
105
|
+
raises :class:`ConptyClosedError` instead, because close may abandon
|
|
106
|
+
buffered output. On this genuine end-of-stream path Windows pipe
|
|
107
|
+
semantics deliver all buffered output before the read side observes the
|
|
108
|
+
broken pipe, so every byte the pseudoconsole emitted has been returned by
|
|
109
|
+
earlier ``read`` calls when this is raised.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if sys.platform == "win32":
|
|
114
|
+
import ctypes
|
|
115
|
+
from ctypes import wintypes
|
|
116
|
+
|
|
117
|
+
_SYNCHRONIZE = 0x0010_0000
|
|
118
|
+
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
119
|
+
_PROCESS_SET_QUOTA = 0x0100
|
|
120
|
+
_PROCESS_TERMINATE = 0x0001
|
|
121
|
+
_WAIT_OBJECT_0 = 0
|
|
122
|
+
_WAIT_TIMEOUT = 0x102
|
|
123
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
|
124
|
+
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
125
|
+
|
|
126
|
+
class _IoCounters(ctypes.Structure):
|
|
127
|
+
_fields_ = [
|
|
128
|
+
("ReadOperationCount", ctypes.c_uint64),
|
|
129
|
+
("WriteOperationCount", ctypes.c_uint64),
|
|
130
|
+
("OtherOperationCount", ctypes.c_uint64),
|
|
131
|
+
("ReadTransferCount", ctypes.c_uint64),
|
|
132
|
+
("WriteTransferCount", ctypes.c_uint64),
|
|
133
|
+
("OtherTransferCount", ctypes.c_uint64),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
class _JobBasicLimits(ctypes.Structure):
|
|
137
|
+
_fields_ = [
|
|
138
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
139
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
140
|
+
("LimitFlags", wintypes.DWORD),
|
|
141
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
142
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
143
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
144
|
+
("Affinity", ctypes.c_size_t),
|
|
145
|
+
("PriorityClass", wintypes.DWORD),
|
|
146
|
+
("SchedulingClass", wintypes.DWORD),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
class _JobExtendedLimits(ctypes.Structure):
|
|
150
|
+
_fields_ = [
|
|
151
|
+
("BasicLimitInformation", _JobBasicLimits),
|
|
152
|
+
("IoInfo", _IoCounters),
|
|
153
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
154
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
155
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
156
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
160
|
+
_kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
161
|
+
_kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
162
|
+
_kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
163
|
+
_kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
164
|
+
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
165
|
+
_kernel32.CloseHandle.restype = wintypes.BOOL
|
|
166
|
+
_kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
167
|
+
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
168
|
+
_kernel32.SetInformationJobObject.argtypes = [
|
|
169
|
+
wintypes.HANDLE,
|
|
170
|
+
ctypes.c_int,
|
|
171
|
+
ctypes.c_void_p,
|
|
172
|
+
wintypes.DWORD,
|
|
173
|
+
]
|
|
174
|
+
_kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
175
|
+
_kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
176
|
+
_kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
177
|
+
_kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
178
|
+
_kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
179
|
+
_kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
180
|
+
_kernel32.TerminateProcess.restype = wintypes.BOOL
|
|
181
|
+
|
|
182
|
+
def _create_containment_job() -> int:
|
|
183
|
+
"""Create a kill-on-close job object for one pseudoconsole child."""
|
|
184
|
+
job = _kernel32.CreateJobObjectW(None, None)
|
|
185
|
+
if not job:
|
|
186
|
+
raise OSError(f"CreateJobObject failed: {ctypes.get_last_error()}")
|
|
187
|
+
limits = _JobExtendedLimits()
|
|
188
|
+
limits.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
189
|
+
if not _kernel32.SetInformationJobObject(
|
|
190
|
+
job,
|
|
191
|
+
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
192
|
+
ctypes.byref(limits),
|
|
193
|
+
ctypes.sizeof(limits),
|
|
194
|
+
):
|
|
195
|
+
error = ctypes.get_last_error()
|
|
196
|
+
_kernel32.CloseHandle(job)
|
|
197
|
+
raise OSError(f"SetInformationJobObject failed: {error}")
|
|
198
|
+
return int(job)
|
|
199
|
+
|
|
200
|
+
def _open_containment_handle(pid: int) -> int:
|
|
201
|
+
"""Open the child's real process handle for assignment and waits."""
|
|
202
|
+
handle = _kernel32.OpenProcess(
|
|
203
|
+
_SYNCHRONIZE
|
|
204
|
+
| _PROCESS_QUERY_LIMITED_INFORMATION
|
|
205
|
+
| _PROCESS_SET_QUOTA
|
|
206
|
+
| _PROCESS_TERMINATE,
|
|
207
|
+
False,
|
|
208
|
+
pid,
|
|
209
|
+
)
|
|
210
|
+
if not handle:
|
|
211
|
+
raise OSError(f"OpenProcess({pid}) failed: {ctypes.get_last_error()}")
|
|
212
|
+
return int(handle)
|
|
213
|
+
|
|
214
|
+
def _assign_to_job(job: int, process_handle: int) -> None:
|
|
215
|
+
if not _kernel32.AssignProcessToJobObject(job, process_handle):
|
|
216
|
+
raise OSError(f"AssignProcessToJobObject failed: {ctypes.get_last_error()}")
|
|
217
|
+
|
|
218
|
+
def _terminate_job(job: int, exit_code: int) -> None:
|
|
219
|
+
if not _kernel32.TerminateJobObject(job, exit_code):
|
|
220
|
+
raise OSError(f"TerminateJobObject failed: {ctypes.get_last_error()}")
|
|
221
|
+
|
|
222
|
+
def _terminate_process(process_handle: int, exit_code: int) -> None:
|
|
223
|
+
_kernel32.TerminateProcess(process_handle, exit_code)
|
|
224
|
+
|
|
225
|
+
def _wait_for_handle(handle: int, timeout_ms: int) -> bool:
|
|
226
|
+
"""OS wait on a real handle; True once it is signaled, never a sleep.
|
|
227
|
+
|
|
228
|
+
A wait failure is a real error, not a timeout, and is raised as such
|
|
229
|
+
so it cannot masquerade as "the process did not terminate".
|
|
230
|
+
"""
|
|
231
|
+
result = int(_kernel32.WaitForSingleObject(handle, timeout_ms))
|
|
232
|
+
if result == _WAIT_OBJECT_0:
|
|
233
|
+
return True
|
|
234
|
+
if result == _WAIT_TIMEOUT:
|
|
235
|
+
return False
|
|
236
|
+
raise OSError(
|
|
237
|
+
f"WaitForSingleObject failed ({result:#x}): {ctypes.get_last_error()}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _close_handle(handle: int) -> None:
|
|
241
|
+
_kernel32.CloseHandle(handle)
|
|
242
|
+
|
|
243
|
+
else:
|
|
244
|
+
|
|
245
|
+
def _unsupported() -> ConptyUnsupportedError:
|
|
246
|
+
return ConptyUnsupportedError(
|
|
247
|
+
"the ConPTY binding requires Windows; this host has no ConPTY"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _create_containment_job() -> int:
|
|
251
|
+
raise _unsupported()
|
|
252
|
+
|
|
253
|
+
def _open_containment_handle(pid: int) -> int:
|
|
254
|
+
raise _unsupported()
|
|
255
|
+
|
|
256
|
+
def _assign_to_job(job: int, process_handle: int) -> None:
|
|
257
|
+
raise _unsupported()
|
|
258
|
+
|
|
259
|
+
def _terminate_job(job: int, exit_code: int) -> None:
|
|
260
|
+
raise _unsupported()
|
|
261
|
+
|
|
262
|
+
def _terminate_process(process_handle: int, exit_code: int) -> None:
|
|
263
|
+
raise _unsupported()
|
|
264
|
+
|
|
265
|
+
def _wait_for_handle(handle: int, timeout_ms: int) -> bool:
|
|
266
|
+
raise _unsupported()
|
|
267
|
+
|
|
268
|
+
def _close_handle(handle: int) -> None:
|
|
269
|
+
raise _unsupported()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class ConptyChild:
|
|
273
|
+
"""Thin ownership wrapper around one native ConPTY pseudoconsole child."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, pty: Any, pid: int, job: int, process_handle: int) -> None:
|
|
276
|
+
self._pty: Any | None = pty
|
|
277
|
+
self._pid = pid
|
|
278
|
+
self._job: int | None = job
|
|
279
|
+
self._process_handle: int | None = process_handle
|
|
280
|
+
self._exit_status: int | None = None
|
|
281
|
+
self._lock = threading.Lock()
|
|
282
|
+
self._pending_io = 0
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def spawn(
|
|
286
|
+
cls,
|
|
287
|
+
argv: Sequence[str],
|
|
288
|
+
*,
|
|
289
|
+
rows: int,
|
|
290
|
+
columns: int,
|
|
291
|
+
env_overlay: Mapping[str, str] | None = None,
|
|
292
|
+
cwd: str | None = None,
|
|
293
|
+
) -> ConptyChild:
|
|
294
|
+
"""Spawn a contained child on a ConPTY pseudoconsole.
|
|
295
|
+
|
|
296
|
+
The child is assigned to a fresh kill-on-close job object before the
|
|
297
|
+
binding is returned. If containment cannot be established, the child
|
|
298
|
+
is terminated and the spawn fails closed: no uncontained session is
|
|
299
|
+
ever handed out.
|
|
300
|
+
|
|
301
|
+
``env_overlay`` variables are overlaid onto this process's ambient
|
|
302
|
+
environment at spawn time; an overlay variable always wins over an
|
|
303
|
+
ambient variable of the same name. Disclosed: the child inherits the
|
|
304
|
+
ambient environment underneath the overlay — ambient contents are
|
|
305
|
+
not evidence and are not recorded, only the overlay is. ``cwd``
|
|
306
|
+
selects the child's working directory; without it, the child starts
|
|
307
|
+
in this process's current directory.
|
|
308
|
+
"""
|
|
309
|
+
if os.name != "nt":
|
|
310
|
+
raise ConptyUnsupportedError(
|
|
311
|
+
"the ConPTY binding requires Windows; this host has no ConPTY"
|
|
312
|
+
)
|
|
313
|
+
from winpty import Backend
|
|
314
|
+
from winpty._winpty import PTY
|
|
315
|
+
|
|
316
|
+
arguments = list(argv)
|
|
317
|
+
command = shutil.which(arguments[0])
|
|
318
|
+
if command is None:
|
|
319
|
+
raise FileNotFoundError(
|
|
320
|
+
f"the command was not found or was not executable: {arguments[0]}"
|
|
321
|
+
)
|
|
322
|
+
merged = dict(os.environ)
|
|
323
|
+
if env_overlay is not None:
|
|
324
|
+
merged.update(env_overlay)
|
|
325
|
+
pty = PTY(columns, rows, backend=Backend.ConPTY)
|
|
326
|
+
environment = (
|
|
327
|
+
"\0".join(f"{name}={value}" for name, value in merged.items()) + "\0"
|
|
328
|
+
)
|
|
329
|
+
cmdline = (
|
|
330
|
+
" " + subprocess.list2cmdline(arguments[1:]) if len(arguments) > 1 else None
|
|
331
|
+
)
|
|
332
|
+
working_directory = cwd if cwd is not None else os.getcwd()
|
|
333
|
+
try:
|
|
334
|
+
spawned = pty.spawn(
|
|
335
|
+
command, cmdline=cmdline, cwd=working_directory, env=environment
|
|
336
|
+
)
|
|
337
|
+
except Exception as error:
|
|
338
|
+
# Drop the native reference before raising so the held exception's
|
|
339
|
+
# traceback cannot pin the freshly created pseudoconsole.
|
|
340
|
+
del pty
|
|
341
|
+
raise OSError(f"ConPTY spawn failed for {command}") from error
|
|
342
|
+
if not spawned:
|
|
343
|
+
del pty
|
|
344
|
+
raise OSError(f"ConPTY spawn reported failure for {command}")
|
|
345
|
+
pid = int(pty.pid)
|
|
346
|
+
job: int | None = None
|
|
347
|
+
process_handle: int | None = None
|
|
348
|
+
try:
|
|
349
|
+
job = _create_containment_job()
|
|
350
|
+
process_handle = _open_containment_handle(pid)
|
|
351
|
+
_assign_to_job(job, process_handle)
|
|
352
|
+
except OSError as error:
|
|
353
|
+
if process_handle is not None:
|
|
354
|
+
_terminate_process(process_handle, FORCED_TERMINATION_EXIT_CODE)
|
|
355
|
+
_close_handle(process_handle)
|
|
356
|
+
if job is not None:
|
|
357
|
+
_close_handle(job)
|
|
358
|
+
# Drop the native reference before raising so the exception
|
|
359
|
+
# traceback cannot pin the pseudoconsole handles.
|
|
360
|
+
del pty
|
|
361
|
+
raise OSError(
|
|
362
|
+
f"failed to contain ConPTY child {pid} in a job object"
|
|
363
|
+
) from error
|
|
364
|
+
return cls(pty, pid, job, process_handle)
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def pid(self) -> int:
|
|
368
|
+
"""Return the child's OS process id."""
|
|
369
|
+
return self._pid
|
|
370
|
+
|
|
371
|
+
def read(self) -> str:
|
|
372
|
+
"""Block until pseudoconsole output is available and return it.
|
|
373
|
+
|
|
374
|
+
Raises :class:`ConptyEndOfStreamError` when the binding is still open
|
|
375
|
+
and the native output pipe reports end-of-stream after the child has
|
|
376
|
+
exited; the native exit status has been captured by then. Raises
|
|
377
|
+
:class:`ConptyClosedError` when the binding is closed before or while
|
|
378
|
+
the read is in flight, and :class:`ConptyConcurrentIOError` when
|
|
379
|
+
another read or write is already in flight. Any other native read
|
|
380
|
+
failure — the binding open, the child alive — is re-raised unchanged.
|
|
381
|
+
"""
|
|
382
|
+
pty = self._begin_io()
|
|
383
|
+
try:
|
|
384
|
+
return str(pty.read(blocking=True))
|
|
385
|
+
except Exception as error:
|
|
386
|
+
replacement = self._classify_io_failure(pty, end_of_stream=True)
|
|
387
|
+
# Drop the frame-local native reference before raising: the
|
|
388
|
+
# exception's traceback keeps this frame alive, and a pinned
|
|
389
|
+
# native object would defer the handle release indefinitely.
|
|
390
|
+
del pty
|
|
391
|
+
if replacement is None:
|
|
392
|
+
raise
|
|
393
|
+
raise replacement from error
|
|
394
|
+
finally:
|
|
395
|
+
self._end_io()
|
|
396
|
+
|
|
397
|
+
def write(self, text: str) -> None:
|
|
398
|
+
"""Write ``text`` to the child without claiming a byte-count receipt.
|
|
399
|
+
|
|
400
|
+
Raises :class:`ConptyClosedError` when the binding is closed before
|
|
401
|
+
or while the write is in flight, and
|
|
402
|
+
:class:`ConptyConcurrentIOError` when another read or write is
|
|
403
|
+
already in flight; other native write failures are re-raised
|
|
404
|
+
unchanged.
|
|
405
|
+
"""
|
|
406
|
+
pty = self._begin_io()
|
|
407
|
+
try:
|
|
408
|
+
pty.write(text)
|
|
409
|
+
except Exception as error:
|
|
410
|
+
replacement = self._classify_io_failure(pty, end_of_stream=False)
|
|
411
|
+
del pty
|
|
412
|
+
if replacement is None:
|
|
413
|
+
raise
|
|
414
|
+
raise replacement from error
|
|
415
|
+
finally:
|
|
416
|
+
self._end_io()
|
|
417
|
+
|
|
418
|
+
def resize(self, *, rows: int, columns: int) -> None:
|
|
419
|
+
"""Resize the pseudoconsole explicitly."""
|
|
420
|
+
self._require_open().set_size(columns, rows)
|
|
421
|
+
|
|
422
|
+
def is_alive(self) -> bool:
|
|
423
|
+
"""Report whether the child process is still alive.
|
|
424
|
+
|
|
425
|
+
A closed binding reports ``False``: it no longer owns a live native
|
|
426
|
+
session through which liveness could be observed.
|
|
427
|
+
"""
|
|
428
|
+
pty = self._pty
|
|
429
|
+
return False if pty is None else bool(pty.isalive())
|
|
430
|
+
|
|
431
|
+
def close(self, *, force: bool) -> None:
|
|
432
|
+
"""Release native ownership; with ``force``, terminate the child's tree.
|
|
433
|
+
|
|
434
|
+
The forced path terminates the entire job — the child and every
|
|
435
|
+
descendant — atomically with ``TerminateJobObject`` (uniform exit
|
|
436
|
+
code :data:`FORCED_TERMINATION_EXIT_CODE`), waits on the child's real
|
|
437
|
+
process handle, and captures the native exit record before releasing
|
|
438
|
+
the handles.
|
|
439
|
+
|
|
440
|
+
A release-only close (``force=False``) of a live child records no
|
|
441
|
+
exit status — the binding never observed a native exit record — while
|
|
442
|
+
the pseudoconsole handle release itself makes ConPTY terminate the
|
|
443
|
+
attached client, which callers can observe at the OS level. The close
|
|
444
|
+
waits for that termination on the child's process handle and then
|
|
445
|
+
closes the job handle, whose kill-on-close limit sweeps any remaining
|
|
446
|
+
descendants; the no-kill path therefore cannot leak a process tree
|
|
447
|
+
either.
|
|
448
|
+
|
|
449
|
+
Close first unpublishes the native object so no new I/O can start,
|
|
450
|
+
then cancels pending native I/O until every in-flight read and write
|
|
451
|
+
has returned. Interrupted I/O surfaces :class:`ConptyClosedError` with
|
|
452
|
+
its frame-local native reference dropped, so a held exception cannot
|
|
453
|
+
pin the native object and the handles are released as soon as the
|
|
454
|
+
last frame still holding it unwinds.
|
|
455
|
+
"""
|
|
456
|
+
with self._lock:
|
|
457
|
+
pty = self._pty
|
|
458
|
+
if pty is None:
|
|
459
|
+
return
|
|
460
|
+
self._pty = None
|
|
461
|
+
job = self._job
|
|
462
|
+
process_handle = self._process_handle
|
|
463
|
+
self._job = None
|
|
464
|
+
self._process_handle = None
|
|
465
|
+
child_exited = True
|
|
466
|
+
try:
|
|
467
|
+
try:
|
|
468
|
+
if force and pty.isalive():
|
|
469
|
+
if job is None:
|
|
470
|
+
# Defensive: unreachable on the only construction
|
|
471
|
+
# path; failing fast beats waiting on a live child.
|
|
472
|
+
raise OSError("no containment job to terminate")
|
|
473
|
+
_terminate_job(job, FORCED_TERMINATION_EXIT_CODE)
|
|
474
|
+
if process_handle is not None and not _wait_for_handle(
|
|
475
|
+
process_handle, _CHILD_EXIT_WAIT_MS
|
|
476
|
+
):
|
|
477
|
+
raise OSError(
|
|
478
|
+
f"child process {self._pid} did not terminate on"
|
|
479
|
+
" forced close"
|
|
480
|
+
)
|
|
481
|
+
if not pty.isalive():
|
|
482
|
+
self._capture_exit_status(pty)
|
|
483
|
+
finally:
|
|
484
|
+
try:
|
|
485
|
+
self._cancel_pending_io(pty)
|
|
486
|
+
finally:
|
|
487
|
+
# Release the binding's native reference even when the
|
|
488
|
+
# cancel loop raises: the propagating exception's
|
|
489
|
+
# traceback must never pin the native object. The
|
|
490
|
+
# destructor closes the pseudoconsole once the last
|
|
491
|
+
# in-flight frame unwinds.
|
|
492
|
+
del pty
|
|
493
|
+
if not force and process_handle is not None:
|
|
494
|
+
child_exited = _wait_for_handle(process_handle, _CHILD_EXIT_WAIT_MS)
|
|
495
|
+
finally:
|
|
496
|
+
if job is not None:
|
|
497
|
+
# Kill-on-close sweeps every remaining job member, so even a
|
|
498
|
+
# failed graceful path cannot leak the tree.
|
|
499
|
+
_close_handle(job)
|
|
500
|
+
if not child_exited and process_handle is not None:
|
|
501
|
+
child_exited = _wait_for_handle(process_handle, _CHILD_EXIT_WAIT_MS)
|
|
502
|
+
if process_handle is not None:
|
|
503
|
+
_close_handle(process_handle)
|
|
504
|
+
if not child_exited:
|
|
505
|
+
raise OSError(
|
|
506
|
+
f"child process {self._pid} did not terminate after handle release"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def _cancel_pending_io(self, pty: Any) -> None:
|
|
510
|
+
"""Cancel native I/O until no read or write frame can block on ``pty``."""
|
|
511
|
+
deadline = time.monotonic() + _READ_CANCEL_TIMEOUT_SECONDS
|
|
512
|
+
while True:
|
|
513
|
+
with self._lock:
|
|
514
|
+
pending = self._pending_io
|
|
515
|
+
if pending == 0:
|
|
516
|
+
return
|
|
517
|
+
with contextlib.suppress(Exception):
|
|
518
|
+
pty.cancel_io()
|
|
519
|
+
if time.monotonic() >= deadline:
|
|
520
|
+
raise OSError("pending native ConPTY I/O did not cancel during close")
|
|
521
|
+
time.sleep(_READ_CANCEL_RETRY_SECONDS)
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def exit_status(self) -> int | None:
|
|
525
|
+
"""Return the natively observed exit status, else ``None``."""
|
|
526
|
+
pty = self._pty
|
|
527
|
+
if self._exit_status is None and pty is not None:
|
|
528
|
+
self._capture_exit_status(pty)
|
|
529
|
+
return self._exit_status
|
|
530
|
+
|
|
531
|
+
def _require_open(self) -> Any:
|
|
532
|
+
pty = self._pty
|
|
533
|
+
if pty is None:
|
|
534
|
+
raise ConptyClosedError("the ConPTY binding is closed")
|
|
535
|
+
return pty
|
|
536
|
+
|
|
537
|
+
def _begin_io(self) -> Any:
|
|
538
|
+
"""Atomically take the native object and count the in-flight call.
|
|
539
|
+
|
|
540
|
+
At most one read or write may be in flight: overlapped native calls
|
|
541
|
+
on one pseudoconsole can crash the interpreter, and blocking a write
|
|
542
|
+
behind an indefinitely blocked read would deadlock, so overlap fails
|
|
543
|
+
fast as :class:`ConptyConcurrentIOError`.
|
|
544
|
+
"""
|
|
545
|
+
with self._lock:
|
|
546
|
+
pty = self._pty
|
|
547
|
+
if pty is None:
|
|
548
|
+
raise ConptyClosedError("the ConPTY binding is closed")
|
|
549
|
+
if self._pending_io > 0:
|
|
550
|
+
raise ConptyConcurrentIOError(
|
|
551
|
+
"another native read or write is already in flight; the"
|
|
552
|
+
" binding is single-flight by contract"
|
|
553
|
+
)
|
|
554
|
+
self._pending_io += 1
|
|
555
|
+
return pty
|
|
556
|
+
|
|
557
|
+
def _end_io(self) -> None:
|
|
558
|
+
with self._lock:
|
|
559
|
+
self._pending_io -= 1
|
|
560
|
+
|
|
561
|
+
def _classify_io_failure(
|
|
562
|
+
self, pty: Any, *, end_of_stream: bool
|
|
563
|
+
) -> Exception | None:
|
|
564
|
+
"""Map a native I/O failure to the binding's honest exception, if any.
|
|
565
|
+
|
|
566
|
+
A failure observed after ``close`` unpublished the native object is
|
|
567
|
+
the close's own cancellation (or indistinguishable from it) and
|
|
568
|
+
becomes :class:`ConptyClosedError` — never an end-of-stream claim,
|
|
569
|
+
because close may have abandoned buffered output. A read failure on
|
|
570
|
+
an open binding with a dead child is the native end-of-stream signal.
|
|
571
|
+
Anything else is the caller's to see unchanged (``None``).
|
|
572
|
+
"""
|
|
573
|
+
if not pty.isalive():
|
|
574
|
+
self._capture_exit_status(pty)
|
|
575
|
+
if self._pty is None:
|
|
576
|
+
return ConptyClosedError("the ConPTY binding was closed during native I/O")
|
|
577
|
+
if end_of_stream and not pty.isalive():
|
|
578
|
+
return ConptyEndOfStreamError(
|
|
579
|
+
"the native ConPTY output pipe reported end-of-stream"
|
|
580
|
+
)
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
def _capture_exit_status(self, pty: Any) -> None:
|
|
584
|
+
status = pty.get_exitstatus()
|
|
585
|
+
if status is not None:
|
|
586
|
+
self._exit_status = int(status)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Protocol-owned enforcement-tier vocabulary `termverify.enforcement-tier/v1`.
|
|
2
|
+
|
|
3
|
+
A closed, versioned vocabulary owned by the protocol exactly like the
|
|
4
|
+
timezone and key registries: exact case-sensitive membership, no aliases or
|
|
5
|
+
normalization, runtime validation authoritative. Post-freeze membership or
|
|
6
|
+
meaning changes require a new vocabulary version. Membership is not evidence
|
|
7
|
+
that an emitter exists; each tier's authorized emitting path is fixed by the
|
|
8
|
+
accepted cooperation-tier design
|
|
9
|
+
(`docs/agent/design/cooperation-tier-constraint-ports.md`) and enforced
|
|
10
|
+
fail-closed during receipt-binding validation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Literal, TypeGuard
|
|
16
|
+
|
|
17
|
+
type EnforcementTier = Literal["os", "constructive", "delivered"]
|
|
18
|
+
|
|
19
|
+
#: The closed v1 membership, in disclosure-strength order: ``os`` (applied by
|
|
20
|
+
#: an operating-system mechanism at the subject boundary), ``constructive``
|
|
21
|
+
#: (applied by construction of the controlled in-process runtime),
|
|
22
|
+
#: ``delivered`` (placed into the subject's spawn environment; honoring it is
|
|
23
|
+
#: subject cooperation — nothing is enforced).
|
|
24
|
+
ENFORCEMENT_TIERS: tuple[EnforcementTier, ...] = ("os", "constructive", "delivered")
|
|
25
|
+
_ENFORCEMENT_TIER_SET = frozenset(ENFORCEMENT_TIERS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_enforcement_tier(value: object) -> TypeGuard[EnforcementTier]:
|
|
29
|
+
"""Return whether *value* is an exact v1 enforcement-tier member."""
|
|
30
|
+
return type(value) is str and value in _ENFORCEMENT_TIER_SET
|