albert-memory 0.4.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.
- albert/__init__.py +3 -0
- albert/admin.py +532 -0
- albert/api.py +698 -0
- albert/automatic_memory_release.json +14 -0
- albert/benchmark.py +272 -0
- albert/benchmark_cli.py +37 -0
- albert/capture.py +1051 -0
- albert/capture_installer.py +1000 -0
- albert/capture_promotion.py +298 -0
- albert/capture_retention.py +72 -0
- albert/capture_sdk.py +558 -0
- albert/capture_security.py +79 -0
- albert/chunking.py +79 -0
- albert/classifier.py +263 -0
- albert/claude_adapter.py +357 -0
- albert/claude_lifecycle.py +418 -0
- albert/claude_package.json +19 -0
- albert/claude_plugin/.claude-plugin/plugin.json +11 -0
- albert/claude_plugin/.mcp.json +13 -0
- albert/claude_plugin/hooks/hooks.json +70 -0
- albert/clusters.py +140 -0
- albert/codex_adapter.py +308 -0
- albert/codex_hooks/hooks.json +49 -0
- albert/codex_lifecycle.py +485 -0
- albert/codex_package.json +19 -0
- albert/compatibility.py +121 -0
- albert/config.py +144 -0
- albert/console.py +508 -0
- albert/console_backends.py +236 -0
- albert/console_map.py +518 -0
- albert/console_ops.py +919 -0
- albert/credentials.py +616 -0
- albert/cursor_adapter.py +320 -0
- albert/cursor_hooks/hooks.json +11 -0
- albert/cursor_rules/albert-memory.mdc +11 -0
- albert/db.py +68 -0
- albert/embeddings.py +91 -0
- albert/graph.py +623 -0
- albert/graph_builders.py +879 -0
- albert/graph_cypher.py +669 -0
- albert/graph_store.py +376 -0
- albert/host_targets/claude-code.json +45 -0
- albert/host_targets/codex.json +45 -0
- albert/host_targets/openclaw.json +45 -0
- albert/host_targets.py +271 -0
- albert/links.py +428 -0
- albert/mcp_server.py +457 -0
- albert/memory_safety.py +111 -0
- albert/models.py +611 -0
- albert/openclaw_adapter.py +326 -0
- albert/openclaw_adapter_trust.json +5 -0
- albert/openclaw_package.json +22 -0
- albert/openclaw_plugin/index.js +135 -0
- albert/openclaw_plugin/openclaw.plugin.json +25 -0
- albert/openclaw_plugin/package.json +12 -0
- albert/portability.py +291 -0
- albert/provenance.py +105 -0
- albert/recorder.py +166 -0
- albert/release_gate.py +83 -0
- albert/schemas.py +617 -0
- albert/scope.py +26 -0
- albert/search.py +447 -0
- albert/security.py +150 -0
- albert/services.py +1316 -0
- albert/session_context.py +162 -0
- albert/trace_feed.py +165 -0
- albert/trace_writer.py +131 -0
- albert/tracing.py +75 -0
- albert/worker.py +202 -0
- albert/workspaces.py +256 -0
- albert_memory-0.4.0.dist-info/METADATA +554 -0
- albert_memory-0.4.0.dist-info/RECORD +74 -0
- albert_memory-0.4.0.dist-info/WHEEL +4 -0
- albert_memory-0.4.0.dist-info/entry_points.txt +14 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
"""Safe installer, doctor, and uninstaller for Albert capture adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
import typer
|
|
23
|
+
from cryptography.exceptions import InvalidSignature
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
25
|
+
|
|
26
|
+
from albert.capture_sdk import CaptureClient, EncryptedQueue, generate_queue_key
|
|
27
|
+
from albert.host_targets import (
|
|
28
|
+
HostTargetError,
|
|
29
|
+
assert_owned_install_root,
|
|
30
|
+
negotiate_host_target,
|
|
31
|
+
)
|
|
32
|
+
from albert.openclaw_adapter import OPENCLAW_EVENTS
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(help="Connect supported agent hosts directly to an Albert Brain.")
|
|
35
|
+
PLUGIN_ID = "albert-memory"
|
|
36
|
+
MIN_OPENCLAW = (2026, 9, 6)
|
|
37
|
+
MAX_OPENCLAW = (2027, 0, 0)
|
|
38
|
+
ID_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,200}$")
|
|
39
|
+
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
40
|
+
CAPABILITY_PATTERN = re.compile(r"^[a-z][a-z0-9._-]{0,99}$")
|
|
41
|
+
TRUST_PATH = Path(__file__).with_name("openclaw_adapter_trust.json")
|
|
42
|
+
VERSION_PATTERN = re.compile(r"OpenClaw\s+(\d+)\.(\d+)\.(\d+)")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InstallError(RuntimeError):
|
|
46
|
+
"""An expected, safely reportable installer failure."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class InstallPaths:
|
|
51
|
+
root: Path
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def credentials(self) -> Path:
|
|
55
|
+
return self.root / "credentials.env"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def wrapper(self) -> Path:
|
|
59
|
+
return self.root / "bin" / "albert-openclaw-adapter"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def marker(self) -> Path:
|
|
63
|
+
return self.root / "install.json"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def queue(self) -> Path:
|
|
67
|
+
return self.root / "capture.db"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class CloudManifest:
|
|
75
|
+
capabilities: list[str]
|
|
76
|
+
compensation_url: str
|
|
77
|
+
identity: dict[str, str]
|
|
78
|
+
manifest_id: str
|
|
79
|
+
manifest_version: str
|
|
80
|
+
redemption_url: str
|
|
81
|
+
rest_url: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _canonical_json(value: object) -> str:
|
|
85
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _urlsafe_decode(value: str) -> bytes:
|
|
89
|
+
import base64
|
|
90
|
+
import binascii
|
|
91
|
+
|
|
92
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", value) or len(value) > 8192:
|
|
93
|
+
raise InstallError("signed adapter metadata is malformed")
|
|
94
|
+
try:
|
|
95
|
+
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
|
96
|
+
except (binascii.Error, ValueError):
|
|
97
|
+
raise InstallError("signed adapter metadata is malformed") from None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _https_url(value: object, label: str) -> str:
|
|
101
|
+
from ipaddress import ip_address
|
|
102
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
103
|
+
|
|
104
|
+
if not isinstance(value, str) or len(value) > 4096:
|
|
105
|
+
raise InstallError(f"{label} must be a canonical public HTTPS URL")
|
|
106
|
+
try:
|
|
107
|
+
parsed = urlsplit(value)
|
|
108
|
+
port = parsed.port
|
|
109
|
+
except ValueError:
|
|
110
|
+
raise InstallError(f"{label} must be a canonical public HTTPS URL") from None
|
|
111
|
+
hostname = (parsed.hostname or "").rstrip(".").lower()
|
|
112
|
+
try:
|
|
113
|
+
address = ip_address(hostname)
|
|
114
|
+
except ValueError:
|
|
115
|
+
address = None
|
|
116
|
+
private_name = address is None and (
|
|
117
|
+
"." not in hostname or hostname.endswith((".local", ".localhost", ".internal", ".lan"))
|
|
118
|
+
)
|
|
119
|
+
if (
|
|
120
|
+
parsed.scheme != "https"
|
|
121
|
+
or not hostname
|
|
122
|
+
or parsed.username is not None
|
|
123
|
+
or parsed.password is not None
|
|
124
|
+
or port is not None
|
|
125
|
+
or parsed.fragment
|
|
126
|
+
or (address is not None and not address.is_global)
|
|
127
|
+
or private_name
|
|
128
|
+
):
|
|
129
|
+
raise InstallError(f"{label} must be a canonical public HTTPS URL")
|
|
130
|
+
canonical_host = f"[{hostname}]" if address is not None and address.version == 6 else hostname
|
|
131
|
+
canonical = urlunsplit(
|
|
132
|
+
("https", canonical_host, parsed.path.rstrip("/") or "/", parsed.query, "")
|
|
133
|
+
)
|
|
134
|
+
if canonical.rstrip("/") != value.rstrip("/"):
|
|
135
|
+
raise InstallError(f"{label} must be a canonical public HTTPS URL")
|
|
136
|
+
return value.rstrip("/")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_trust() -> dict[str, object]:
|
|
140
|
+
try:
|
|
141
|
+
trust = json.loads(TRUST_PATH.read_text(encoding="utf-8"))
|
|
142
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
143
|
+
raise InstallError("OpenClaw adapter trust metadata is unavailable") from exc
|
|
144
|
+
if not isinstance(trust, dict):
|
|
145
|
+
raise InstallError("OpenClaw adapter trust metadata is invalid")
|
|
146
|
+
return trust
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _package_version() -> str:
|
|
150
|
+
try:
|
|
151
|
+
return importlib.metadata.version("albert-memory")
|
|
152
|
+
except importlib.metadata.PackageNotFoundError:
|
|
153
|
+
return "0.4.0"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _version_in_range(version: tuple[int, int, int], declared: str) -> bool:
|
|
157
|
+
clauses = [part.strip() for part in declared.split(",") if part.strip()]
|
|
158
|
+
if not clauses:
|
|
159
|
+
return False
|
|
160
|
+
for clause in clauses:
|
|
161
|
+
match = re.fullmatch(r"(>=|>|<=|<|==)(\d+)\.(\d+)\.(\d+)", clause)
|
|
162
|
+
if not match:
|
|
163
|
+
return False
|
|
164
|
+
target = tuple(int(value) for value in match.groups()[1:])
|
|
165
|
+
operator = match.group(1)
|
|
166
|
+
if not {
|
|
167
|
+
">=": version >= target,
|
|
168
|
+
">": version > target,
|
|
169
|
+
"<=": version <= target,
|
|
170
|
+
"<": version < target,
|
|
171
|
+
"==": version == target,
|
|
172
|
+
}[operator]:
|
|
173
|
+
return False
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _verify_manifest(
|
|
178
|
+
payload: object,
|
|
179
|
+
*,
|
|
180
|
+
link_url: str,
|
|
181
|
+
openclaw_version: tuple[int, int, int],
|
|
182
|
+
trust: dict[str, object] | None = None,
|
|
183
|
+
package_version: str | None = None,
|
|
184
|
+
) -> CloudManifest:
|
|
185
|
+
from datetime import UTC, datetime, timedelta
|
|
186
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
187
|
+
|
|
188
|
+
if not isinstance(payload, dict):
|
|
189
|
+
raise InstallError("Cloud adapter manifest is malformed")
|
|
190
|
+
signature = payload.get("signature")
|
|
191
|
+
unsigned = {key: value for key, value in payload.items() if key != "signature"}
|
|
192
|
+
if not isinstance(signature, dict) or set(signature) != {"algorithm", "key_id", "value"}:
|
|
193
|
+
raise InstallError("Cloud adapter manifest signature is malformed")
|
|
194
|
+
key_id = signature.get("key_id")
|
|
195
|
+
loaded_trust = trust if trust is not None else _load_trust()
|
|
196
|
+
keys = loaded_trust.get("manifest_keys")
|
|
197
|
+
if (
|
|
198
|
+
signature.get("algorithm") != "Ed25519"
|
|
199
|
+
or not isinstance(key_id, str)
|
|
200
|
+
or not isinstance(keys, dict)
|
|
201
|
+
or not isinstance(keys.get(key_id), str)
|
|
202
|
+
or not isinstance(signature.get("value"), str)
|
|
203
|
+
):
|
|
204
|
+
raise InstallError("Cloud adapter manifest is not signed by a trusted key")
|
|
205
|
+
try:
|
|
206
|
+
public_key = Ed25519PublicKey.from_public_bytes(_urlsafe_decode(keys[key_id]))
|
|
207
|
+
public_key.verify(
|
|
208
|
+
_urlsafe_decode(signature["value"]), _canonical_json(unsigned).encode("utf-8")
|
|
209
|
+
)
|
|
210
|
+
except (ValueError, InvalidSignature):
|
|
211
|
+
raise InstallError("Cloud adapter manifest signature is invalid") from None
|
|
212
|
+
|
|
213
|
+
if set(unsigned) != {
|
|
214
|
+
"adapter",
|
|
215
|
+
"capabilities",
|
|
216
|
+
"compensation",
|
|
217
|
+
"connection",
|
|
218
|
+
"expires_at",
|
|
219
|
+
"identity",
|
|
220
|
+
"issued_at",
|
|
221
|
+
"manifest_id",
|
|
222
|
+
"manifest_version",
|
|
223
|
+
"redemption",
|
|
224
|
+
"reload",
|
|
225
|
+
"revocation",
|
|
226
|
+
"rollback",
|
|
227
|
+
"schema",
|
|
228
|
+
"verification",
|
|
229
|
+
}:
|
|
230
|
+
raise InstallError("Cloud adapter manifest contains unknown or missing fields")
|
|
231
|
+
version = unsigned.get("manifest_version")
|
|
232
|
+
if not isinstance(version, str) or version.split(".")[0] != "2":
|
|
233
|
+
raise InstallError("unsupported Cloud adapter manifest major version")
|
|
234
|
+
if unsigned.get("schema") != "com.jiggai.albert.adapter-manifest":
|
|
235
|
+
raise InstallError("Cloud adapter manifest schema is unsupported")
|
|
236
|
+
issued_value = unsigned.get("issued_at")
|
|
237
|
+
expires_value = unsigned.get("expires_at")
|
|
238
|
+
if not isinstance(issued_value, str) or not isinstance(expires_value, str):
|
|
239
|
+
raise InstallError("Cloud adapter manifest lifetime is malformed")
|
|
240
|
+
try:
|
|
241
|
+
issued = datetime.fromisoformat(issued_value.replace("Z", "+00:00"))
|
|
242
|
+
expires = datetime.fromisoformat(expires_value.replace("Z", "+00:00"))
|
|
243
|
+
except ValueError:
|
|
244
|
+
raise InstallError("Cloud adapter manifest lifetime is malformed") from None
|
|
245
|
+
now = datetime.now(UTC)
|
|
246
|
+
if (
|
|
247
|
+
issued.utcoffset() != timedelta(0)
|
|
248
|
+
or expires.utcoffset() != timedelta(0)
|
|
249
|
+
or issued > now
|
|
250
|
+
or expires <= now
|
|
251
|
+
or expires - issued > timedelta(hours=1)
|
|
252
|
+
):
|
|
253
|
+
raise InstallError("Cloud adapter manifest is expired or not yet valid")
|
|
254
|
+
|
|
255
|
+
identity = unsigned.get("identity")
|
|
256
|
+
capabilities = unsigned.get("capabilities")
|
|
257
|
+
manifest_id = unsigned.get("manifest_id")
|
|
258
|
+
identity_keys = ("brain_id", "request_id", "workspace_id")
|
|
259
|
+
if (
|
|
260
|
+
not isinstance(identity, dict)
|
|
261
|
+
or set(identity) != {"brain_id", "cloud", "request_id", "workspace_id"}
|
|
262
|
+
or identity.get("cloud") != "albert-cloud"
|
|
263
|
+
or not all(
|
|
264
|
+
isinstance(identity.get(key), str) and ID_PATTERN.fullmatch(identity[key])
|
|
265
|
+
for key in identity_keys
|
|
266
|
+
)
|
|
267
|
+
or identity.get("request_id") != manifest_id
|
|
268
|
+
or not isinstance(capabilities, list)
|
|
269
|
+
or not capabilities
|
|
270
|
+
or capabilities != sorted(set(capabilities))
|
|
271
|
+
or not all(
|
|
272
|
+
isinstance(item, str) and CAPABILITY_PATTERN.fullmatch(item) for item in capabilities
|
|
273
|
+
)
|
|
274
|
+
):
|
|
275
|
+
raise InstallError("Cloud adapter manifest identity or capabilities are invalid")
|
|
276
|
+
|
|
277
|
+
adapter = unsigned.get("adapter")
|
|
278
|
+
if (
|
|
279
|
+
not isinstance(adapter, dict)
|
|
280
|
+
or set(adapter)
|
|
281
|
+
!= {
|
|
282
|
+
"artifact_sha256",
|
|
283
|
+
"artifact_signature",
|
|
284
|
+
"artifact_url",
|
|
285
|
+
"family",
|
|
286
|
+
"package",
|
|
287
|
+
"package_version",
|
|
288
|
+
"supported_openclaw",
|
|
289
|
+
}
|
|
290
|
+
):
|
|
291
|
+
raise InstallError("Cloud adapter package provenance is malformed")
|
|
292
|
+
local_version = package_version or _package_version()
|
|
293
|
+
artifact_sha256 = adapter.get("artifact_sha256")
|
|
294
|
+
if (
|
|
295
|
+
adapter.get("family") != "openclaw"
|
|
296
|
+
or adapter.get("package") != "albert-memory"
|
|
297
|
+
or adapter.get("package_version") != local_version
|
|
298
|
+
or not isinstance(artifact_sha256, str)
|
|
299
|
+
or not SHA256_PATTERN.fullmatch(artifact_sha256)
|
|
300
|
+
or adapter.get("artifact_signature")
|
|
301
|
+
!= f"github-oidc-dsse:sha256:{artifact_sha256}"
|
|
302
|
+
or not isinstance(adapter.get("supported_openclaw"), str)
|
|
303
|
+
or not _version_in_range(openclaw_version, adapter["supported_openclaw"])
|
|
304
|
+
):
|
|
305
|
+
raise InstallError("Cloud adapter package provenance or host compatibility is untrusted")
|
|
306
|
+
_https_url(adapter.get("artifact_url"), "Adapter artifact")
|
|
307
|
+
|
|
308
|
+
parsed_link = urlsplit(link_url)
|
|
309
|
+
base_path = parsed_link.path.rstrip("/")
|
|
310
|
+
if not re.fullmatch(r".*/onboard/[A-Za-z0-9_-]{20,128}", base_path):
|
|
311
|
+
raise InstallError("Cloud one-time link path is invalid")
|
|
312
|
+
rest_path = base_path.rsplit("/onboard/", 1)[0]
|
|
313
|
+
expected_rest = urlunsplit((parsed_link.scheme, parsed_link.netloc, rest_path, "", "")).rstrip(
|
|
314
|
+
"/"
|
|
315
|
+
)
|
|
316
|
+
connection = unsigned.get("connection")
|
|
317
|
+
redemption = unsigned.get("redemption")
|
|
318
|
+
compensation = unsigned.get("compensation")
|
|
319
|
+
reload_contract = unsigned.get("reload")
|
|
320
|
+
revocation = unsigned.get("revocation")
|
|
321
|
+
rollback = unsigned.get("rollback")
|
|
322
|
+
verification = unsigned.get("verification")
|
|
323
|
+
if (
|
|
324
|
+
not all(isinstance(value, dict) for value in (connection, redemption, compensation))
|
|
325
|
+
or set(connection) != {"authorization", "rest_url"}
|
|
326
|
+
or set(redemption) != {"identity_bound", "method", "single_use", "url"}
|
|
327
|
+
or set(compensation) != {"credential_specific", "method", "receipt_ttl_seconds", "url"}
|
|
328
|
+
):
|
|
329
|
+
raise InstallError("Cloud adapter endpoints are malformed")
|
|
330
|
+
rest_url = _https_url(connection.get("rest_url"), "Brain endpoint")
|
|
331
|
+
redemption_url = _https_url(redemption.get("url"), "Redemption endpoint")
|
|
332
|
+
compensation_url = _https_url(compensation.get("url"), "Compensation endpoint")
|
|
333
|
+
expected_redeem = urlunsplit(
|
|
334
|
+
(parsed_link.scheme, parsed_link.netloc, base_path + "/redeem/v2", parsed_link.query, "")
|
|
335
|
+
)
|
|
336
|
+
if (
|
|
337
|
+
rest_url != expected_rest
|
|
338
|
+
or connection.get("authorization") != "***"
|
|
339
|
+
or redemption.get("method") != "POST"
|
|
340
|
+
or redemption.get("single_use") is not True
|
|
341
|
+
or redemption.get("identity_bound") is not True
|
|
342
|
+
or redemption_url != expected_redeem
|
|
343
|
+
or compensation.get("method") != "POST"
|
|
344
|
+
or compensation.get("credential_specific") is not True
|
|
345
|
+
or compensation.get("receipt_ttl_seconds") != 600
|
|
346
|
+
or compensation_url != f"{expected_rest}/onboard/compensate"
|
|
347
|
+
or reload_contract != {"command": ["openclaw", "plugins", "reload", PLUGIN_ID]}
|
|
348
|
+
or revocation
|
|
349
|
+
!= {
|
|
350
|
+
"credential_id_issued_on_redemption": True,
|
|
351
|
+
"independent_agent": True,
|
|
352
|
+
"scope": "single-credential",
|
|
353
|
+
}
|
|
354
|
+
or rollback
|
|
355
|
+
!= {
|
|
356
|
+
"compensate_before_commit": True,
|
|
357
|
+
"ownership_marker": PLUGIN_ID,
|
|
358
|
+
"restore_previous_state": True,
|
|
359
|
+
}
|
|
360
|
+
or verification
|
|
361
|
+
!= {
|
|
362
|
+
"capture_recall_smoke": True,
|
|
363
|
+
"health": "/v1/health/ready",
|
|
364
|
+
"runtime_inspection": True,
|
|
365
|
+
}
|
|
366
|
+
):
|
|
367
|
+
raise InstallError("Cloud adapter endpoint binding is invalid")
|
|
368
|
+
return CloudManifest(
|
|
369
|
+
capabilities=capabilities,
|
|
370
|
+
compensation_url=compensation_url,
|
|
371
|
+
identity={key: str(identity[key]) for key in identity_keys},
|
|
372
|
+
manifest_id=str(manifest_id),
|
|
373
|
+
manifest_version=version,
|
|
374
|
+
redemption_url=redemption_url,
|
|
375
|
+
rest_url=rest_url,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
|
380
|
+
return subprocess.run(command, text=True, capture_output=True, check=False)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _checked(runner: Runner, command: list[str], label: str) -> subprocess.CompletedProcess[str]:
|
|
384
|
+
result = runner(command)
|
|
385
|
+
if result.returncode:
|
|
386
|
+
detail = (result.stderr or result.stdout).strip().splitlines()
|
|
387
|
+
suffix = f": {detail[-1]}" if detail else ""
|
|
388
|
+
raise InstallError(f"{label} failed{suffix}")
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _detect_openclaw(runner: Runner) -> tuple[int, int, int]:
|
|
393
|
+
result = _checked(runner, ["openclaw", "--version"], "OpenClaw detection")
|
|
394
|
+
match = VERSION_PATTERN.search(result.stdout)
|
|
395
|
+
if not match:
|
|
396
|
+
raise InstallError("unsupported OpenClaw version output")
|
|
397
|
+
version = tuple(int(value) for value in match.groups())
|
|
398
|
+
if not MIN_OPENCLAW <= version < MAX_OPENCLAW:
|
|
399
|
+
rendered = ".".join(str(value) for value in version)
|
|
400
|
+
raise InstallError(f"OpenClaw {rendered} is unsupported; require >=2026.9.6 and <2027")
|
|
401
|
+
return version
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _validate_secret(value: str) -> str:
|
|
405
|
+
if not value or any(character in value for character in "\r\n\0"):
|
|
406
|
+
raise InstallError("credential must be non-empty and contain no control characters")
|
|
407
|
+
return value
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _write_private(path: Path, content: str, mode: int) -> None:
|
|
411
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
412
|
+
path.write_text(content, encoding="utf-8")
|
|
413
|
+
os.chmod(path, mode)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _credentials_text(
|
|
417
|
+
api_url: str, token: str, queue_key: str, queue_path: Path, workspace_id: str | None
|
|
418
|
+
) -> str:
|
|
419
|
+
values = {
|
|
420
|
+
"ALBERT_API_URL": api_url.rstrip("/"),
|
|
421
|
+
"ALBERT_CAPTURE_TOKEN": token,
|
|
422
|
+
"ALBERT_CAPTURE_QUEUE_KEY": queue_key,
|
|
423
|
+
"ALBERT_CAPTURE_QUEUE_PATH": str(queue_path),
|
|
424
|
+
"ALBERT_CAPTURE_SCOPE": "openclaw:default",
|
|
425
|
+
}
|
|
426
|
+
if workspace_id:
|
|
427
|
+
values["ALBERT_WORKSPACE_ID"] = workspace_id
|
|
428
|
+
return json.dumps(values, sort_keys=True) + "\n"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _wrapper_text(credentials: Path) -> str:
|
|
432
|
+
python = shlex.quote(sys.executable)
|
|
433
|
+
secret_file = shlex.quote(str(credentials))
|
|
434
|
+
return (
|
|
435
|
+
"#!/bin/sh\n"
|
|
436
|
+
f"export ALBERT_CAPTURE_CREDENTIAL_FILE={secret_file}\n"
|
|
437
|
+
f"exec {python} -m albert.openclaw_adapter\n"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _brain_check(
|
|
442
|
+
api_url: str,
|
|
443
|
+
token: str,
|
|
444
|
+
queue_key: str,
|
|
445
|
+
workspace_id: str | None,
|
|
446
|
+
*,
|
|
447
|
+
smoke: bool,
|
|
448
|
+
event_classes: frozenset[str] = OPENCLAW_EVENTS,
|
|
449
|
+
) -> None:
|
|
450
|
+
base_url = api_url.rstrip("/")
|
|
451
|
+
try:
|
|
452
|
+
with httpx.Client(
|
|
453
|
+
base_url=base_url,
|
|
454
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
455
|
+
timeout=10,
|
|
456
|
+
) as http:
|
|
457
|
+
ready = http.get("/v1/health/ready")
|
|
458
|
+
if not ready.is_success:
|
|
459
|
+
raise InstallError(f"Brain readiness check failed ({ready.status_code})")
|
|
460
|
+
with tempfile.TemporaryDirectory(prefix="albert-install-check-") as temporary:
|
|
461
|
+
queue = EncryptedQueue(Path(temporary) / "capture.db", queue_key, "installer:smoke")
|
|
462
|
+
client = CaptureClient(
|
|
463
|
+
base_url,
|
|
464
|
+
token,
|
|
465
|
+
queue,
|
|
466
|
+
local_event_classes=event_classes,
|
|
467
|
+
client=http,
|
|
468
|
+
)
|
|
469
|
+
if not client.negotiate():
|
|
470
|
+
raise InstallError("Brain lacks the required capture/recall capabilities")
|
|
471
|
+
if not smoke:
|
|
472
|
+
return
|
|
473
|
+
nonce = uuid.uuid4().hex
|
|
474
|
+
session_key = f"installer:start:{nonce}"
|
|
475
|
+
start = client.start_session(
|
|
476
|
+
session_key,
|
|
477
|
+
session_key,
|
|
478
|
+
workspace_id=workspace_id,
|
|
479
|
+
provenance={"client": "installer", "event": "live_smoke"},
|
|
480
|
+
)
|
|
481
|
+
event = client.record_event(
|
|
482
|
+
session_key,
|
|
483
|
+
1,
|
|
484
|
+
"user_goal",
|
|
485
|
+
f"Albert installer smoke {nonce}",
|
|
486
|
+
f"installer:event:{nonce}",
|
|
487
|
+
provenance={"client": "installer", "event": "live_smoke"},
|
|
488
|
+
)
|
|
489
|
+
recalled = client.recall(f"Albert installer smoke {nonce}", max_items=1)
|
|
490
|
+
end = client.end_session(session_key, 1, f"installer:end:{nonce}")
|
|
491
|
+
if any(item.status != "delivered" for item in (start, event, end)):
|
|
492
|
+
raise InstallError("live capture smoke test was rejected")
|
|
493
|
+
if "ALBERT REFERENCE CONTEXT" not in recalled:
|
|
494
|
+
raise InstallError("live recall smoke test returned an invalid response")
|
|
495
|
+
except httpx.HTTPError as exc:
|
|
496
|
+
raise InstallError(f"direct Brain connection failed ({type(exc).__name__})") from None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def install_openclaw(
|
|
500
|
+
*,
|
|
501
|
+
api_url: str,
|
|
502
|
+
token: str,
|
|
503
|
+
workspace_id: str | None,
|
|
504
|
+
paths: InstallPaths,
|
|
505
|
+
runner: Runner = _run,
|
|
506
|
+
brain_check: Callable[[str, str, str, str | None], None] | None = None,
|
|
507
|
+
cloud_metadata: dict[str, object] | None = None,
|
|
508
|
+
package_version: str | None = None,
|
|
509
|
+
) -> None:
|
|
510
|
+
version = _detect_openclaw(runner)
|
|
511
|
+
installed_package_version = package_version or _package_version()
|
|
512
|
+
try:
|
|
513
|
+
target = negotiate_host_target(
|
|
514
|
+
"openclaw", version, {"capture", "recall", "reload", "rollback", "uninstall"}
|
|
515
|
+
)
|
|
516
|
+
assert_owned_install_root(paths.root, expected_marker=target.ownership["marker"])
|
|
517
|
+
except HostTargetError as exc:
|
|
518
|
+
raise InstallError(str(exc)) from None
|
|
519
|
+
token = _validate_secret(token)
|
|
520
|
+
api_url = api_url.rstrip("/")
|
|
521
|
+
if not api_url.startswith(("http://", "https://")):
|
|
522
|
+
raise InstallError("Brain URL must use http:// or https://")
|
|
523
|
+
|
|
524
|
+
previous_values: dict[str, str] | None = None
|
|
525
|
+
if paths.root.exists():
|
|
526
|
+
previous_values = _load_credentials(paths)
|
|
527
|
+
queue_key = (
|
|
528
|
+
previous_values.get("ALBERT_CAPTURE_QUEUE_KEY")
|
|
529
|
+
if previous_values is not None
|
|
530
|
+
else generate_queue_key()
|
|
531
|
+
)
|
|
532
|
+
if not queue_key:
|
|
533
|
+
raise InstallError("existing capture queue key is invalid")
|
|
534
|
+
check = brain_check or (
|
|
535
|
+
lambda url, secret, key, workspace: _brain_check(url, secret, key, workspace, smoke=True)
|
|
536
|
+
)
|
|
537
|
+
check(api_url, token, queue_key, workspace_id)
|
|
538
|
+
|
|
539
|
+
config_path = Path(
|
|
540
|
+
_checked(runner, ["openclaw", "config", "file"], "config discovery").stdout.strip()
|
|
541
|
+
).expanduser()
|
|
542
|
+
config_existed = config_path.exists()
|
|
543
|
+
config_before = config_path.read_bytes() if config_existed else None
|
|
544
|
+
config_mode = stat.S_IMODE(config_path.stat().st_mode) if config_existed else 0o600
|
|
545
|
+
backup = paths.root.with_name(f".{paths.root.name}.rollback-{uuid.uuid4().hex}")
|
|
546
|
+
staging = paths.root.with_name(f".{paths.root.name}.staging-{uuid.uuid4().hex}")
|
|
547
|
+
plugin_path = Path(__file__).with_name("openclaw_plugin")
|
|
548
|
+
previous_install = paths.root.exists()
|
|
549
|
+
plugin_installed = False
|
|
550
|
+
try:
|
|
551
|
+
staged = InstallPaths(staging)
|
|
552
|
+
_write_private(
|
|
553
|
+
staged.credentials,
|
|
554
|
+
_credentials_text(api_url, token, queue_key, paths.queue, workspace_id),
|
|
555
|
+
0o600,
|
|
556
|
+
)
|
|
557
|
+
_write_private(staged.wrapper, _wrapper_text(paths.credentials), 0o700)
|
|
558
|
+
if paths.queue.exists():
|
|
559
|
+
shutil.copy2(paths.queue, staged.queue)
|
|
560
|
+
os.chmod(staged.queue, 0o600)
|
|
561
|
+
marker = {
|
|
562
|
+
"owner": "albert-memory",
|
|
563
|
+
"host": "openclaw",
|
|
564
|
+
"host_target_schema": target.schema_version,
|
|
565
|
+
"host_target_version": target.descriptor_version,
|
|
566
|
+
"package_version": installed_package_version,
|
|
567
|
+
"api_url": api_url,
|
|
568
|
+
"openclaw_version": ".".join(str(value) for value in version),
|
|
569
|
+
}
|
|
570
|
+
if cloud_metadata is not None:
|
|
571
|
+
marker["cloud"] = cloud_metadata
|
|
572
|
+
_write_private(staged.marker, json.dumps(marker, sort_keys=True) + "\n", 0o600)
|
|
573
|
+
|
|
574
|
+
if paths.root.exists():
|
|
575
|
+
paths.root.rename(backup)
|
|
576
|
+
staging.rename(paths.root)
|
|
577
|
+
_checked(
|
|
578
|
+
runner,
|
|
579
|
+
[
|
|
580
|
+
"openclaw",
|
|
581
|
+
"plugins",
|
|
582
|
+
"install",
|
|
583
|
+
"--link",
|
|
584
|
+
"--force",
|
|
585
|
+
"--accept-capabilities",
|
|
586
|
+
str(plugin_path),
|
|
587
|
+
],
|
|
588
|
+
"OpenClaw plugin install",
|
|
589
|
+
)
|
|
590
|
+
plugin_installed = True
|
|
591
|
+
entry = {
|
|
592
|
+
"enabled": True,
|
|
593
|
+
"hooks": {"allowPromptInjection": True, "allowConversationAccess": True},
|
|
594
|
+
"config": {"command": str(paths.wrapper), "timeoutMs": 1800},
|
|
595
|
+
}
|
|
596
|
+
_checked(
|
|
597
|
+
runner,
|
|
598
|
+
[
|
|
599
|
+
"openclaw",
|
|
600
|
+
"config",
|
|
601
|
+
"set",
|
|
602
|
+
f"plugins.entries.{PLUGIN_ID}",
|
|
603
|
+
json.dumps(entry, separators=(",", ":")),
|
|
604
|
+
"--strict-json",
|
|
605
|
+
],
|
|
606
|
+
"OpenClaw plugin configuration",
|
|
607
|
+
)
|
|
608
|
+
_checked(runner, ["openclaw", "config", "validate"], "OpenClaw config validation")
|
|
609
|
+
_checked(
|
|
610
|
+
runner,
|
|
611
|
+
["openclaw", "plugins", "reload", PLUGIN_ID],
|
|
612
|
+
"OpenClaw plugin reload",
|
|
613
|
+
)
|
|
614
|
+
_checked(
|
|
615
|
+
runner,
|
|
616
|
+
["openclaw", "plugins", "inspect", PLUGIN_ID, "--runtime", "--json"],
|
|
617
|
+
"OpenClaw runtime inspection",
|
|
618
|
+
)
|
|
619
|
+
check(api_url, token, queue_key, workspace_id)
|
|
620
|
+
except Exception as exc:
|
|
621
|
+
cleanup_error: Exception | None = None
|
|
622
|
+
if plugin_installed and not previous_install:
|
|
623
|
+
try:
|
|
624
|
+
_checked(
|
|
625
|
+
runner,
|
|
626
|
+
["openclaw", "plugins", "uninstall", PLUGIN_ID, "--force"],
|
|
627
|
+
"OpenClaw plugin rollback",
|
|
628
|
+
)
|
|
629
|
+
except Exception as rollback_exc:
|
|
630
|
+
cleanup_error = rollback_exc
|
|
631
|
+
if paths.root.exists():
|
|
632
|
+
shutil.rmtree(paths.root)
|
|
633
|
+
if backup.exists():
|
|
634
|
+
backup.rename(paths.root)
|
|
635
|
+
if config_before is not None:
|
|
636
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
temporary = config_path.with_name(f".{config_path.name}.albert-rollback")
|
|
638
|
+
temporary.write_bytes(config_before)
|
|
639
|
+
os.chmod(temporary, config_mode)
|
|
640
|
+
os.replace(temporary, config_path)
|
|
641
|
+
elif not config_existed and config_path.exists():
|
|
642
|
+
config_path.unlink()
|
|
643
|
+
if staging.exists():
|
|
644
|
+
shutil.rmtree(staging)
|
|
645
|
+
if cleanup_error is not None:
|
|
646
|
+
raise InstallError(
|
|
647
|
+
f"{exc}; automatic plugin rollback failed ({cleanup_error}). "
|
|
648
|
+
f"Run `openclaw plugins uninstall {PLUGIN_ID} --force`, then retry."
|
|
649
|
+
) from exc
|
|
650
|
+
raise
|
|
651
|
+
else:
|
|
652
|
+
if backup.exists():
|
|
653
|
+
shutil.rmtree(backup)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _load_credentials(paths: InstallPaths) -> dict[str, str]:
|
|
657
|
+
if not paths.marker.exists() or not paths.credentials.exists():
|
|
658
|
+
raise InstallError("no Albert-owned OpenClaw installation found")
|
|
659
|
+
marker = json.loads(paths.marker.read_text(encoding="utf-8"))
|
|
660
|
+
if marker.get("owner") != "albert-memory":
|
|
661
|
+
raise InstallError("installation ownership marker is invalid")
|
|
662
|
+
values = json.loads(paths.credentials.read_text(encoding="utf-8"))
|
|
663
|
+
if not isinstance(values, dict) or not all(
|
|
664
|
+
isinstance(key, str) and isinstance(value, str) for key, value in values.items()
|
|
665
|
+
):
|
|
666
|
+
raise InstallError("credential file is invalid")
|
|
667
|
+
return values
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def doctor_openclaw(paths: InstallPaths, *, smoke: bool = False, runner: Runner = _run) -> None:
|
|
671
|
+
version = _detect_openclaw(runner)
|
|
672
|
+
try:
|
|
673
|
+
target = negotiate_host_target("openclaw", version, {"capture", "recall"})
|
|
674
|
+
assert_owned_install_root(paths.root, expected_marker=target.ownership["marker"])
|
|
675
|
+
except HostTargetError as exc:
|
|
676
|
+
raise InstallError(str(exc)) from None
|
|
677
|
+
values = _load_credentials(paths)
|
|
678
|
+
_checked(runner, ["openclaw", "config", "validate"], "OpenClaw config validation")
|
|
679
|
+
_checked(
|
|
680
|
+
runner,
|
|
681
|
+
["openclaw", "plugins", "inspect", PLUGIN_ID, "--runtime", "--json"],
|
|
682
|
+
"OpenClaw runtime inspection",
|
|
683
|
+
)
|
|
684
|
+
_brain_check(
|
|
685
|
+
values["ALBERT_API_URL"],
|
|
686
|
+
values["ALBERT_CAPTURE_TOKEN"],
|
|
687
|
+
values["ALBERT_CAPTURE_QUEUE_KEY"],
|
|
688
|
+
values.get("ALBERT_WORKSPACE_ID"),
|
|
689
|
+
smoke=smoke,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def uninstall_openclaw(paths: InstallPaths, *, runner: Runner = _run) -> None:
|
|
694
|
+
version = _detect_openclaw(runner)
|
|
695
|
+
try:
|
|
696
|
+
target = negotiate_host_target("openclaw", version, {"uninstall"})
|
|
697
|
+
assert_owned_install_root(paths.root, expected_marker=target.ownership["marker"])
|
|
698
|
+
except HostTargetError as exc:
|
|
699
|
+
raise InstallError(str(exc)) from None
|
|
700
|
+
_load_credentials(paths)
|
|
701
|
+
result = runner(["openclaw", "plugins", "uninstall", PLUGIN_ID, "--force"])
|
|
702
|
+
if result.returncode:
|
|
703
|
+
_checked(
|
|
704
|
+
runner,
|
|
705
|
+
["openclaw", "config", "unset", f"plugins.entries.{PLUGIN_ID}"],
|
|
706
|
+
"OpenClaw config cleanup",
|
|
707
|
+
)
|
|
708
|
+
if paths.root.exists():
|
|
709
|
+
shutil.rmtree(paths.root)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def connect_openclaw(
|
|
713
|
+
*,
|
|
714
|
+
link_url: str,
|
|
715
|
+
paths: InstallPaths,
|
|
716
|
+
runner: Runner = _run,
|
|
717
|
+
trust: dict[str, object] | None = None,
|
|
718
|
+
package_version: str | None = None,
|
|
719
|
+
http: httpx.Client | None = None,
|
|
720
|
+
) -> None:
|
|
721
|
+
from datetime import UTC, datetime, timedelta
|
|
722
|
+
from urllib.parse import parse_qs, urlsplit, urlunsplit
|
|
723
|
+
|
|
724
|
+
link_url = _https_url(_validate_secret(link_url.strip()), "Cloud one-time link")
|
|
725
|
+
installed_package_version = package_version or _package_version()
|
|
726
|
+
parsed = urlsplit(link_url)
|
|
727
|
+
try:
|
|
728
|
+
query = parse_qs(parsed.query, strict_parsing=True)
|
|
729
|
+
except ValueError:
|
|
730
|
+
raise InstallError("Cloud one-time link context is invalid") from None
|
|
731
|
+
if set(query) != {"adapter_context"} or len(query["adapter_context"]) != 1:
|
|
732
|
+
raise InstallError("Cloud one-time link context is invalid")
|
|
733
|
+
base_path = parsed.path.rstrip("/")
|
|
734
|
+
if not re.fullmatch(r".*/onboard/[A-Za-z0-9_-]{20,128}", base_path):
|
|
735
|
+
raise InstallError("Cloud one-time link path is invalid")
|
|
736
|
+
manifest_url = urlunsplit(
|
|
737
|
+
(parsed.scheme, parsed.netloc, base_path + "/manifest/v2", parsed.query, "")
|
|
738
|
+
)
|
|
739
|
+
version = _detect_openclaw(runner)
|
|
740
|
+
try:
|
|
741
|
+
negotiate_host_target(
|
|
742
|
+
"openclaw",
|
|
743
|
+
version,
|
|
744
|
+
{"capture", "recall", "reload", "rollback", "rotate", "revoke"},
|
|
745
|
+
)
|
|
746
|
+
except HostTargetError as exc:
|
|
747
|
+
raise InstallError(str(exc)) from None
|
|
748
|
+
client = http or httpx.Client(timeout=15, follow_redirects=False)
|
|
749
|
+
owns_client = http is None
|
|
750
|
+
receipt: str | None = None
|
|
751
|
+
compensation_url: str | None = None
|
|
752
|
+
try:
|
|
753
|
+
try:
|
|
754
|
+
response = client.get(manifest_url, headers={"Accept": "application/json"})
|
|
755
|
+
except httpx.HTTPError as exc:
|
|
756
|
+
raise InstallError(f"Cloud manifest request failed ({type(exc).__name__})") from None
|
|
757
|
+
if response.status_code != 200:
|
|
758
|
+
raise InstallError(f"Cloud manifest request was rejected ({response.status_code})")
|
|
759
|
+
try:
|
|
760
|
+
manifest_payload = response.json()
|
|
761
|
+
except ValueError:
|
|
762
|
+
raise InstallError("Cloud adapter manifest is malformed") from None
|
|
763
|
+
manifest = _verify_manifest(
|
|
764
|
+
manifest_payload,
|
|
765
|
+
link_url=link_url,
|
|
766
|
+
openclaw_version=version,
|
|
767
|
+
trust=trust,
|
|
768
|
+
package_version=installed_package_version,
|
|
769
|
+
)
|
|
770
|
+
compensation_url = manifest.compensation_url
|
|
771
|
+
redemption = {
|
|
772
|
+
"agent_name": "Native OpenClaw",
|
|
773
|
+
"brain_id": manifest.identity["brain_id"],
|
|
774
|
+
"capabilities": manifest.capabilities,
|
|
775
|
+
"manifest_id": manifest.manifest_id,
|
|
776
|
+
"manifest_version": manifest.manifest_version,
|
|
777
|
+
"request_id": manifest.identity["request_id"],
|
|
778
|
+
"workspace_id": manifest.identity["workspace_id"],
|
|
779
|
+
}
|
|
780
|
+
try:
|
|
781
|
+
response = client.post(
|
|
782
|
+
manifest.redemption_url,
|
|
783
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
784
|
+
json=redemption,
|
|
785
|
+
)
|
|
786
|
+
except httpx.HTTPError as exc:
|
|
787
|
+
raise InstallError(f"Cloud redemption failed ({type(exc).__name__})") from None
|
|
788
|
+
if response.status_code != 201:
|
|
789
|
+
raise InstallError(f"Cloud redemption was rejected ({response.status_code})")
|
|
790
|
+
try:
|
|
791
|
+
redeemed = response.json()
|
|
792
|
+
except ValueError:
|
|
793
|
+
raise InstallError("Cloud redemption response is malformed") from None
|
|
794
|
+
if not isinstance(redeemed, dict):
|
|
795
|
+
raise InstallError("Cloud redemption response is malformed")
|
|
796
|
+
identity = redeemed.get("identity")
|
|
797
|
+
compensation = redeemed.get("compensation")
|
|
798
|
+
if isinstance(compensation, dict) and isinstance(compensation.get("receipt"), str):
|
|
799
|
+
candidate_receipt = compensation["receipt"]
|
|
800
|
+
if candidate_receipt and not any(char in candidate_receipt for char in "\r\n\0"):
|
|
801
|
+
receipt = candidate_receipt
|
|
802
|
+
credential = redeemed.get("credential")
|
|
803
|
+
expected_identity = {
|
|
804
|
+
"brain_id": manifest.identity["brain_id"],
|
|
805
|
+
"request_id": manifest.identity["request_id"],
|
|
806
|
+
"workspace_id": manifest.identity["workspace_id"],
|
|
807
|
+
}
|
|
808
|
+
if (
|
|
809
|
+
set(redeemed)
|
|
810
|
+
!= {
|
|
811
|
+
"capabilities",
|
|
812
|
+
"compensation",
|
|
813
|
+
"credential",
|
|
814
|
+
"credential_notice",
|
|
815
|
+
"identity",
|
|
816
|
+
"manifest_version",
|
|
817
|
+
"rest_url",
|
|
818
|
+
}
|
|
819
|
+
or not isinstance(identity, dict)
|
|
820
|
+
or set(identity)
|
|
821
|
+
!= {
|
|
822
|
+
"brain_id",
|
|
823
|
+
"connection_id",
|
|
824
|
+
"credential_id",
|
|
825
|
+
"request_id",
|
|
826
|
+
"workspace_id",
|
|
827
|
+
}
|
|
828
|
+
or any(identity.get(key) != value for key, value in expected_identity.items())
|
|
829
|
+
or not all(
|
|
830
|
+
isinstance(identity.get(key), str) and ID_PATTERN.fullmatch(identity[key])
|
|
831
|
+
for key in ("connection_id", "credential_id")
|
|
832
|
+
)
|
|
833
|
+
or redeemed.get("manifest_version") != manifest.manifest_version
|
|
834
|
+
or redeemed.get("rest_url") != manifest.rest_url
|
|
835
|
+
or redeemed.get("capabilities") != manifest.capabilities
|
|
836
|
+
or redeemed.get("credential_notice")
|
|
837
|
+
!= "Shown once; store only in the Albert-owned credential file."
|
|
838
|
+
or not isinstance(credential, str)
|
|
839
|
+
or not credential
|
|
840
|
+
or any(character in credential for character in "\r\n\0")
|
|
841
|
+
or not isinstance(compensation, dict)
|
|
842
|
+
or set(compensation) != {"expires_at", "receipt"}
|
|
843
|
+
or receipt is None
|
|
844
|
+
or not isinstance(compensation.get("expires_at"), str)
|
|
845
|
+
):
|
|
846
|
+
raise InstallError("Cloud redemption response identity does not match")
|
|
847
|
+
try:
|
|
848
|
+
compensation_expires = datetime.fromisoformat(
|
|
849
|
+
compensation["expires_at"].replace("Z", "+00:00")
|
|
850
|
+
)
|
|
851
|
+
except ValueError:
|
|
852
|
+
raise InstallError("Cloud compensation receipt lifetime is malformed") from None
|
|
853
|
+
now = datetime.now(UTC)
|
|
854
|
+
if (
|
|
855
|
+
compensation_expires.utcoffset() != timedelta(0)
|
|
856
|
+
or compensation_expires <= now
|
|
857
|
+
or compensation_expires - now > timedelta(minutes=11)
|
|
858
|
+
):
|
|
859
|
+
raise InstallError("Cloud compensation receipt lifetime is invalid")
|
|
860
|
+
cloud_metadata = {
|
|
861
|
+
"brain_id": identity["brain_id"],
|
|
862
|
+
"connection_id": identity["connection_id"],
|
|
863
|
+
"credential_id": identity["credential_id"],
|
|
864
|
+
"manifest_id": manifest.manifest_id,
|
|
865
|
+
"manifest_version": manifest.manifest_version,
|
|
866
|
+
"request_id": identity["request_id"],
|
|
867
|
+
"rest_url": manifest.rest_url,
|
|
868
|
+
"workspace_id": identity["workspace_id"],
|
|
869
|
+
}
|
|
870
|
+
install_openclaw(
|
|
871
|
+
api_url=manifest.rest_url,
|
|
872
|
+
token=credential,
|
|
873
|
+
workspace_id=manifest.identity["workspace_id"],
|
|
874
|
+
paths=paths,
|
|
875
|
+
runner=runner,
|
|
876
|
+
cloud_metadata=cloud_metadata,
|
|
877
|
+
package_version=installed_package_version,
|
|
878
|
+
)
|
|
879
|
+
except Exception as connect_exc:
|
|
880
|
+
if receipt is not None and compensation_url is not None:
|
|
881
|
+
try:
|
|
882
|
+
compensated = client.post(
|
|
883
|
+
compensation_url,
|
|
884
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
885
|
+
json={"receipt": receipt},
|
|
886
|
+
)
|
|
887
|
+
if compensated.status_code != 200:
|
|
888
|
+
raise InstallError(
|
|
889
|
+
f"credential compensation was rejected ({compensated.status_code})"
|
|
890
|
+
)
|
|
891
|
+
try:
|
|
892
|
+
compensation_result = compensated.json()
|
|
893
|
+
except ValueError:
|
|
894
|
+
raise InstallError("credential compensation response is malformed") from None
|
|
895
|
+
expected_compensation = {
|
|
896
|
+
"connection_id": identity.get("connection_id")
|
|
897
|
+
if isinstance(identity, dict)
|
|
898
|
+
else None,
|
|
899
|
+
"credential_id": identity.get("credential_id")
|
|
900
|
+
if isinstance(identity, dict)
|
|
901
|
+
else None,
|
|
902
|
+
"revoked": True,
|
|
903
|
+
}
|
|
904
|
+
if compensation_result != expected_compensation:
|
|
905
|
+
raise InstallError("credential compensation response did not match")
|
|
906
|
+
except (httpx.HTTPError, InstallError) as compensation_exc:
|
|
907
|
+
raise InstallError(
|
|
908
|
+
f"{connect_exc}; replacement credential compensation failed "
|
|
909
|
+
f"({type(compensation_exc).__name__}); revoke the failed connection in Cloud"
|
|
910
|
+
) from connect_exc
|
|
911
|
+
if isinstance(connect_exc, InstallError):
|
|
912
|
+
raise
|
|
913
|
+
raise InstallError(f"connection failed safely ({type(connect_exc).__name__})") from None
|
|
914
|
+
finally:
|
|
915
|
+
if owns_client:
|
|
916
|
+
client.close()
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _default_paths() -> InstallPaths:
|
|
920
|
+
return InstallPaths(Path("~/.openclaw/albert").expanduser())
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
@app.command("connect")
|
|
924
|
+
def connect_command(
|
|
925
|
+
link_stdin: bool = typer.Option(
|
|
926
|
+
False,
|
|
927
|
+
"--link-stdin",
|
|
928
|
+
help="Read the one-time link from private standard input.",
|
|
929
|
+
),
|
|
930
|
+
link_fd: int | None = typer.Option(
|
|
931
|
+
None,
|
|
932
|
+
"--link-fd",
|
|
933
|
+
min=0,
|
|
934
|
+
help="Read the one-time link from an already-open private file descriptor.",
|
|
935
|
+
),
|
|
936
|
+
) -> None:
|
|
937
|
+
"""Privately redeem a Cloud one-time link and connect OpenClaw."""
|
|
938
|
+
try:
|
|
939
|
+
if link_stdin and link_fd is not None:
|
|
940
|
+
raise InstallError("choose only one private link input mode")
|
|
941
|
+
if link_fd is not None:
|
|
942
|
+
with os.fdopen(os.dup(link_fd), encoding="utf-8") as stream:
|
|
943
|
+
link = stream.read(8193)
|
|
944
|
+
elif link_stdin:
|
|
945
|
+
link = sys.stdin.read(8193)
|
|
946
|
+
else:
|
|
947
|
+
link = getpass.getpass("Albert Cloud one-time link: ")
|
|
948
|
+
if len(link) > 8192:
|
|
949
|
+
raise InstallError("Cloud one-time link is too long")
|
|
950
|
+
connect_openclaw(link_url=link.strip(), paths=_default_paths())
|
|
951
|
+
typer.echo("Albert Memory connected; reload, live capture, and fresh recall passed.")
|
|
952
|
+
except (InstallError, OSError) as exc:
|
|
953
|
+
typer.echo(f"Connect failed safely: {exc}", err=True)
|
|
954
|
+
raise typer.Exit(1) from None
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
@app.command("install")
|
|
958
|
+
def install_command(
|
|
959
|
+
api_url: str = typer.Option(..., "--api-url", help="Direct self-hosted or managed Brain URL."),
|
|
960
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
961
|
+
) -> None:
|
|
962
|
+
"""Install the first ready adapter target (OpenClaw)."""
|
|
963
|
+
try:
|
|
964
|
+
token = getpass.getpass("Albert capture token: ")
|
|
965
|
+
install_openclaw(
|
|
966
|
+
api_url=api_url,
|
|
967
|
+
token=token,
|
|
968
|
+
workspace_id=workspace_id,
|
|
969
|
+
paths=_default_paths(),
|
|
970
|
+
)
|
|
971
|
+
typer.echo("Albert Memory installed for OpenClaw; live capture and recall passed.")
|
|
972
|
+
except InstallError as exc:
|
|
973
|
+
typer.echo(f"Install failed safely: {exc}", err=True)
|
|
974
|
+
raise typer.Exit(1) from None
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@app.command("doctor")
|
|
978
|
+
def doctor_command(smoke: bool = typer.Option(False, "--smoke")) -> None:
|
|
979
|
+
"""Verify the installed host, configuration, Brain, and optional live smoke flow."""
|
|
980
|
+
try:
|
|
981
|
+
doctor_openclaw(_default_paths(), smoke=smoke)
|
|
982
|
+
typer.echo("Albert Memory doctor passed.")
|
|
983
|
+
except InstallError as exc:
|
|
984
|
+
typer.echo(f"Doctor failed: {exc}", err=True)
|
|
985
|
+
raise typer.Exit(1) from None
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
@app.command("uninstall")
|
|
989
|
+
def uninstall_command() -> None:
|
|
990
|
+
"""Remove only Albert-owned OpenClaw configuration and files."""
|
|
991
|
+
try:
|
|
992
|
+
uninstall_openclaw(_default_paths())
|
|
993
|
+
typer.echo("Albert Memory removed; non-Albert OpenClaw settings were preserved.")
|
|
994
|
+
except InstallError as exc:
|
|
995
|
+
typer.echo(f"Uninstall failed safely: {exc}", err=True)
|
|
996
|
+
raise typer.Exit(1) from None
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
if __name__ == "__main__":
|
|
1000
|
+
app()
|