agent-bios 0.19.2 → 0.19.3
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.
- package/DEPENDENCIES.md +32 -4
- package/README.md +54 -4
- package/claude/guides/cli-multi-model-workflow.md +1 -1
- package/claude/guides/coding-staged-workflow.md +17 -0
- package/claude/guides/llm-capability-boundary.md +7 -1
- package/claude/guides/tooling-gotchas.md +20 -1
- package/claude/guides/ui-design/visual-direction.md +88 -0
- package/claude/guides/ui-design.md +90 -0
- package/claude/guides/verification-discipline.md +10 -1
- package/claude/hooks/tooling-gotchas-hook.py +41 -0
- package/codex/guides/cli-multi-model-workflow.md +1 -1
- package/codex/guides/coding-staged-workflow.md +17 -0
- package/codex/guides/llm-capability-boundary.md +7 -1
- package/codex/guides/tooling-gotchas.md +20 -1
- package/codex/guides/ui-design/visual-direction.md +88 -0
- package/codex/guides/ui-design.md +90 -0
- package/codex/guides/verification-discipline.md +10 -1
- package/compose/app_bridge/scripts/bridge.py +23 -6
- package/compose/app_desktop/server.py +250 -0
- package/compose/domains.json +1 -0
- package/compose/host_platform.py +121 -0
- package/compose/instructions-state.py +5 -2
- package/compose/instructions_app.py +281 -48
- package/compose/instructions_catalog.py +2 -2
- package/compose/instructions_import.py +19 -11
- package/compose/instructions_install.py +77 -11
- package/compose/instructions_session.py +6 -6
- package/compose/instructions_setup.py +41 -4
- package/compose/instructions_setup_cli.py +31 -10
- package/compose/instructions_setup_i18n.py +3 -0
- package/compose/instructions_store.py +6 -6
- package/compose/instructions_transaction.py +8 -6
- package/compose/instructions_ui_runtime.py +2 -1
- package/compose/native_cli.py +52 -0
- package/compose/runtime_entry.py +58 -0
- package/compose/windows_deploy.py +719 -0
- package/docs/instructions.md +1 -0
- package/docs/releases/0.19.3.md +107 -0
- package/docs/session-model.md +8 -0
- package/docs/setup.md +36 -0
- package/docs/windows.md +99 -0
- package/install.sh +1 -0
- package/launch/agent-launch.py +12 -4
- package/launch/agent-launch.zsh +11 -1
- package/package.json +8 -3
- package/provenance.json +1 -1
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""Owned Windows script deployment; private instructions remain installer-owned.
|
|
2
|
+
|
|
3
|
+
The caller authenticates the downloaded bundle before invoking this module. This
|
|
4
|
+
owner checks its contents, interpreter binding and destinations, stages immutable
|
|
5
|
+
versions, and publishes a data binding consumed by the signed static wrappers.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from .host_platform import redirected
|
|
24
|
+
from .instructions_transaction import transaction_lock, reject_symlink_ancestors
|
|
25
|
+
except ImportError:
|
|
26
|
+
from host_platform import redirected
|
|
27
|
+
from instructions_transaction import transaction_lock, reject_symlink_ancestors
|
|
28
|
+
|
|
29
|
+
OWNER = "agent-bios-windows-script"
|
|
30
|
+
SCHEMA_VERSION = 1
|
|
31
|
+
COMMANDS = ("agent-bios.ps1", "agent-launch.ps1")
|
|
32
|
+
# A directory on PATH holding only .ps1 files is reachable from PowerShell alone:
|
|
33
|
+
# Windows does not treat .ps1 as executable, so cmd.exe, Win+R and any program
|
|
34
|
+
# that spawns a command see nothing. Each command therefore also gets a cmd shim.
|
|
35
|
+
# The shim is generated at installation rather than shipped in the bundle because
|
|
36
|
+
# Authenticode cannot sign a .cmd, and a signed bundle carrying an unsignable
|
|
37
|
+
# member would say less than its signature appears to say. Its bytes are fixed --
|
|
38
|
+
# %~dp0 resolves the directory at run time -- so its digest is a stable claim.
|
|
39
|
+
SHIMS = {"agent-bios.cmd": "agent-bios.ps1", "agent-launch.cmd": "agent-launch.ps1"}
|
|
40
|
+
ENTRY = "runtime_entry.py"
|
|
41
|
+
PRIVATE_ENVIRONMENT = ("HOME", "AGENT_BIOS_STATE_DIR", "AGENT_BIOS_INSTRUCTIONS_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DeploymentError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _shim(target: str) -> bytes:
|
|
49
|
+
"""A cmd.exe entry point for a PowerShell command in the same directory.
|
|
50
|
+
|
|
51
|
+
The interpreter is named by its fixed location, not looked up on PATH, which
|
|
52
|
+
is what the Start menu shortcut already does. A caller whose PATH lacks the
|
|
53
|
+
Windows PowerShell directory is exactly the caller this shim exists for, and
|
|
54
|
+
a bare `powershell.exe` fails there with cmd's own 9009.
|
|
55
|
+
"""
|
|
56
|
+
if target not in COMMANDS:
|
|
57
|
+
raise DeploymentError(f"a shim may only target an owned command: {target}")
|
|
58
|
+
return ("@echo off\r\n"
|
|
59
|
+
'"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" '
|
|
60
|
+
"-NoProfile -ExecutionPolicy Bypass "
|
|
61
|
+
f'-File "%~dp0{target}" %*\r\n'
|
|
62
|
+
"exit /b %ERRORLEVEL%\r\n").encode("ascii")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _shim_inventory() -> dict[str, str]:
|
|
66
|
+
return {name: hashlib.sha256(_shim(target)).hexdigest() for name, target in SHIMS.items()}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _hash(path: Path) -> str:
|
|
70
|
+
digest = hashlib.sha256()
|
|
71
|
+
with path.open("rb") as stream:
|
|
72
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
73
|
+
digest.update(chunk)
|
|
74
|
+
return digest.hexdigest()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _canonical(data: Any) -> bytes:
|
|
78
|
+
return (json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _atomic(path: Path, data: bytes) -> None:
|
|
82
|
+
reject_symlink_ancestors(path)
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
fd, temporary = tempfile.mkstemp(prefix=".deploy-", dir=path.parent)
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(fd, "wb") as stream:
|
|
87
|
+
stream.write(data)
|
|
88
|
+
stream.flush()
|
|
89
|
+
os.fsync(stream.fileno())
|
|
90
|
+
os.replace(temporary, path)
|
|
91
|
+
finally:
|
|
92
|
+
with contextlib.suppress(FileNotFoundError):
|
|
93
|
+
os.unlink(temporary)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _json(path: Path) -> dict[str, Any]:
|
|
97
|
+
reject_symlink_ancestors(path)
|
|
98
|
+
try:
|
|
99
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
100
|
+
except (OSError, ValueError) as exc:
|
|
101
|
+
raise DeploymentError(f"invalid deployment record: {path}") from exc
|
|
102
|
+
if not isinstance(value, dict):
|
|
103
|
+
raise DeploymentError(f"invalid deployment record: {path}")
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _within(path: Path, root: Path) -> bool:
|
|
108
|
+
try:
|
|
109
|
+
path.relative_to(root)
|
|
110
|
+
return True
|
|
111
|
+
except ValueError:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _inventory(root: Path) -> dict[str, str]:
|
|
116
|
+
reject_symlink_ancestors(root)
|
|
117
|
+
if not root.is_dir():
|
|
118
|
+
raise DeploymentError(f"required directory is absent: {root}")
|
|
119
|
+
result: dict[str, str] = {}
|
|
120
|
+
for directory, directories, files in os.walk(root, followlinks=False):
|
|
121
|
+
for name in directories + files:
|
|
122
|
+
path = Path(directory) / name
|
|
123
|
+
if redirected(path):
|
|
124
|
+
raise DeploymentError(f"redirected bundle path: {path}")
|
|
125
|
+
if name in files:
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
raise DeploymentError(f"non-file bundle entry: {path}")
|
|
128
|
+
result[path.relative_to(root).as_posix()] = _hash(path)
|
|
129
|
+
return dict(sorted(result.items()))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_inventory(root: Path, inventory: dict[str, str], *, exact: bool = True) -> None:
|
|
133
|
+
if not isinstance(inventory, dict) or not inventory:
|
|
134
|
+
raise DeploymentError(f"empty file inventory: {root}")
|
|
135
|
+
for name, digest in inventory.items():
|
|
136
|
+
relative = Path(name)
|
|
137
|
+
if relative.is_absolute() or ".." in relative.parts or "\\" in name or ":" in name:
|
|
138
|
+
raise DeploymentError(f"unsafe inventory path: {name}")
|
|
139
|
+
path = root / relative
|
|
140
|
+
reject_symlink_ancestors(path)
|
|
141
|
+
if not path.is_file() or _hash(path) != digest:
|
|
142
|
+
raise DeploymentError(f"owned file drifted: {path}")
|
|
143
|
+
if exact and _inventory(root) != inventory:
|
|
144
|
+
raise DeploymentError(f"unexpected files in owned release: {root}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _version(value: str) -> tuple[int, int, int, int, tuple]:
|
|
148
|
+
if not isinstance(value, str):
|
|
149
|
+
raise DeploymentError("application version is missing")
|
|
150
|
+
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?", value)
|
|
151
|
+
if not match:
|
|
152
|
+
raise DeploymentError(f"unsupported version: {value!r}")
|
|
153
|
+
parts = tuple((0, int(part)) if part.isdigit() else (1, part) for part in (match[4] or "").split("."))
|
|
154
|
+
return int(match[1]), int(match[2]), int(match[3]), int(match[4] is None), parts
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _same_path(left: str, right: str) -> bool:
|
|
158
|
+
left, right = os.path.expandvars(left.strip('"')), os.path.expandvars(right.strip('"'))
|
|
159
|
+
try:
|
|
160
|
+
return os.path.samefile(left, right)
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class WindowsIntegration:
|
|
166
|
+
"""HKCU PATH and per-root Start menu entries, with exact ownership receipts."""
|
|
167
|
+
|
|
168
|
+
def __init__(self):
|
|
169
|
+
if os.name != "nt":
|
|
170
|
+
raise DeploymentError("Windows deployment must run on Windows")
|
|
171
|
+
import winreg
|
|
172
|
+
self.registry = winreg
|
|
173
|
+
|
|
174
|
+
def _path(self) -> tuple[str, int]:
|
|
175
|
+
reg = self.registry
|
|
176
|
+
with reg.CreateKey(reg.HKEY_CURRENT_USER, "Environment") as key:
|
|
177
|
+
try:
|
|
178
|
+
value, kind = reg.QueryValueEx(key, "Path")
|
|
179
|
+
if kind not in (reg.REG_SZ, reg.REG_EXPAND_SZ):
|
|
180
|
+
raise DeploymentError("user PATH has an unsupported registry type")
|
|
181
|
+
return value, kind
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
return "", reg.REG_EXPAND_SZ
|
|
184
|
+
|
|
185
|
+
def _set_path(self, value: str, kind: int) -> None:
|
|
186
|
+
reg = self.registry
|
|
187
|
+
with reg.CreateKey(reg.HKEY_CURRENT_USER, "Environment") as key:
|
|
188
|
+
reg.SetValueEx(key, "Path", 0, kind, value)
|
|
189
|
+
import ctypes
|
|
190
|
+
result = ctypes.c_size_t()
|
|
191
|
+
ctypes.windll.user32.SendMessageTimeoutW(0xFFFF, 0x1A, 0, "Environment", 2, 2000, ctypes.byref(result))
|
|
192
|
+
|
|
193
|
+
# Static C# source, ASCII only: it is delivered on stdin and compiled by the
|
|
194
|
+
# shell, so no path or user value is ever interpolated into program text.
|
|
195
|
+
SHORTCUT_SOURCE = """
|
|
196
|
+
using System;
|
|
197
|
+
using System.Runtime.InteropServices;
|
|
198
|
+
using System.Runtime.InteropServices.ComTypes;
|
|
199
|
+
using System.Text;
|
|
200
|
+
namespace AgentBiosShortcut {
|
|
201
|
+
[ComImport, Guid("00021401-0000-0000-C000-000000000046")]
|
|
202
|
+
public class ShellLink {}
|
|
203
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
|
|
204
|
+
public interface IShellLinkW {
|
|
205
|
+
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
|
|
206
|
+
void GetIDList(out IntPtr ppidl);
|
|
207
|
+
void SetIDList(IntPtr pidl);
|
|
208
|
+
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
|
|
209
|
+
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
|
210
|
+
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
|
|
211
|
+
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
|
|
212
|
+
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
|
|
213
|
+
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
|
|
214
|
+
void GetHotkey(out short pwHotkey);
|
|
215
|
+
void SetHotkey(short wHotkey);
|
|
216
|
+
void GetShowCmd(out int piShowCmd);
|
|
217
|
+
void SetShowCmd(int iShowCmd);
|
|
218
|
+
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
|
|
219
|
+
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
|
|
220
|
+
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
|
|
221
|
+
void Resolve(IntPtr hwnd, int fFlags);
|
|
222
|
+
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
|
|
223
|
+
}
|
|
224
|
+
public static class Writer {
|
|
225
|
+
public static void Save(string path, string target, string arguments, string workingDirectory) {
|
|
226
|
+
IShellLinkW link = (IShellLinkW)new ShellLink();
|
|
227
|
+
link.SetPath(target);
|
|
228
|
+
link.SetArguments(arguments);
|
|
229
|
+
link.SetWorkingDirectory(workingDirectory);
|
|
230
|
+
((IPersistFile)link).Save(path, false);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def _shortcut(self, target: Path, destination: Path, command: str) -> None:
|
|
237
|
+
# The executed PowerShell program is static; paths are data environment
|
|
238
|
+
# values, never interpolated into its source or encoded as a workaround.
|
|
239
|
+
# IShellLinkW is used directly: the WScript.Shell shortcut object cannot
|
|
240
|
+
# save to paths with characters outside the system ANSI code page.
|
|
241
|
+
script = ("$ErrorActionPreference='Stop'; $source=[Console]::In.ReadToEnd(); "
|
|
242
|
+
"Add-Type -TypeDefinition $source -Language CSharp; "
|
|
243
|
+
"[AgentBiosShortcut.Writer]::Save($env:AGENT_BIOS_SHORTCUT_DESTINATION, $env:AGENT_BIOS_SHORTCUT_SHELL, "
|
|
244
|
+
"('-NoProfile -File '+[char]34+$env:AGENT_BIOS_SHORTCUT_TARGET+[char]34+' '+$env:AGENT_BIOS_SHORTCUT_COMMAND), "
|
|
245
|
+
"$env:USERPROFILE)")
|
|
246
|
+
powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
|
247
|
+
env = dict(os.environ, AGENT_BIOS_SHORTCUT_DESTINATION=str(destination),
|
|
248
|
+
AGENT_BIOS_SHORTCUT_SHELL=str(powershell), AGENT_BIOS_SHORTCUT_TARGET=str(target),
|
|
249
|
+
AGENT_BIOS_SHORTCUT_COMMAND=command)
|
|
250
|
+
result = subprocess.run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", script], env=env,
|
|
251
|
+
input=self.SHORTCUT_SOURCE, capture_output=True, text=True, encoding="utf-8",
|
|
252
|
+
errors="replace", timeout=180)
|
|
253
|
+
if result.returncode:
|
|
254
|
+
detail = (result.stderr or result.stdout).strip().replace("\r\n", " ")[:600]
|
|
255
|
+
raise DeploymentError(f"Start menu shortcut creation failed (exit {result.returncode}): {detail}")
|
|
256
|
+
|
|
257
|
+
def _group(self, root: Path) -> Path:
|
|
258
|
+
token = hashlib.sha256(os.path.normcase(str(root)).encode()).hexdigest()[:12]
|
|
259
|
+
return Path(os.environ["APPDATA"]) / "Microsoft/Windows/Start Menu/Programs" / ("agent-bios Script " + token)
|
|
260
|
+
|
|
261
|
+
def _validate(self, root: Path, receipt: dict[str, Any]) -> None:
|
|
262
|
+
if not receipt:
|
|
263
|
+
return
|
|
264
|
+
if receipt.get("owner") != OWNER or receipt.get("root") != str(root) or receipt.get("schema_version") != SCHEMA_VERSION:
|
|
265
|
+
raise DeploymentError("invalid platform ownership receipt")
|
|
266
|
+
if receipt.get("path_entry") not in (None, str(root / "bin")):
|
|
267
|
+
raise DeploymentError("invalid owned PATH entry")
|
|
268
|
+
group = self._group(root)
|
|
269
|
+
if receipt.get("shortcut_group") not in (None, str(group)):
|
|
270
|
+
raise DeploymentError("invalid Start menu group ownership")
|
|
271
|
+
for collection in (receipt.get("shortcuts", {}), receipt.get("pending_shortcuts", {})):
|
|
272
|
+
if not isinstance(collection, dict):
|
|
273
|
+
raise DeploymentError("invalid Start menu ownership inventory")
|
|
274
|
+
for name in collection:
|
|
275
|
+
path = Path(name)
|
|
276
|
+
if path.parent != group or path.name not in ("agent-bios.lnk", "Instructions Studio.lnk"):
|
|
277
|
+
raise DeploymentError("invalid Start menu ownership claim")
|
|
278
|
+
reject_symlink_ancestors(path)
|
|
279
|
+
|
|
280
|
+
def apply(self, root: Path, prior: dict[str, Any]) -> dict[str, Any]:
|
|
281
|
+
self._validate(root, prior)
|
|
282
|
+
receipt_path = root / "platform.json"
|
|
283
|
+
receipt = dict(prior)
|
|
284
|
+
receipt.update(schema_version=SCHEMA_VERSION, owner=OWNER, root=str(root))
|
|
285
|
+
group = self._group(root)
|
|
286
|
+
reject_symlink_ancestors(group)
|
|
287
|
+
owned = dict(prior.get("shortcuts", {}))
|
|
288
|
+
pending = dict(prior.get("pending_shortcuts", {}))
|
|
289
|
+
if group.exists() and prior.get("shortcut_group") != str(group) and not owned:
|
|
290
|
+
raise DeploymentError(f"unowned Start menu directory: {group}")
|
|
291
|
+
for name in ("agent-bios.lnk", "Instructions Studio.lnk"):
|
|
292
|
+
destination = group / name
|
|
293
|
+
reject_symlink_ancestors(destination)
|
|
294
|
+
accepted = (owned.get(str(destination)), pending.get(str(destination)))
|
|
295
|
+
if destination.exists() and _hash(destination) not in accepted:
|
|
296
|
+
raise DeploymentError(f"modified or unowned Start menu shortcut: {destination}")
|
|
297
|
+
current, kind = self._path()
|
|
298
|
+
entry = str(root / "bin")
|
|
299
|
+
if not any(_same_path(part, entry) for part in current.split(";") if part):
|
|
300
|
+
receipt["path_entry"] = entry
|
|
301
|
+
_atomic(receipt_path, _canonical(receipt))
|
|
302
|
+
self._set_path(current + (";" if current and not current.endswith(";") else "") + entry, kind)
|
|
303
|
+
elif not prior.get("path_entry"):
|
|
304
|
+
receipt["path_entry"] = None # A preexisting user's PATH entry is not ours.
|
|
305
|
+
receipt["shortcut_group"] = str(group)
|
|
306
|
+
_atomic(receipt_path, _canonical(receipt))
|
|
307
|
+
group.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
for name, command in (("agent-bios.lnk", "install"), ("Instructions Studio.lnk", "instructions")):
|
|
309
|
+
destination = group / name
|
|
310
|
+
fd, temporary_name = tempfile.mkstemp(prefix=".agent-bios-", suffix=".lnk", dir=group)
|
|
311
|
+
os.close(fd)
|
|
312
|
+
temporary = Path(temporary_name)
|
|
313
|
+
temporary.unlink()
|
|
314
|
+
try:
|
|
315
|
+
self._shortcut(root / "bin/agent-bios.ps1", temporary, command)
|
|
316
|
+
pending[str(destination)] = _hash(temporary)
|
|
317
|
+
receipt.update(shortcuts=owned, pending_shortcuts=pending)
|
|
318
|
+
_atomic(receipt_path, _canonical(receipt))
|
|
319
|
+
os.replace(temporary, destination)
|
|
320
|
+
owned[str(destination)] = pending.pop(str(destination))
|
|
321
|
+
receipt.update(shortcuts=owned, pending_shortcuts=pending)
|
|
322
|
+
_atomic(receipt_path, _canonical(receipt))
|
|
323
|
+
finally:
|
|
324
|
+
temporary.unlink(missing_ok=True)
|
|
325
|
+
return receipt
|
|
326
|
+
|
|
327
|
+
def remove(self, root: Path, receipt: dict[str, Any]) -> list[str]:
|
|
328
|
+
self._validate(root, receipt)
|
|
329
|
+
retained: list[str] = []
|
|
330
|
+
entry = receipt.get("path_entry")
|
|
331
|
+
if entry is not None and entry != str(root / "bin"):
|
|
332
|
+
raise DeploymentError("invalid owned PATH entry")
|
|
333
|
+
if entry:
|
|
334
|
+
current, kind = self._path()
|
|
335
|
+
# Remove only the exact representation we wrote. A later user edit
|
|
336
|
+
# to an equivalent/short path is not permission to rewrite it.
|
|
337
|
+
self._set_path(";".join(part for part in current.split(";") if part != entry), kind)
|
|
338
|
+
group = self._group(root)
|
|
339
|
+
pending = receipt.get("pending_shortcuts", {})
|
|
340
|
+
shortcuts = receipt.get("shortcuts", {})
|
|
341
|
+
for name in set(shortcuts) | set(pending):
|
|
342
|
+
path = Path(name)
|
|
343
|
+
if path.parent != group or path.name not in ("agent-bios.lnk", "Instructions Studio.lnk"):
|
|
344
|
+
raise DeploymentError("invalid Start menu ownership claim")
|
|
345
|
+
reject_symlink_ancestors(path)
|
|
346
|
+
if path.is_file() and _hash(path) in (shortcuts.get(name), pending.get(name)):
|
|
347
|
+
path.unlink()
|
|
348
|
+
elif path.exists():
|
|
349
|
+
retained.append(str(path))
|
|
350
|
+
with contextlib.suppress(OSError):
|
|
351
|
+
group.rmdir()
|
|
352
|
+
return retained
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class Deployment:
|
|
356
|
+
def __init__(self, root: Path, *, environ: dict[str, str] | None = None,
|
|
357
|
+
integration=None, installer_factory=None, probe=None):
|
|
358
|
+
self.root = Path(root).expanduser().absolute()
|
|
359
|
+
reject_symlink_ancestors(self.root)
|
|
360
|
+
self.root = self.root.resolve()
|
|
361
|
+
self.env = dict(os.environ if environ is None else environ)
|
|
362
|
+
legacy = self.env.get("AGENT_BIOS_CORPUS_DIR")
|
|
363
|
+
canonical = self.env.get("AGENT_BIOS_INSTRUCTIONS_DIR")
|
|
364
|
+
if legacy is not None:
|
|
365
|
+
if canonical is not None and not _same_path(legacy, canonical):
|
|
366
|
+
raise DeploymentError("conflicting AGENT_BIOS_INSTRUCTIONS_DIR and AGENT_BIOS_CORPUS_DIR")
|
|
367
|
+
self.env.setdefault("AGENT_BIOS_INSTRUCTIONS_DIR", legacy)
|
|
368
|
+
if (self.root / "owner.json").exists() or (self.root / "deployment.json").exists():
|
|
369
|
+
owner = _json(self.root / "owner.json")
|
|
370
|
+
saved = _json(self.root / "deployment.json") if (self.root / "deployment.json").exists() else owner
|
|
371
|
+
for record in (saved, owner):
|
|
372
|
+
if record.get("owner") != OWNER or record.get("schema_version") != SCHEMA_VERSION or not _same_path(record.get("root", ""), str(self.root)):
|
|
373
|
+
raise DeploymentError("invalid deployment ownership")
|
|
374
|
+
values = saved.get("private_environment", owner.get("private_environment", {}))
|
|
375
|
+
if not isinstance(values, dict) or set(values) - set(PRIVATE_ENVIRONMENT):
|
|
376
|
+
raise DeploymentError("invalid saved private environment")
|
|
377
|
+
values = dict(values)
|
|
378
|
+
if "AGENT_BIOS_STATE_DIR" not in values and owner.get("state_root"):
|
|
379
|
+
values["AGENT_BIOS_STATE_DIR"] = owner["state_root"]
|
|
380
|
+
for name, value in values.items():
|
|
381
|
+
if not isinstance(value, str) or not Path(value).is_absolute():
|
|
382
|
+
raise DeploymentError("invalid saved private environment path")
|
|
383
|
+
if name in self.env and not _same_path(self.env[name], value):
|
|
384
|
+
raise DeploymentError(f"{name} differs from the saved deployment root; explicit root relocation is not supported")
|
|
385
|
+
self.env.setdefault(name, value)
|
|
386
|
+
home = Path(self.env.get("HOME", str(Path.home()))).expanduser()
|
|
387
|
+
self.state_root = Path(self.env.get("AGENT_BIOS_STATE_DIR", str(home / ".local/share/agent-bios"))).resolve()
|
|
388
|
+
if _within(self.state_root, self.root) or _within(self.root, self.state_root):
|
|
389
|
+
raise DeploymentError("application root and private instructions state must be separate")
|
|
390
|
+
self.integration = integration if integration is not None else WindowsIntegration()
|
|
391
|
+
if installer_factory is None:
|
|
392
|
+
try:
|
|
393
|
+
from .instructions_install import InstructionsInstaller
|
|
394
|
+
except ImportError:
|
|
395
|
+
from instructions_install import InstructionsInstaller
|
|
396
|
+
installer_factory = lambda package, env: InstructionsInstaller(package, environ=env)
|
|
397
|
+
self.installer_factory = installer_factory
|
|
398
|
+
self.probe = probe or self._probe
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def binding_path(self) -> Path:
|
|
402
|
+
return self.root / "deployment.json"
|
|
403
|
+
|
|
404
|
+
def _owned(self, *, allow_absent=False) -> dict[str, Any] | None:
|
|
405
|
+
reject_symlink_ancestors(self.root)
|
|
406
|
+
if self.root.exists() and not self.root.is_dir():
|
|
407
|
+
raise DeploymentError(f"deployment root is not a directory: {self.root}")
|
|
408
|
+
if any(self.root.glob("unins*.exe")) or (self.root / "agent-bios.exe").exists():
|
|
409
|
+
raise DeploymentError("Inno-managed installation migration is not supported; use a distinct script installation root")
|
|
410
|
+
marker = self.root / "owner.json"
|
|
411
|
+
if not marker.exists():
|
|
412
|
+
if self.root.exists() and any(self.root.iterdir()):
|
|
413
|
+
raise DeploymentError(f"unowned nonempty deployment root: {self.root}")
|
|
414
|
+
if allow_absent:
|
|
415
|
+
return None
|
|
416
|
+
raise DeploymentError("no owned Windows script installation exists")
|
|
417
|
+
owner = _json(marker)
|
|
418
|
+
if owner.get("owner") != OWNER or owner.get("schema_version") != SCHEMA_VERSION or not _same_path(owner.get("root", ""), str(self.root)):
|
|
419
|
+
raise DeploymentError("invalid deployment ownership marker")
|
|
420
|
+
if owner.get("state_root") != str(self.state_root):
|
|
421
|
+
raise DeploymentError("private instructions state differs from the saved deployment root")
|
|
422
|
+
if not isinstance(owner.get("trees"), dict):
|
|
423
|
+
raise DeploymentError("invalid owned tree inventory")
|
|
424
|
+
for name, files in owner["trees"].items():
|
|
425
|
+
path = Path(name)
|
|
426
|
+
if len(path.parts) != 2 or path.parts[0] not in ("releases", "runtimes") or not re.fullmatch(r"[a-f0-9]{64}", path.name):
|
|
427
|
+
raise DeploymentError("invalid owned tree claim")
|
|
428
|
+
if not isinstance(files, dict) or not files:
|
|
429
|
+
raise DeploymentError("invalid owned file inventory")
|
|
430
|
+
reject_symlink_ancestors(self.root / path)
|
|
431
|
+
return owner
|
|
432
|
+
|
|
433
|
+
def _binding(self) -> dict[str, Any]:
|
|
434
|
+
value = _json(self.binding_path)
|
|
435
|
+
if value.get("owner") != OWNER or value.get("schema_version") != SCHEMA_VERSION or not _same_path(value.get("root", ""), str(self.root)):
|
|
436
|
+
raise DeploymentError("invalid deployment binding ownership")
|
|
437
|
+
release = Path(value.get("release_root", ""))
|
|
438
|
+
if release.parent != self.root / "releases" or not re.fullmatch(r"[a-f0-9]{64}", release.name):
|
|
439
|
+
raise DeploymentError("invalid deployment release root")
|
|
440
|
+
for field, expected in (("application_root", release / "package"), ("dependencies_root", release / "dependencies"), ("commands_root", self.root / "bin")):
|
|
441
|
+
if value.get(field) != str(expected):
|
|
442
|
+
raise DeploymentError(f"invalid deployment {field}")
|
|
443
|
+
if value.get("state_root") != str(self.state_root):
|
|
444
|
+
raise DeploymentError("private instructions state differs from the saved deployment root")
|
|
445
|
+
python = value.get("python")
|
|
446
|
+
if not isinstance(python, dict) or not Path(python.get("path", "")).is_absolute():
|
|
447
|
+
raise DeploymentError("invalid Python binding")
|
|
448
|
+
managed = python.get("managed_root")
|
|
449
|
+
if managed and (Path(managed).parent != self.root / "runtimes" or not _within(Path(python["path"]), Path(managed))):
|
|
450
|
+
raise DeploymentError("invalid managed Python ownership")
|
|
451
|
+
if not isinstance(value.get("command_inventory"), dict) or set(value["command_inventory"]) != set(COMMANDS) | set(SHIMS):
|
|
452
|
+
raise DeploymentError("invalid command ownership inventory")
|
|
453
|
+
if {name: value["command_inventory"][name] for name in SHIMS} != _shim_inventory():
|
|
454
|
+
raise DeploymentError("invalid command shim ownership inventory")
|
|
455
|
+
return value
|
|
456
|
+
|
|
457
|
+
def _probe(self, binding: dict[str, Any]) -> None:
|
|
458
|
+
python = Path(binding["python"]["path"])
|
|
459
|
+
if not python.is_file() or _hash(python) != binding["python"]["sha256"]:
|
|
460
|
+
raise DeploymentError(f"Python binding changed or is missing: {python}")
|
|
461
|
+
env = dict(self.env, PYTHONDONTWRITEBYTECODE="1", AGENT_BIOS_WINDOWS_BINDING=str(self.binding_path))
|
|
462
|
+
# The entry handles embeddable _pth isolation before native imports.
|
|
463
|
+
result = subprocess.run([str(python), "-I", "-B", str(Path(binding["application_root"]) / "compose" / ENTRY),
|
|
464
|
+
"--dependencies", binding["dependencies_root"], "--script",
|
|
465
|
+
str(Path(binding["application_root"]) / "compose/native_cli.py"), "--version"],
|
|
466
|
+
env=env, capture_output=True, text=True, timeout=60)
|
|
467
|
+
if result.returncode or result.stdout.strip() != binding["version"]:
|
|
468
|
+
raise DeploymentError(f"staged application probe failed: {(result.stderr or result.stdout).strip()}")
|
|
469
|
+
|
|
470
|
+
def _stage(self, source: Path, parent: str, owner: dict[str, Any]) -> tuple[Path, dict[str, str]]:
|
|
471
|
+
inventory = _inventory(source)
|
|
472
|
+
if not inventory:
|
|
473
|
+
raise DeploymentError("cannot stage an empty bundle")
|
|
474
|
+
digest = hashlib.sha256(_canonical(inventory)).hexdigest()
|
|
475
|
+
target = self.root / parent / digest
|
|
476
|
+
reject_symlink_ancestors(target)
|
|
477
|
+
claims = owner.setdefault("trees", {})
|
|
478
|
+
relative = target.relative_to(self.root).as_posix()
|
|
479
|
+
if target.exists():
|
|
480
|
+
if claims.get(relative) != inventory:
|
|
481
|
+
raise DeploymentError(f"unowned or changed immutable destination: {target}")
|
|
482
|
+
observed = _inventory(target)
|
|
483
|
+
if set(observed) - set(inventory):
|
|
484
|
+
raise DeploymentError(f"unexpected files in owned release: {target}")
|
|
485
|
+
# Repair only previously claimed bytes. Do not delete unknown files
|
|
486
|
+
# or replace a version directory that running workers may still use.
|
|
487
|
+
for name, digest in inventory.items():
|
|
488
|
+
if observed.get(name) != digest:
|
|
489
|
+
_atomic(target / name, (source / name).read_bytes())
|
|
490
|
+
_check_inventory(target, inventory)
|
|
491
|
+
return target, inventory
|
|
492
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
493
|
+
temporary = Path(tempfile.mkdtemp(prefix=".staging-", dir=target.parent))
|
|
494
|
+
try:
|
|
495
|
+
shutil.copytree(source, temporary, dirs_exist_ok=True)
|
|
496
|
+
_check_inventory(temporary, inventory)
|
|
497
|
+
# Claim exactly these files before publication for resumable cleanup.
|
|
498
|
+
claims[relative] = inventory
|
|
499
|
+
_atomic(self.root / "owner.json", _canonical(owner))
|
|
500
|
+
os.replace(temporary, target)
|
|
501
|
+
finally:
|
|
502
|
+
if temporary.exists():
|
|
503
|
+
shutil.rmtree(temporary)
|
|
504
|
+
return target, inventory
|
|
505
|
+
|
|
506
|
+
@contextlib.contextmanager
|
|
507
|
+
def _private_installer(self, binding: dict[str, Any]):
|
|
508
|
+
"""Private projections and all children retain the selected entry/deps."""
|
|
509
|
+
runtime_env = {"AGENT_BIOS_PYTHON_ENTRY": str(Path(binding["application_root"]) / "compose" / ENTRY),
|
|
510
|
+
"AGENT_BIOS_PYTHON_DEPS": binding["dependencies_root"],
|
|
511
|
+
"AGENT_BIOS_PYTHON_EXECUTABLE": binding["python"]["path"]}
|
|
512
|
+
home = Path(self.env.get("HOME", str(Path.home()))).expanduser().resolve()
|
|
513
|
+
defaults = {"HOME": str(home), "AGENT_BIOS_STATE_DIR": str(self.state_root),
|
|
514
|
+
"AGENT_BIOS_INSTRUCTIONS_DIR": str(home / ".config/agent-bios/corpus"),
|
|
515
|
+
"CLAUDE_CONFIG_DIR": str(home / ".claude"), "CODEX_HOME": str(home / ".codex")}
|
|
516
|
+
private_env = {name: str(Path(self.env.get(name, default)).expanduser().resolve()) for name, default in defaults.items()}
|
|
517
|
+
runtime_env.update(private_env)
|
|
518
|
+
binding["private_environment"] = private_env
|
|
519
|
+
previous = {name: os.environ.get(name) for name in runtime_env}
|
|
520
|
+
try:
|
|
521
|
+
os.environ.update(runtime_env)
|
|
522
|
+
yield self.installer_factory(Path(binding["application_root"]), dict(self.env, **runtime_env))
|
|
523
|
+
finally:
|
|
524
|
+
for name, value in previous.items():
|
|
525
|
+
if value is None:
|
|
526
|
+
os.environ.pop(name, None)
|
|
527
|
+
else:
|
|
528
|
+
os.environ[name] = value
|
|
529
|
+
|
|
530
|
+
def _install_preflight(self, source: Path, version: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
531
|
+
owner = self._owned(allow_absent=True)
|
|
532
|
+
prior = self._binding() if self.binding_path.exists() else None
|
|
533
|
+
record = self.installer_factory(source / "package", self.env).record_path
|
|
534
|
+
reject_symlink_ancestors(record)
|
|
535
|
+
if record.exists() and (owner is None or prior is None):
|
|
536
|
+
raise DeploymentError("existing private installation requires an explicit deployment handoff; "
|
|
537
|
+
"a new or detached script root cannot adopt state owned by another installation")
|
|
538
|
+
floors = [value for value in ((owner or {}).get("last_version"), (prior or {}).get("version")) if value is not None]
|
|
539
|
+
for floor in floors:
|
|
540
|
+
if _version(version) < _version(floor):
|
|
541
|
+
raise DeploymentError(f"version downgrade refused: {floor} -> {version}")
|
|
542
|
+
return owner, prior
|
|
543
|
+
|
|
544
|
+
def install(self, source: Path, python: Path, managed_runtime: Path | None = None) -> dict[str, Any]:
|
|
545
|
+
source = Path(source).resolve()
|
|
546
|
+
python = Path(python).resolve()
|
|
547
|
+
if _within(source, self.root) or _within(self.root, source):
|
|
548
|
+
raise DeploymentError("source bundle and deployment root must be separate")
|
|
549
|
+
if managed_runtime is None and _within(python, source):
|
|
550
|
+
raise DeploymentError("a Python inside the temporary source bundle must be supplied as managed runtime")
|
|
551
|
+
manifest = _json(source / "package/package.json")
|
|
552
|
+
version = manifest.get("version")
|
|
553
|
+
_version(version)
|
|
554
|
+
if manifest.get("name") != "agent-bios":
|
|
555
|
+
raise DeploymentError("application bundle is not agent-bios")
|
|
556
|
+
for relative in ("package/compose/" + ENTRY, *("commands/" + name for name in COMMANDS)):
|
|
557
|
+
if not (source / relative).is_file():
|
|
558
|
+
raise DeploymentError(f"incomplete application bundle: {relative}")
|
|
559
|
+
if not (source / "dependencies").is_dir():
|
|
560
|
+
raise DeploymentError("incomplete application bundle: dependencies")
|
|
561
|
+
if not python.is_file():
|
|
562
|
+
raise DeploymentError(f"Python is absent: {python}")
|
|
563
|
+
_inventory(source) # Reject redirects before claiming or writing a root.
|
|
564
|
+
self._install_preflight(source, version) # Refuse foreign ownership before even creating a state lock.
|
|
565
|
+
with transaction_lock(self.state_root):
|
|
566
|
+
owner, prior = self._install_preflight(source, version)
|
|
567
|
+
owner = owner or {"owner": OWNER, "schema_version": SCHEMA_VERSION, "root": str(self.root),
|
|
568
|
+
"state_root": str(self.state_root), "trees": {}}
|
|
569
|
+
_atomic(self.root / "owner.json", _canonical(owner))
|
|
570
|
+
release, inventory = self._stage(source, "releases", owner)
|
|
571
|
+
managed_root = None
|
|
572
|
+
if managed_runtime is not None:
|
|
573
|
+
managed_runtime = Path(managed_runtime).resolve()
|
|
574
|
+
if not _within(python, managed_runtime):
|
|
575
|
+
raise DeploymentError("selected Python is outside the supplied managed runtime")
|
|
576
|
+
relative_python = python.relative_to(managed_runtime)
|
|
577
|
+
managed_root, _ = self._stage(managed_runtime, "runtimes", owner)
|
|
578
|
+
python = managed_root / relative_python
|
|
579
|
+
elif _within(python, self.root):
|
|
580
|
+
if not prior or prior["python"]["path"] != str(python) or not prior["python"].get("managed_root"):
|
|
581
|
+
raise DeploymentError("Python inside the deployment root must have recorded runtime ownership")
|
|
582
|
+
managed_root = Path(prior["python"]["managed_root"])
|
|
583
|
+
binding = {"owner": OWNER, "schema_version": SCHEMA_VERSION, "root": str(self.root),
|
|
584
|
+
"version": version, "content_id": release.name, "release_root": str(release),
|
|
585
|
+
"application_root": str(release / "package"), "dependencies_root": str(release / "dependencies"),
|
|
586
|
+
"commands_root": str(self.root / "bin"), "state_root": str(self.state_root),
|
|
587
|
+
"python": {"path": str(python), "sha256": _hash(python), "managed_root": str(managed_root) if managed_root else None},
|
|
588
|
+
"inventory": inventory,
|
|
589
|
+
"command_inventory": {**{name: _hash(release / "commands" / name) for name in COMMANDS},
|
|
590
|
+
**_shim_inventory()}}
|
|
591
|
+
self.probe(binding)
|
|
592
|
+
old_commands = (prior or {}).get("command_inventory", {})
|
|
593
|
+
for name in (*COMMANDS, *SHIMS):
|
|
594
|
+
destination = self.root / "bin" / name
|
|
595
|
+
reject_symlink_ancestors(destination)
|
|
596
|
+
if destination.exists() and _hash(destination) not in (old_commands.get(name), binding["command_inventory"][name]):
|
|
597
|
+
raise DeploymentError(f"modified or unowned command: {destination}")
|
|
598
|
+
operation = {"owner": OWNER, "schema_version": SCHEMA_VERSION, "version": version,
|
|
599
|
+
"phase": "prepared", "application_root": str(release / "package")}
|
|
600
|
+
_atomic(self.root / "operation.json", _canonical(operation))
|
|
601
|
+
with self._private_installer(binding) as installer:
|
|
602
|
+
configured = installer.record_path.exists()
|
|
603
|
+
if configured:
|
|
604
|
+
operation["phase"] = "private_update_pending"
|
|
605
|
+
_atomic(self.root / "operation.json", _canonical(operation))
|
|
606
|
+
try:
|
|
607
|
+
installer.install()
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
operation.update(phase="private_update_needs_recovery", error=type(exc).__name__)
|
|
610
|
+
_atomic(self.root / "operation.json", _canonical(operation))
|
|
611
|
+
raise DeploymentError("private environment update failed; native recovery is required; active binding was not switched") from exc
|
|
612
|
+
operation["phase"] = "private_environment_updated"
|
|
613
|
+
_atomic(self.root / "operation.json", _canonical(operation))
|
|
614
|
+
for name in COMMANDS:
|
|
615
|
+
_atomic(self.root / "bin" / name, (release / "commands" / name).read_bytes())
|
|
616
|
+
for name, target in SHIMS.items():
|
|
617
|
+
_atomic(self.root / "bin" / name, _shim(target))
|
|
618
|
+
platform_path = self.root / "platform.json"
|
|
619
|
+
prior_platform = _json(platform_path) if platform_path.exists() else {}
|
|
620
|
+
if prior_platform and (prior_platform.get("owner") != OWNER or prior_platform.get("root") != str(self.root)):
|
|
621
|
+
raise DeploymentError("invalid platform ownership receipt")
|
|
622
|
+
receipt = self.integration.apply(self.root, prior_platform)
|
|
623
|
+
_atomic(platform_path, _canonical(receipt))
|
|
624
|
+
_atomic(self.binding_path, _canonical(binding))
|
|
625
|
+
owner.update(last_version=version, private_environment=binding["private_environment"])
|
|
626
|
+
_atomic(self.root / "owner.json", _canonical(owner))
|
|
627
|
+
operation["phase"] = "complete"
|
|
628
|
+
_atomic(self.root / "operation.json", _canonical(operation))
|
|
629
|
+
return {"application_deployed": True, "version": version, "root": str(self.root),
|
|
630
|
+
"commands_root": binding["commands_root"], "binding_path": str(self.binding_path),
|
|
631
|
+
"private_environment_updated": configured, "configuration_pending": not configured,
|
|
632
|
+
"activation": "unverified"}
|
|
633
|
+
|
|
634
|
+
def verify(self) -> dict[str, Any]:
|
|
635
|
+
self._owned()
|
|
636
|
+
binding = self._binding()
|
|
637
|
+
_check_inventory(Path(binding["release_root"]), binding["inventory"])
|
|
638
|
+
_check_inventory(self.root / "bin", binding["command_inventory"], exact=False)
|
|
639
|
+
self.probe(binding)
|
|
640
|
+
if (self.root / "operation.json").exists() and _json(self.root / "operation.json").get("phase") != "complete":
|
|
641
|
+
raise DeploymentError("Windows deployment operation is incomplete; rerun the same verified installation to recover")
|
|
642
|
+
with self._private_installer(binding) as installer:
|
|
643
|
+
configured = installer.record_path.exists()
|
|
644
|
+
if configured:
|
|
645
|
+
installer.verify()
|
|
646
|
+
return {"application_deployed": True, "version": binding["version"], "root": str(self.root),
|
|
647
|
+
"configuration_pending": not configured, "activation": "unverified"}
|
|
648
|
+
|
|
649
|
+
def uninstall(self) -> dict[str, Any]:
|
|
650
|
+
self._owned()
|
|
651
|
+
with transaction_lock(self.state_root):
|
|
652
|
+
owner = self._owned()
|
|
653
|
+
binding = self._binding()
|
|
654
|
+
platform = _json(self.root / "platform.json")
|
|
655
|
+
if platform.get("owner") != OWNER or platform.get("root") != str(self.root) or platform.get("schema_version") != SCHEMA_VERSION:
|
|
656
|
+
raise DeploymentError("invalid platform ownership receipt")
|
|
657
|
+
if hasattr(self.integration, "_validate"):
|
|
658
|
+
self.integration._validate(self.root, platform)
|
|
659
|
+
for name in (*COMMANDS, *SHIMS):
|
|
660
|
+
reject_symlink_ancestors(self.root / "bin" / name)
|
|
661
|
+
with self._private_installer(binding) as installer:
|
|
662
|
+
if hasattr(installer, "_active_intents") and installer._active_intents():
|
|
663
|
+
raise DeploymentError("active instructions activation requires completion before application removal")
|
|
664
|
+
installer.uninstall()
|
|
665
|
+
retained = self.integration.remove(self.root, platform)
|
|
666
|
+
for name, digest in binding["command_inventory"].items():
|
|
667
|
+
path = self.root / "bin" / name
|
|
668
|
+
reject_symlink_ancestors(path)
|
|
669
|
+
if path.is_file() and _hash(path) == digest:
|
|
670
|
+
path.unlink()
|
|
671
|
+
elif path.exists():
|
|
672
|
+
retained.append(str(path))
|
|
673
|
+
releases, runtimes = [], []
|
|
674
|
+
for relative, inventory in owner.get("trees", {}).items():
|
|
675
|
+
target = self.root / relative
|
|
676
|
+
if Path(relative).is_absolute() or ".." in Path(relative).parts or target.parent not in (self.root / "releases", self.root / "runtimes"):
|
|
677
|
+
raise DeploymentError("invalid owned tree claim")
|
|
678
|
+
reject_symlink_ancestors(target)
|
|
679
|
+
if not target.exists():
|
|
680
|
+
continue
|
|
681
|
+
# Historical app helpers/session resources retain absolute entry,
|
|
682
|
+
# dependency and Python paths beyond the active binding. Garbage
|
|
683
|
+
# collection is deferred until all those references are inventoried.
|
|
684
|
+
(runtimes if target.parent == self.root / "runtimes" else releases).append(str(target))
|
|
685
|
+
owner.update(last_version=binding["version"], private_environment=binding["private_environment"])
|
|
686
|
+
_atomic(self.root / "owner.json", _canonical(owner))
|
|
687
|
+
for name in ("deployment.json", "platform.json", "operation.json"):
|
|
688
|
+
(self.root / name).unlink(missing_ok=True)
|
|
689
|
+
for directory in (self.root / "bin", self.root / "releases", self.root / "runtimes", self.root):
|
|
690
|
+
with contextlib.suppress(OSError):
|
|
691
|
+
directory.rmdir()
|
|
692
|
+
return {"application_removed": True, "retained_paths": retained,
|
|
693
|
+
"retained_release_paths": releases, "retained_runtime_paths": runtimes,
|
|
694
|
+
"external_python_preserved": binding["python"]["managed_root"] is None,
|
|
695
|
+
"private_instructions_preserved": True, "sessions_preserved": True}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def main(argv=None) -> int:
|
|
699
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
700
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
701
|
+
install = subcommands.add_parser("install")
|
|
702
|
+
install.add_argument("--source", type=Path, required=True)
|
|
703
|
+
install.add_argument("--python", type=Path, required=True)
|
|
704
|
+
install.add_argument("--managed-runtime", type=Path)
|
|
705
|
+
for command in (install, subcommands.add_parser("verify"), subcommands.add_parser("uninstall")):
|
|
706
|
+
command.add_argument("--root", type=Path, required=True)
|
|
707
|
+
args = parser.parse_args(argv)
|
|
708
|
+
try:
|
|
709
|
+
deployment = Deployment(args.root)
|
|
710
|
+
result = deployment.install(args.source, args.python, args.managed_runtime) if args.command == "install" else getattr(deployment, args.command)()
|
|
711
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
712
|
+
return 0
|
|
713
|
+
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
|
714
|
+
print(f"windows-deploy: {exc}", file=sys.stderr)
|
|
715
|
+
return 1
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
if __name__ == "__main__":
|
|
719
|
+
raise SystemExit(main())
|