@agentsdock/server 1.0.10-beta.4 → 1.0.10-beta.6
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/README.md +6 -0
- package/npm/cli.cjs +23 -1
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +20 -6
- package/server/codex_provider.py +12 -2
- package/server/execution_activation.py +1 -1
- package/server/execution_control.py +4 -0
- package/server/execution_install.py +48 -7
- package/server/execution_manage.py +304 -0
- package/server/execution_recovery.py +1 -1
- package/server/execution_uninstall.py +19 -0
- package/server/execution_update_status.py +3 -10
- package/server/opencode_agent_client.py +5 -0
- package/server/provider_connections.py +21 -11
- package/server/server_instances.py +45 -2
- package/server/update_handoff.py +6 -11
package/README.md
CHANGED
|
@@ -20,6 +20,12 @@ the same prerequisites as the source installer: a trusted `uv`, Linux with a
|
|
|
20
20
|
working user systemd session or Apple silicon macOS with a GUI launchd session,
|
|
21
21
|
and the desired provider CLIs. It runs as the current user without `sudo`.
|
|
22
22
|
|
|
23
|
+
Installation and recovery exclude temporary npm lifecycle `.bin` paths, npm
|
|
24
|
+
`_npx` cache paths and npm node-gyp shims from the inherited PATH. Stable absolute
|
|
25
|
+
user/provider/Node locations remain ordered; empty and relative entries are
|
|
26
|
+
excluded. This lexical filter is not executable or symlink authorization and
|
|
27
|
+
does not repair the persisted PATH of an existing installation.
|
|
28
|
+
|
|
23
29
|
The npm entrypoint only permits a fresh default installation. It refuses an
|
|
24
30
|
existing managed installation, legacy service, or custom installation roots;
|
|
25
31
|
changing directories does not create a second service. Use AgentsDock's managed
|
package/npm/cli.cjs
CHANGED
|
@@ -23,6 +23,27 @@ No installation hooks run when this npm package is installed.
|
|
|
23
23
|
`
|
|
24
24
|
const ROOT_SELECTORS = ['AGENTS_SERVER_INSTALL_DIR', 'AGENTS_SERVER_CONFIG_DIR', 'AGENTS_SERVER_STATE_DIR', 'AGENTSDOCK_STATE_DIR', 'ZENITHBOT_AGENT_DIR', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME']
|
|
25
25
|
|
|
26
|
+
function persistentPath(value) {
|
|
27
|
+
if (value === undefined) return undefined
|
|
28
|
+
// npm adds package/ancestor .bin directories and node-gyp shims to lifecycle
|
|
29
|
+
// PATH; npx additionally exposes its disposable cache. These must not become
|
|
30
|
+
// dependencies of a long-lived native service. Keep stable absolute user,
|
|
31
|
+
// provider and Node paths in order; do not trust npm env as an "original PATH".
|
|
32
|
+
// This is a lexical persistence filter, not executable/symlink authorization.
|
|
33
|
+
const entries = new Set()
|
|
34
|
+
for (const entry of String(value).split(path.delimiter)) {
|
|
35
|
+
if (!path.isAbsolute(entry) || /[\x00-\x1f\x7f]/.test(entry)) continue
|
|
36
|
+
const normalized = path.normalize(entry).replace(/\/$/, '') || '/'
|
|
37
|
+
if (/\/node_modules\/\.bin$/.test(normalized) ||
|
|
38
|
+
/\/_npx\/[^/]+(?:\/|$)/.test(normalized) ||
|
|
39
|
+
/\/node_modules\/(?:npm\/bin|@npmcli\/run-script\/lib)\/node-gyp-bin$/.test(normalized)) continue
|
|
40
|
+
// Do not rewrite retained paths across symlink/.. boundaries: lexical
|
|
41
|
+
// normalization is only for recognizing npm entries, not resolving paths.
|
|
42
|
+
entries.add(entry)
|
|
43
|
+
}
|
|
44
|
+
return entries.size ? [...entries].join(path.delimiter) : '/usr/bin:/bin:/usr/sbin:/sbin'
|
|
45
|
+
}
|
|
46
|
+
|
|
26
47
|
function parse(argv) {
|
|
27
48
|
const [command = 'help', ...args] = argv
|
|
28
49
|
if (['help', '--help', '-h', '--version'].includes(command)) {
|
|
@@ -236,6 +257,7 @@ async function run(argv, overrides = {}) {
|
|
|
236
257
|
const payload = path.join(context.packageRoot, 'server')
|
|
237
258
|
const payloadVersion = readRegular(path.join(payload, 'VERSION'), 200).toString('utf8').trim()
|
|
238
259
|
if (payloadVersion !== metadata.version) throw new Error('Package and server payload versions do not match.')
|
|
260
|
+
if (Object.hasOwn(context.env, 'PATH')) context.env = { ...context.env, PATH: persistentPath(context.env.PATH) }
|
|
239
261
|
if (command === 'recover') {
|
|
240
262
|
const recovery = recoveryContext(context)
|
|
241
263
|
if (!recovery) { context.print('No unfinished default server migration was found. No changes were made.'); return 0 }
|
|
@@ -290,4 +312,4 @@ async function run(argv, overrides = {}) {
|
|
|
290
312
|
}
|
|
291
313
|
|
|
292
314
|
if (require.main === module) run(process.argv.slice(2)).then(code => { process.exitCode = code }).catch(error => { process.stderr.write(`agentsdock-server: ${error.message}\n`); process.exitCode = 1 })
|
|
293
|
-
module.exports = { parse, readRegular, ensureFreshInstall, recoveryContext, validateOrigin, run }
|
|
315
|
+
module.exports = { parse, readRegular, ensureFreshInstall, recoveryContext, validateOrigin, persistentPath, run }
|
package/package.json
CHANGED
package/server/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.10-beta.
|
|
1
|
+
1.0.10-beta.6
|
package/server/agent_server.py
CHANGED
|
@@ -67519,8 +67519,8 @@ async def run_opencode_process(
|
|
|
67519
67519
|
- The workspace is explicitly passed as ``--dir`` as well as cwd.
|
|
67520
67520
|
- There is no init event. Every event already carries the sessionID, so
|
|
67521
67521
|
the session is considered started at the first event of any kind.
|
|
67522
|
-
-
|
|
67523
|
-
|
|
67522
|
+
- An explicit completion ``step_finish`` ends the turn; ``tool-calls``
|
|
67523
|
+
continues it and invalid/unknown finishes fail closed.
|
|
67524
67524
|
- Usage is reported per step and must be accumulated, not overwritten.
|
|
67525
67525
|
"""
|
|
67526
67526
|
from opencode_agent_client import (
|
|
@@ -68429,10 +68429,24 @@ async def run_opencode_process(
|
|
|
68429
68429
|
opencode_usage,
|
|
68430
68430
|
normalized.get("usage") or {},
|
|
68431
68431
|
)
|
|
68432
|
-
#
|
|
68433
|
-
#
|
|
68434
|
-
#
|
|
68435
|
-
|
|
68432
|
+
# Only explicit completion reasons end a turn. In particular,
|
|
68433
|
+
# HTML-200/empty streams can yield "unknown" while the CLI
|
|
68434
|
+
# immediately requests another step; waiting for exit lets it
|
|
68435
|
+
# loop and hides the invalid response behind a cleanup error.
|
|
68436
|
+
finish_reason = str(normalized.get("reason") or "")
|
|
68437
|
+
if finish_reason not in {"tool-calls", "stop", "length", "content-filter"}:
|
|
68438
|
+
stream_error = (
|
|
68439
|
+
"OpenCode did not receive a valid completion from the "
|
|
68440
|
+
"model API. Check the endpoint URL, selected protocol "
|
|
68441
|
+
"and streaming support. The run was stopped to prevent "
|
|
68442
|
+
"repeated requests."
|
|
68443
|
+
)
|
|
68444
|
+
await terminate_process_tree(
|
|
68445
|
+
proc,
|
|
68446
|
+
grace=OPENCODE_GUARD_TEARDOWN_GRACE_SECONDS,
|
|
68447
|
+
)
|
|
68448
|
+
break
|
|
68449
|
+
if finish_reason != "tool-calls":
|
|
68436
68450
|
await flush_opencode_reasoning()
|
|
68437
68451
|
terminal_event_seen = True
|
|
68438
68452
|
try:
|
package/server/codex_provider.py
CHANGED
|
@@ -814,6 +814,17 @@ def safe_model_label(label, model):
|
|
|
814
814
|
return label.strip() if isinstance(label, str) and label.strip() and len(label) <= 160 and all(ord(c) >= 32 and ord(c) != 127 for c in label) else model
|
|
815
815
|
|
|
816
816
|
|
|
817
|
+
def endpoint_api_base_url(base: str, protocol: str) -> str:
|
|
818
|
+
"""Resolve the API prefix, retaining the gateway's path and saved identity.
|
|
819
|
+
|
|
820
|
+
Anthropic settings accept either a root or its /v1 API prefix. AI SDK
|
|
821
|
+
adapters append /messages, unlike Claude Code which appends /v1/messages.
|
|
822
|
+
OpenAI-compatible settings already specify their complete API prefix.
|
|
823
|
+
"""
|
|
824
|
+
base = base.rstrip("/")
|
|
825
|
+
return base + "/v1" if protocol == "anthropic" and not base.endswith("/v1") else base
|
|
826
|
+
|
|
827
|
+
|
|
817
828
|
def endpoint_model_catalog(selected: dict, *, protocol="responses") -> dict:
|
|
818
829
|
"""Read only this endpoint's inventory. Never use CLI models or infer a default.
|
|
819
830
|
|
|
@@ -823,8 +834,7 @@ def endpoint_model_catalog(selected: dict, *, protocol="responses") -> dict:
|
|
|
823
834
|
base = selected["base_url"].rstrip("/")
|
|
824
835
|
parsed = urlsplit(base)
|
|
825
836
|
router = parsed.scheme == "https" and parsed.netloc in {"openrouter.ai", "eu.openrouter.ai"} and parsed.path in {"/api", "/api/v1"}
|
|
826
|
-
target = (base.removesuffix("/v1") + "/v1/models/user") if router else base +
|
|
827
|
-
"/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
|
|
837
|
+
target = (base.removesuffix("/v1") + "/v1/models/user") if router else endpoint_api_base_url(base, protocol) + "/models"
|
|
828
838
|
headers = {"Accept": "application/json", "anthropic-version": "2023-06-01"}
|
|
829
839
|
header = selected.get("auth_header", "bearer")
|
|
830
840
|
headers["x-api-key" if header == "x-api-key" else "Authorization"] = selected["api_key"] if header == "x-api-key" else "Bearer " + selected["api_key"]
|
|
@@ -249,7 +249,7 @@ def require_retired_legacy_intent(layout: files.ExecutionLayout, previous: Any,
|
|
|
249
249
|
|
|
250
250
|
root = layout.install_root
|
|
251
251
|
def no_journal():
|
|
252
|
-
for name in (".activation-transaction", ".execution-transaction", ".execution-uninstall.json"):
|
|
252
|
+
for name in (".activation-transaction", ".execution-transaction", ".execution-uninstall.json", ".execution-lifecycle.json"):
|
|
253
253
|
if (root / name).exists() or (root / name).is_symlink():
|
|
254
254
|
raise RuntimeError("a lifecycle journal still owns the previous recovery intent")
|
|
255
255
|
no_journal()
|
|
@@ -186,6 +186,10 @@ class ExecutionControl:
|
|
|
186
186
|
if length not in {None, 0}:
|
|
187
187
|
raise ExecutionControlError(400, "Status requests do not accept a body")
|
|
188
188
|
result = await self.status_callback()
|
|
189
|
+
# This running worker understands execution-lifecycle startup
|
|
190
|
+
# holds. New CLIs must not stop an older retained worker and
|
|
191
|
+
# discover only after restart that its queue was not held.
|
|
192
|
+
result = {**result, "native_lifecycle_protocol": 1}
|
|
189
193
|
else:
|
|
190
194
|
content_types = _header_values(headers, b"content-type")
|
|
191
195
|
if (len(content_types) != 1 or content_types[0].lower()
|
|
@@ -329,6 +329,36 @@ def layout_manifest(layout: ExecutionLayout) -> dict[str, Any]:
|
|
|
329
329
|
"retained_releases": sorted({str(layout.worker_release), str(layout.gateway_release)})}
|
|
330
330
|
|
|
331
331
|
|
|
332
|
+
def installed_layout(root: Path) -> ExecutionLayout:
|
|
333
|
+
"""Validate authoritative bindings without discarding retained rollback history."""
|
|
334
|
+
root = _path(root)
|
|
335
|
+
data, _mode = _read_file(root / LAYOUT_NAME, private=True)
|
|
336
|
+
value = json.loads(data)
|
|
337
|
+
if not isinstance(value, dict):
|
|
338
|
+
raise ValueError("installed execution layout is invalid")
|
|
339
|
+
layout = ExecutionLayout.from_dict(value.get("layout"))
|
|
340
|
+
expected = layout_manifest(layout)
|
|
341
|
+
retained = value.get("retained_releases")
|
|
342
|
+
if (layout.install_root != root or set(value) != set(expected)
|
|
343
|
+
or {key: item for key, item in value.items() if key != "retained_releases"}
|
|
344
|
+
!= {key: item for key, item in expected.items() if key != "retained_releases"}
|
|
345
|
+
or not isinstance(retained, list) or not all(isinstance(item, str) for item in retained)
|
|
346
|
+
or len(retained) != len(set(retained))
|
|
347
|
+
or not set(expected["retained_releases"]).issubset(retained)):
|
|
348
|
+
raise ValueError("installed execution layout does not match its runtime")
|
|
349
|
+
# Old monolith releases may have no execution_service.py. Retention is not
|
|
350
|
+
# activation authority, but every advisory entry must be an owned, real
|
|
351
|
+
# version directory inside this installation (never an arbitrary path).
|
|
352
|
+
for item in retained:
|
|
353
|
+
path = _path(item)
|
|
354
|
+
if path.parent != root / "releases" or path.name.startswith("."):
|
|
355
|
+
raise ValueError("retained release is outside the installation")
|
|
356
|
+
_owned_directory(path)
|
|
357
|
+
version, _mode = _read_file(path / "VERSION")
|
|
358
|
+
_text(version.decode().strip())
|
|
359
|
+
return layout
|
|
360
|
+
|
|
361
|
+
|
|
332
362
|
def prepare_runtime(layout: ExecutionLayout) -> None:
|
|
333
363
|
"""Create private IPC state; never rotate an existing control token."""
|
|
334
364
|
from execution_transport import ensure_execution_secret
|
|
@@ -399,6 +429,10 @@ def stage(layout: ExecutionLayout, *, scope: str, prior_services: dict[str, Any]
|
|
|
399
429
|
legacy = layout.install_root / ".activation-transaction"
|
|
400
430
|
if legacy.exists() or legacy.is_symlink():
|
|
401
431
|
raise RuntimeError("legacy activation recovery must finish before execution activation")
|
|
432
|
+
for name in (".execution-lifecycle.json", ".execution-uninstall.json"):
|
|
433
|
+
marker = layout.install_root / name
|
|
434
|
+
if marker.exists() or marker.is_symlink():
|
|
435
|
+
raise RuntimeError("native lifecycle ownership must finish before execution activation")
|
|
402
436
|
if layout.transaction_dir.exists() or layout.transaction_dir.is_symlink():
|
|
403
437
|
raise FileExistsError("an execution activation is already pending")
|
|
404
438
|
prior_layout = _snapshot(layout.manifest_path)
|
|
@@ -674,6 +708,19 @@ def pending_worker_operation(root: Path, runtime_root: Path) -> str | None:
|
|
|
674
708
|
journal = root / TRANSACTION_NAME
|
|
675
709
|
outer = root / ".activation-transaction"
|
|
676
710
|
uninstall = root / ".execution-uninstall.json"
|
|
711
|
+
lifecycle = root / ".execution-lifecycle.json"
|
|
712
|
+
if lifecycle.exists() or lifecycle.is_symlink():
|
|
713
|
+
if any(path.exists() or path.is_symlink() for path in (outer, journal)):
|
|
714
|
+
raise RuntimeError("activation and lifecycle both claim execution ownership")
|
|
715
|
+
from execution_manage import pending_lifecycle_operation
|
|
716
|
+
operation = pending_lifecycle_operation(root, runtime_root)
|
|
717
|
+
if uninstall.exists() or uninstall.is_symlink():
|
|
718
|
+
# An interrupted owned-stop -> uninstall adoption may retain both
|
|
719
|
+
# journals, but they must describe the very same sealed operation.
|
|
720
|
+
from execution_uninstall import pending_uninstall_operation
|
|
721
|
+
if pending_uninstall_operation(root, runtime_root) != operation:
|
|
722
|
+
raise RuntimeError("uninstall and lifecycle ownership conflict")
|
|
723
|
+
return operation
|
|
677
724
|
if uninstall.exists() or uninstall.is_symlink():
|
|
678
725
|
if outer.exists() or outer.is_symlink() or journal.exists() or journal.is_symlink():
|
|
679
726
|
raise RuntimeError("activation and uninstall both claim execution ownership")
|
|
@@ -714,13 +761,7 @@ def active_worker_release(root: Path) -> Path | None:
|
|
|
714
761
|
data, _mode = _read_file(root / LAYOUT_NAME, private=True)
|
|
715
762
|
except FileNotFoundError:
|
|
716
763
|
return None
|
|
717
|
-
|
|
718
|
-
if not isinstance(value, dict) or value.get("format") != FORMAT:
|
|
719
|
-
raise ValueError("installed execution layout is invalid")
|
|
720
|
-
layout = ExecutionLayout.from_dict(value.get("layout"))
|
|
721
|
-
if layout.install_root != root or value != layout_manifest(layout):
|
|
722
|
-
raise ValueError("installed execution layout no longer matches its runtime")
|
|
723
|
-
return layout.worker_release
|
|
764
|
+
return installed_layout(root).worker_release
|
|
724
765
|
|
|
725
766
|
|
|
726
767
|
def main(argv: list[str] | None = None) -> None:
|
|
@@ -12,7 +12,9 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import argparse
|
|
14
14
|
import base64
|
|
15
|
+
from contextlib import ExitStack
|
|
15
16
|
import errno
|
|
17
|
+
import hashlib
|
|
16
18
|
import json
|
|
17
19
|
import os
|
|
18
20
|
from pathlib import Path
|
|
@@ -25,6 +27,7 @@ import sys
|
|
|
25
27
|
import time
|
|
26
28
|
from typing import Any, Callable
|
|
27
29
|
import urllib.parse
|
|
30
|
+
import uuid
|
|
28
31
|
|
|
29
32
|
import execution_install as files
|
|
30
33
|
from execution_transport import _read_secret
|
|
@@ -216,6 +219,35 @@ class NativeServices:
|
|
|
216
219
|
raise RuntimeError("native service is transitioning; retry after it settles")
|
|
217
220
|
return states
|
|
218
221
|
|
|
222
|
+
def validate_registered_bindings(self) -> None:
|
|
223
|
+
"""Reject foreign or stale manager-loaded jobs without exposing output."""
|
|
224
|
+
for role in ("worker", "gateway"):
|
|
225
|
+
path = str(self.layout.service_path(role))
|
|
226
|
+
if self.layout.platform == "Linux":
|
|
227
|
+
result = self._command(["systemctl", "--user", "show", self._unit(role), "--no-pager",
|
|
228
|
+
"--property=FragmentPath,DropInPaths,NeedDaemonReload"])
|
|
229
|
+
values = {}
|
|
230
|
+
for line in result.stdout.splitlines():
|
|
231
|
+
key, separator, value = line.partition("=")
|
|
232
|
+
if not separator or key in values:
|
|
233
|
+
raise RuntimeError("systemd returned ambiguous registered bindings")
|
|
234
|
+
values[key] = value
|
|
235
|
+
if values != {"FragmentPath": path, "DropInPaths": "", "NeedDaemonReload": "no"}:
|
|
236
|
+
raise RuntimeError("native unit registration is foreign, overridden, or stale")
|
|
237
|
+
else:
|
|
238
|
+
result = self._command(["/bin/launchctl", "print", self._target(role)], allow_failure=True)
|
|
239
|
+
if result.returncode:
|
|
240
|
+
if "Could not find service" in result.stderr:
|
|
241
|
+
continue # bootout removed the job; exact plist is checked separately.
|
|
242
|
+
raise RuntimeError("launchd registration ownership is unknown")
|
|
243
|
+
paths = re.findall(r"(?m)^\s*path = (.+)$", result.stdout)
|
|
244
|
+
programs = re.findall(r"(?m)^\s*program = (.+)$", result.stdout)
|
|
245
|
+
arguments = re.findall(r"(?ms)^\s*arguments = \{\n(.*?)^\s*\}", result.stdout)
|
|
246
|
+
expected = files.service_arguments(self.layout, role)
|
|
247
|
+
if (paths != [path] or programs != [expected[0]] or len(arguments) != 1
|
|
248
|
+
or [line.strip() for line in arguments[0].splitlines() if line.strip()] != expected):
|
|
249
|
+
raise RuntimeError("native launchd registration does not match the installed service")
|
|
250
|
+
|
|
219
251
|
def stop(self, role: str) -> None:
|
|
220
252
|
state = self._observe(role)
|
|
221
253
|
if state["state"] in {"absent", "stopped"}:
|
|
@@ -426,6 +458,278 @@ class WorkerControl:
|
|
|
426
458
|
"maintenance_held": worker.get("maintenance_held")}
|
|
427
459
|
|
|
428
460
|
|
|
461
|
+
LIFECYCLE_NAME = ".execution-lifecycle.json"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def read_lifecycle_intent(layout: files.ExecutionLayout) -> dict | None:
|
|
465
|
+
"""Read owned stop/restart evidence; never repair or normalize user state."""
|
|
466
|
+
from execution_preparation import _check_binding
|
|
467
|
+
try:
|
|
468
|
+
data, _mode = files._read_file(layout.install_root / LIFECYCLE_NAME, private=True)
|
|
469
|
+
except FileNotFoundError:
|
|
470
|
+
return None
|
|
471
|
+
value = json.loads(data)
|
|
472
|
+
expected = {"format", "operation_id", "layout_sha256", "identities", "server_identity",
|
|
473
|
+
"worker_pid", "gateway_pid", "worker_instance_id", "lease_id", "phase", "started"}
|
|
474
|
+
if (not isinstance(value, dict) or set(value) != expected or type(value["format"]) is not int
|
|
475
|
+
or value["format"] != 1 or value["phase"] not in {"prepared", "sealed", "stopped", "starting", "releasing"}
|
|
476
|
+
or not isinstance(value["operation_id"], str)
|
|
477
|
+
or str(uuid.UUID(value["operation_id"])) != value["operation_id"]
|
|
478
|
+
or any(type(value[key]) is not int or value[key] <= 0 for key in ("worker_pid", "gateway_pid"))
|
|
479
|
+
or any(not isinstance(value[key], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value[key]) is None
|
|
480
|
+
for key in ("server_identity", "worker_instance_id"))
|
|
481
|
+
or (value["lease_id"] is not None and (not isinstance(value["lease_id"], str)
|
|
482
|
+
or str(uuid.UUID(value["lease_id"])) != value["lease_id"]))):
|
|
483
|
+
raise ValueError("native lifecycle intent is invalid")
|
|
484
|
+
started = value["started"]
|
|
485
|
+
if started is not None and (not isinstance(started, dict) or set(started) != {"pid", "instance_id"}
|
|
486
|
+
or type(started["pid"]) is not int or started["pid"] <= 0
|
|
487
|
+
or not isinstance(started["instance_id"], str)
|
|
488
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", started["instance_id"]) is None):
|
|
489
|
+
raise ValueError("native lifecycle startup identity is invalid")
|
|
490
|
+
if (value["phase"] == "releasing" and started is None
|
|
491
|
+
or value["phase"] not in {"starting", "releasing"} and started is not None
|
|
492
|
+
or value["phase"] != "prepared" and value["lease_id"] is None):
|
|
493
|
+
raise ValueError("native lifecycle phase lacks its admission evidence")
|
|
494
|
+
paths = {"root": layout.install_root, "worker": layout.worker_release,
|
|
495
|
+
"gateway": layout.gateway_release, "config": layout.config_root, "state": layout.state_root}
|
|
496
|
+
if not isinstance(value["identities"], dict) or set(value["identities"]) != set(paths):
|
|
497
|
+
raise ValueError("native lifecycle directory identities are invalid")
|
|
498
|
+
for name, path in paths.items():
|
|
499
|
+
_check_binding(path, value["identities"][name])
|
|
500
|
+
manifest, _mode = files._read_file(layout.manifest_path, private=True)
|
|
501
|
+
if hashlib.sha256(manifest).hexdigest() != value["layout_sha256"]:
|
|
502
|
+
raise RuntimeError("installed layout changed after lifecycle admission")
|
|
503
|
+
return value
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def pending_lifecycle_operation(root: Path, runtime_root: Path) -> str:
|
|
507
|
+
layout = files.installed_layout(root)
|
|
508
|
+
intent = read_lifecycle_intent(layout)
|
|
509
|
+
if intent is None or files._path(runtime_root) != layout.worker_release:
|
|
510
|
+
raise RuntimeError("worker is outside the retained lifecycle operation")
|
|
511
|
+
marker = root / ".execution-uninstall.json"
|
|
512
|
+
if marker.exists() or marker.is_symlink():
|
|
513
|
+
from execution_uninstall import _read_intent
|
|
514
|
+
removal = _read_intent(root, layout)
|
|
515
|
+
if (intent["phase"] != "stopped" or removal is None
|
|
516
|
+
or removal["operation_id"] != intent["operation_id"]
|
|
517
|
+
or removal["server_identity"] != intent["server_identity"]):
|
|
518
|
+
raise RuntimeError("uninstall and lifecycle ownership conflict")
|
|
519
|
+
return intent["operation_id"]
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def retire_lifecycle_intent(layout: files.ExecutionLayout, intent: dict) -> None:
|
|
523
|
+
if read_lifecycle_intent(layout) != intent:
|
|
524
|
+
raise RuntimeError("native lifecycle intent changed before retirement")
|
|
525
|
+
(layout.install_root / LIFECYCLE_NAME).unlink()
|
|
526
|
+
files._fsync_directory(layout.install_root)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class LifecycleController:
|
|
530
|
+
"""Idle-only control of an installed pair, not an installer or force repair.
|
|
531
|
+
|
|
532
|
+
Failures retain an exact operation journal and any sealed admission hold.
|
|
533
|
+
Retry may finish that operation; an unjournaled broken/stopped installation
|
|
534
|
+
or a changed worker epoch is never guessed safe. Explicit start releases
|
|
535
|
+
startup admission only after both components have authenticated health.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
|
|
539
|
+
control: WorkerControl | None = None, health_timeout: float = 30.0):
|
|
540
|
+
self.layout = layout
|
|
541
|
+
self.services = services or NativeServices(layout)
|
|
542
|
+
self.control = control or WorkerControl(services=self.services)
|
|
543
|
+
self.health_timeout = health_timeout
|
|
544
|
+
|
|
545
|
+
def _save(self, intent: dict, **changes) -> dict:
|
|
546
|
+
if read_lifecycle_intent(self.layout) != intent:
|
|
547
|
+
raise RuntimeError("native lifecycle intent changed")
|
|
548
|
+
updated = {**intent, **changes}
|
|
549
|
+
files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(updated))
|
|
550
|
+
return updated
|
|
551
|
+
|
|
552
|
+
def _unchanged(self, originals: dict) -> None:
|
|
553
|
+
from execution_uninstall import _service_files, _no_pending_update
|
|
554
|
+
from update_handoff import _no_activation
|
|
555
|
+
_no_activation(self.layout.install_root)
|
|
556
|
+
marker = self.layout.install_root / ".execution-uninstall.json"
|
|
557
|
+
if marker.exists() or marker.is_symlink():
|
|
558
|
+
raise RuntimeError("uninstall owns this native lifecycle")
|
|
559
|
+
_no_pending_update(self.layout)
|
|
560
|
+
if _service_files(self.layout) != originals:
|
|
561
|
+
raise RuntimeError("native service bindings changed during lifecycle operation")
|
|
562
|
+
self.services.validate_registered_bindings()
|
|
563
|
+
|
|
564
|
+
def _new_intent(self, prior: dict) -> dict:
|
|
565
|
+
from execution_preparation import _binding
|
|
566
|
+
receipt = self.control.receipt(self.layout, self.services)
|
|
567
|
+
record, status = self.control.status(self.layout)
|
|
568
|
+
if type(status.get("native_lifecycle_protocol")) is not int or status["native_lifecycle_protocol"] != 1:
|
|
569
|
+
raise RuntimeError("installed worker does not support safe paired lifecycle commands; update the installed server through its signed managed update first")
|
|
570
|
+
if (receipt.get("worker_pid") != prior["worker"].get("pid")
|
|
571
|
+
or receipt.get("gateway_pid") != prior["gateway"].get("pid")
|
|
572
|
+
or receipt.get("worker_release") != str(self.layout.worker_release)
|
|
573
|
+
or record.get("pid") != receipt.get("worker_pid")
|
|
574
|
+
or record.get("instance_id") != receipt.get("worker_instance_id")
|
|
575
|
+
or status.get("idle") is not True or status.get("lease") is not None):
|
|
576
|
+
raise RuntimeError("execution is busy, changed, or another operation owns admission; no services stopped")
|
|
577
|
+
manifest, _mode = files._read_file(self.layout.manifest_path, private=True)
|
|
578
|
+
intent = {"format": 1, "operation_id": str(uuid.uuid4()), "phase": "prepared", "lease_id": None,
|
|
579
|
+
"started": None, "server_identity": receipt.get("server_identity"),
|
|
580
|
+
"worker_pid": receipt["worker_pid"], "gateway_pid": receipt["gateway_pid"],
|
|
581
|
+
"worker_instance_id": receipt["worker_instance_id"],
|
|
582
|
+
"layout_sha256": hashlib.sha256(manifest).hexdigest(),
|
|
583
|
+
"identities": {name: _binding(path) for name, path in {
|
|
584
|
+
"root": self.layout.install_root, "worker": self.layout.worker_release,
|
|
585
|
+
"gateway": self.layout.gateway_release, "config": self.layout.config_root,
|
|
586
|
+
"state": self.layout.state_root}.items()}}
|
|
587
|
+
if not isinstance(intent["server_identity"], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", intent["server_identity"]) is None:
|
|
588
|
+
raise RuntimeError("authenticated server identity is unavailable")
|
|
589
|
+
if self.services.snapshot() != prior or read_lifecycle_intent(self.layout) is not None:
|
|
590
|
+
raise RuntimeError("native ownership changed before lifecycle admission")
|
|
591
|
+
files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(intent))
|
|
592
|
+
return intent
|
|
593
|
+
|
|
594
|
+
def _stop(self, intent: dict, originals: dict) -> dict:
|
|
595
|
+
from execution_uninstall import _owned_lease, _no_provider_children
|
|
596
|
+
if intent["phase"] in {"starting", "releasing"}:
|
|
597
|
+
raise RuntimeError("an owned start must finish before another stop")
|
|
598
|
+
self._unchanged(originals)
|
|
599
|
+
prior = self.services.snapshot()
|
|
600
|
+
if any(prior[role].get("state") not in {"running", "stopped", "absent"} for role in ("worker", "gateway")):
|
|
601
|
+
raise RuntimeError("native lifecycle state is uncertain")
|
|
602
|
+
if prior["worker"].get("state") == "running":
|
|
603
|
+
record, status = self.control.status(self.layout)
|
|
604
|
+
if (record.get("pid") != intent["worker_pid"] or prior["worker"].get("pid") != intent["worker_pid"]
|
|
605
|
+
or record.get("instance_id") != intent["worker_instance_id"]
|
|
606
|
+
or record.get("release_root") != str(self.layout.worker_release)):
|
|
607
|
+
raise RuntimeError("worker epoch changed after lifecycle admission")
|
|
608
|
+
if intent["lease_id"] is not None and (status.get("lease") or {}).get("lease_id") != intent["lease_id"]:
|
|
609
|
+
raise RuntimeError("owned lifecycle admission changed")
|
|
610
|
+
try:
|
|
611
|
+
sealed = self.control.seal_for_stop(self.layout, intent["operation_id"], intent["worker_pid"])
|
|
612
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
613
|
+
raise RuntimeError("lifecycle admission was not confirmed; its owned retry evidence was retained and no service was stopped by this attempt; retry the same command when work is idle (a changed worker requires ownership recovery)") from error
|
|
614
|
+
if sealed["record"] != record:
|
|
615
|
+
raise RuntimeError("worker changed while lifecycle admission was sealed")
|
|
616
|
+
intent = self._save(intent, phase="sealed", lease_id=sealed["lease"]["lease_id"])
|
|
617
|
+
self._unchanged(originals)
|
|
618
|
+
if self.services.snapshot() != prior:
|
|
619
|
+
raise RuntimeError("native jobs changed after lifecycle admission")
|
|
620
|
+
for role in ("gateway", "worker"):
|
|
621
|
+
current = self.services.snapshot()[role]
|
|
622
|
+
if current.get("state") in {"stopped", "absent"}:
|
|
623
|
+
self.services.set_enabled(role, False)
|
|
624
|
+
continue
|
|
625
|
+
if current.get("state") != "running" or current.get("pid") != intent[role + "_pid"]:
|
|
626
|
+
raise RuntimeError("native process changed before its authorized stop")
|
|
627
|
+
if role == "worker":
|
|
628
|
+
now_record, now_status = self.control.status(self.layout)
|
|
629
|
+
if (now_record != sealed["record"] or now_status.get("lease") != sealed["lease"]
|
|
630
|
+
or now_status.get("idle") is not True):
|
|
631
|
+
raise RuntimeError("worker admission changed before its authorized stop")
|
|
632
|
+
self._unchanged(originals)
|
|
633
|
+
self.services.set_enabled(role, False)
|
|
634
|
+
self.services.stop(role)
|
|
635
|
+
elif intent["lease_id"] is None or prior["gateway"].get("state") == "running":
|
|
636
|
+
raise RuntimeError("stopped worker lacks an owned completed shutdown")
|
|
637
|
+
else:
|
|
638
|
+
for role in ("gateway", "worker"):
|
|
639
|
+
self.services.set_enabled(role, False)
|
|
640
|
+
if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
|
|
641
|
+
raise RuntimeError("both native services must stop before lifecycle completion")
|
|
642
|
+
with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
|
|
643
|
+
_no_provider_children(self.layout.state_root)
|
|
644
|
+
self._unchanged(originals)
|
|
645
|
+
return self._save(intent, phase="stopped")
|
|
646
|
+
|
|
647
|
+
def _wait_worker(self, intent: dict) -> dict:
|
|
648
|
+
deadline = time.monotonic() + self.health_timeout
|
|
649
|
+
while True:
|
|
650
|
+
try:
|
|
651
|
+
record, status = self.control.status(self.layout)
|
|
652
|
+
native = self.services.snapshot()["worker"]
|
|
653
|
+
if (native.get("state") != "running" or native.get("pid") != record.get("pid")
|
|
654
|
+
or record.get("release_root") != str(self.layout.worker_release)):
|
|
655
|
+
raise RuntimeError("native worker does not own startup admission")
|
|
656
|
+
started = {"pid": record["pid"], "instance_id": record["instance_id"]}
|
|
657
|
+
if intent["started"] is not None and started != intent["started"]:
|
|
658
|
+
raise RuntimeError("worker epoch changed during lifecycle startup")
|
|
659
|
+
if intent["phase"] != "releasing" or status.get("lease") is not None:
|
|
660
|
+
self.control.require_startup_hold(self.layout, intent["operation_id"])
|
|
661
|
+
return started
|
|
662
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
663
|
+
if time.monotonic() >= deadline:
|
|
664
|
+
raise RuntimeError("owned native worker did not reach held startup; retry start after recovery") from error
|
|
665
|
+
time.sleep(0.1)
|
|
666
|
+
|
|
667
|
+
def _start(self, intent: dict, originals: dict) -> dict:
|
|
668
|
+
from execution_uninstall import _owned_lease, _no_provider_children
|
|
669
|
+
if intent["phase"] == "stopped":
|
|
670
|
+
with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
|
|
671
|
+
_no_provider_children(self.layout.state_root)
|
|
672
|
+
self._unchanged(originals)
|
|
673
|
+
if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
|
|
674
|
+
raise RuntimeError("stopped native jobs changed before authorized start")
|
|
675
|
+
intent = self._save(intent, phase="starting")
|
|
676
|
+
if intent["phase"] not in {"starting", "releasing"}:
|
|
677
|
+
raise RuntimeError("native shutdown must complete before starting")
|
|
678
|
+
self._unchanged(originals)
|
|
679
|
+
native = self.services.snapshot()
|
|
680
|
+
if native["worker"].get("state") in {"stopped", "absent"}:
|
|
681
|
+
if intent["started"] is not None or native["gateway"].get("state") == "running":
|
|
682
|
+
raise RuntimeError("owned startup process disappeared; automatic replacement is refused")
|
|
683
|
+
self.services.start("worker")
|
|
684
|
+
started = self._wait_worker(intent)
|
|
685
|
+
if intent["started"] is None:
|
|
686
|
+
intent = self._save(intent, started=started)
|
|
687
|
+
self._unchanged(originals)
|
|
688
|
+
gateway = self.services.snapshot()["gateway"]
|
|
689
|
+
if gateway.get("state") in {"stopped", "absent"}:
|
|
690
|
+
self.services.start("gateway")
|
|
691
|
+
receipt = ActivationController(self.layout, services=self.services, control=self.control,
|
|
692
|
+
health_timeout=self.health_timeout)._wait_receipt(self.layout)
|
|
693
|
+
if (receipt.get("server_identity") != intent["server_identity"]
|
|
694
|
+
or receipt.get("worker_pid") != intent["started"]["pid"]
|
|
695
|
+
or receipt.get("worker_instance_id") != intent["started"]["instance_id"]):
|
|
696
|
+
raise RuntimeError("authenticated lifecycle identity changed")
|
|
697
|
+
self._wait_worker(intent)
|
|
698
|
+
self._unchanged(originals)
|
|
699
|
+
intent = self._save(intent, phase="releasing")
|
|
700
|
+
self.control.release(self.layout, intent["operation_id"])
|
|
701
|
+
retire_lifecycle_intent(self.layout, intent)
|
|
702
|
+
return {"ok": True, "status": "running", "receipt": receipt}
|
|
703
|
+
|
|
704
|
+
def run(self, action: str) -> dict:
|
|
705
|
+
if action not in {"start", "stop", "restart"}:
|
|
706
|
+
raise ValueError("unknown native lifecycle action")
|
|
707
|
+
from execution_uninstall import _service_files, _hold_preparation_leases
|
|
708
|
+
from execution_recovery import require_retired_owners
|
|
709
|
+
with InstallationLock(self.layout.install_root), ExitStack() as leases:
|
|
710
|
+
if files.installed_layout(self.layout.install_root) != self.layout:
|
|
711
|
+
raise RuntimeError("native lifecycle layout changed")
|
|
712
|
+
originals = _service_files(self.layout)
|
|
713
|
+
self._unchanged(originals)
|
|
714
|
+
require_retired_owners(self.layout.install_root)
|
|
715
|
+
_hold_preparation_leases(self.layout.install_root, leases)
|
|
716
|
+
intent = read_lifecycle_intent(self.layout)
|
|
717
|
+
if intent is None:
|
|
718
|
+
prior = self.services.snapshot()
|
|
719
|
+
if any(prior[role].get("state") != "running" for role in ("worker", "gateway")):
|
|
720
|
+
raise RuntimeError("unjournaled stopped or broken services require ownership recovery")
|
|
721
|
+
if action == "start":
|
|
722
|
+
return {"ok": True, "status": "running", "receipt": self.control.receipt(self.layout, self.services)}
|
|
723
|
+
intent = self._new_intent(prior)
|
|
724
|
+
if intent["phase"] not in {"starting", "releasing"}:
|
|
725
|
+
intent = self._stop(intent, originals)
|
|
726
|
+
if action == "stop":
|
|
727
|
+
if intent["phase"] != "stopped":
|
|
728
|
+
raise RuntimeError("retry start to finish the pending owned startup")
|
|
729
|
+
return {"ok": True, "status": "stopped"}
|
|
730
|
+
return self._start(intent, originals)
|
|
731
|
+
|
|
732
|
+
|
|
429
733
|
class ActivationController:
|
|
430
734
|
def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
|
|
431
735
|
control: WorkerControl | None = None, health_timeout: float = 30.0) -> None:
|
|
@@ -107,7 +107,7 @@ def _source_inventory(source: Path) -> dict[str, str]:
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
def _conflicts(root: Path) -> None:
|
|
110
|
-
for name in (".execution-transaction", ".execution-uninstall.json"):
|
|
110
|
+
for name in (".execution-transaction", ".execution-uninstall.json", ".execution-lifecycle.json"):
|
|
111
111
|
if (root / name).exists() or (root / name).is_symlink():
|
|
112
112
|
raise RuntimeError("another lifecycle operation owns this installation")
|
|
113
113
|
|
|
@@ -205,6 +205,7 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
|
|
|
205
205
|
native = services or NativeServices(layout)
|
|
206
206
|
control = control or WorkerControl()
|
|
207
207
|
originals = _service_files(layout)
|
|
208
|
+
native.validate_registered_bindings()
|
|
208
209
|
_no_pending_update(layout)
|
|
209
210
|
_hold_preparation_leases(root, leases)
|
|
210
211
|
_removable_tree(root)
|
|
@@ -216,6 +217,24 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
|
|
|
216
217
|
if intent is not None and intent["purge_state"] != purge_state:
|
|
217
218
|
raise RuntimeError("retry must preserve the existing uninstall data-removal choice")
|
|
218
219
|
prior = native.snapshot()
|
|
220
|
+
from execution_manage import read_lifecycle_intent, retire_lifecycle_intent
|
|
221
|
+
lifecycle = read_lifecycle_intent(layout)
|
|
222
|
+
if lifecycle is not None:
|
|
223
|
+
# Adopt only a proven completed owned stop. Never start a worker
|
|
224
|
+
# merely to remove it: that could run saved scheduled/queued work.
|
|
225
|
+
if (lifecycle["phase"] != "stopped"
|
|
226
|
+
or any(prior[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway"))):
|
|
227
|
+
raise RuntimeError("finish the owned lifecycle stop before uninstalling")
|
|
228
|
+
native.validate_registered_bindings()
|
|
229
|
+
with _owned_lease(state_root / "admin/state-owner.lock"):
|
|
230
|
+
_no_provider_children(state_root)
|
|
231
|
+
if intent is None:
|
|
232
|
+
intent = _write_intent(root, layout, lifecycle["operation_id"], purge_state,
|
|
233
|
+
lifecycle["server_identity"])
|
|
234
|
+
elif (intent["operation_id"] != lifecycle["operation_id"]
|
|
235
|
+
or intent["server_identity"] != lifecycle["server_identity"]):
|
|
236
|
+
raise RuntimeError("uninstall and lifecycle journal ownership conflict")
|
|
237
|
+
retire_lifecycle_intent(layout, lifecycle)
|
|
219
238
|
# An interrupted graceful stop can precede the stopped-phase write.
|
|
220
239
|
# The exact intent, empty child registry and exclusive state lease
|
|
221
240
|
# below supply the missing proof; an unjournaled stopped install fails.
|
|
@@ -6,7 +6,6 @@ activation remains incomplete even after it has published the new files.
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import json
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
from typing import Any
|
|
12
11
|
|
|
@@ -22,17 +21,11 @@ def current_components(
|
|
|
22
21
|
"""Read-only proof, invoked off the server event loop for an equal-version ensure."""
|
|
23
22
|
root = files._path(root)
|
|
24
23
|
files._owned_directory(root)
|
|
25
|
-
for name in (".activation-transaction", ".execution-transaction"):
|
|
24
|
+
for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json"):
|
|
26
25
|
journal = root / name
|
|
27
26
|
if journal.exists() or journal.is_symlink():
|
|
28
27
|
return False
|
|
29
|
-
|
|
30
|
-
document = json.loads(data)
|
|
31
|
-
if not isinstance(document, dict) or document.get("format") != files.FORMAT:
|
|
32
|
-
raise ValueError("installed execution layout is invalid")
|
|
33
|
-
layout = files.ExecutionLayout.from_dict(document.get("layout"))
|
|
34
|
-
if layout.install_root != root or document != files.layout_manifest(layout):
|
|
35
|
-
raise ValueError("installed execution layout does not match its runtime")
|
|
28
|
+
layout = files.installed_layout(root)
|
|
36
29
|
receipt: dict[str, Any] = WorkerControl().receipt(layout, NativeServices(layout))
|
|
37
30
|
if (receipt.get("server_identity") != expected_server_identity
|
|
38
31
|
or receipt.get("worker_instance_id") != expected_worker_instance):
|
|
@@ -45,5 +38,5 @@ def current_components(
|
|
|
45
38
|
# Do not claim completion over the operation that just acquired ownership.
|
|
46
39
|
return complete and not any(
|
|
47
40
|
(root / name).exists() or (root / name).is_symlink()
|
|
48
|
-
for name in (".activation-transaction", ".execution-transaction")
|
|
41
|
+
for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json")
|
|
49
42
|
)
|
|
@@ -156,6 +156,11 @@ def opencode_error_message(error: Any) -> str:
|
|
|
156
156
|
if not message:
|
|
157
157
|
message = str(error.get("message") or "")
|
|
158
158
|
name = str(error.get("name") or "")
|
|
159
|
+
status = data.get("statusCode") if isinstance(data, dict) else None
|
|
160
|
+
if type(status) is int and 100 <= status <= 599:
|
|
161
|
+
# Preserve useful HTTP diagnostics without exposing request headers,
|
|
162
|
+
# gateway response bodies or URLs that may contain credentials.
|
|
163
|
+
name = f"{name} (HTTP {status})" if name else f"HTTP {status}"
|
|
159
164
|
if message and name:
|
|
160
165
|
return f"{name}: {message}"
|
|
161
166
|
return message or name
|
|
@@ -22,9 +22,12 @@ import httpx
|
|
|
22
22
|
from fastapi import APIRouter, HTTPException, Request
|
|
23
23
|
from fastapi.responses import JSONResponse
|
|
24
24
|
|
|
25
|
-
from codex_provider import validate_selection, validate_model, endpoint_model_catalog
|
|
25
|
+
from codex_provider import validate_selection, validate_model, endpoint_model_catalog, endpoint_api_base_url
|
|
26
26
|
|
|
27
27
|
MAX_BODY_BYTES = 16 * 1024
|
|
28
|
+
# Reasoning can consume output tokens before the short visible check answer.
|
|
29
|
+
# Keep this explicit probe bounded and single-shot, including for such models.
|
|
30
|
+
MODEL_CHECK_MAX_OUTPUT_TOKENS = 256
|
|
28
31
|
PROTOCOLS = {"claude": {"anthropic"}, "opencode": {"anthropic", "chat_completions", "responses"}, "cursor": {"cursor"}}
|
|
29
32
|
RESULTS = {"verified", "authentication_failed", "rate_limited", "unsupported", "connection_failed", "invalid_response", "model_required", "cli_update_required"}
|
|
30
33
|
|
|
@@ -277,7 +280,7 @@ class ConnectionStore:
|
|
|
277
280
|
model = require_model(value)
|
|
278
281
|
config = json.loads(env.get("OPENCODE_CONFIG_CONTENT") or "{}")
|
|
279
282
|
adapter = {"anthropic": "@ai-sdk/anthropic", "chat_completions": "@ai-sdk/openai-compatible", "responses": "@ai-sdk/openai"}[value["protocol"]]
|
|
280
|
-
options = {"baseURL": value["base_url"], "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
|
|
283
|
+
options = {"baseURL": endpoint_api_base_url(value["base_url"], value["protocol"]), "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
|
|
281
284
|
if value["protocol"] == "anthropic" and value["auth_header"] == "bearer":
|
|
282
285
|
options["headers"] = {"Authorization": "Bearer {env:AGENTSDOCK_CUSTOM_API_KEY}", "x-api-key": ""}
|
|
283
286
|
config.setdefault("provider", {})["agentsdock_custom"] = {"npm": adapter, "name": "Custom endpoint", "options": options, "models": {model: {"name": model}}}
|
|
@@ -361,8 +364,7 @@ async def probe_credentials(selected: dict) -> str:
|
|
|
361
364
|
headers["Authorization"] = "Bearer " + selected["api_key"]
|
|
362
365
|
url = urlsplit(base)
|
|
363
366
|
router = url.scheme == "https" and url.netloc == "openrouter.ai" and url.path in {"/api", "/api/v1"}
|
|
364
|
-
target = "https://openrouter.ai/api/v1/key" if router else base +
|
|
365
|
-
"/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
|
|
367
|
+
target = "https://openrouter.ai/api/v1/key" if router else endpoint_api_base_url(base, protocol) + "/models"
|
|
366
368
|
async def send():
|
|
367
369
|
async with httpx.AsyncClient(timeout=10, follow_redirects=False, trust_env=False) as client:
|
|
368
370
|
async with client.stream("GET", target, headers=headers) as response:
|
|
@@ -403,18 +405,18 @@ async def probe(selected: dict) -> str:
|
|
|
403
405
|
headers["x-api-key"] = selected["api_key"]
|
|
404
406
|
else:
|
|
405
407
|
headers["Authorization"] = "Bearer " + selected["api_key"]
|
|
406
|
-
base = selected["base_url"]
|
|
408
|
+
base = endpoint_api_base_url(selected["base_url"], protocol)
|
|
407
409
|
prompt = "Connection check. Reply with OK only."
|
|
408
410
|
if protocol == "anthropic":
|
|
409
|
-
suffix = "/messages"
|
|
411
|
+
suffix = "/messages"
|
|
410
412
|
headers["anthropic-version"] = "2023-06-01"
|
|
411
|
-
body = {"model": selected["model"], "max_tokens":
|
|
413
|
+
body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
|
|
412
414
|
elif protocol == "responses":
|
|
413
415
|
suffix = "/responses"
|
|
414
|
-
body = {"model": selected["model"], "max_output_tokens":
|
|
416
|
+
body = {"model": selected["model"], "max_output_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "input": prompt, "store": False}
|
|
415
417
|
else:
|
|
416
418
|
suffix = "/chat/completions"
|
|
417
|
-
body = {"model": selected["model"], "max_tokens":
|
|
419
|
+
body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
|
|
418
420
|
async def send():
|
|
419
421
|
async with httpx.AsyncClient(timeout=15, follow_redirects=False, trust_env=False) as client:
|
|
420
422
|
async with client.stream("POST", base + suffix, headers=headers, json=body) as response:
|
|
@@ -600,7 +602,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
|
|
|
600
602
|
current = store.read(backend)
|
|
601
603
|
if current["revision"] != revision(payload["expected_revision"]):
|
|
602
604
|
raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
|
|
603
|
-
if current.get("
|
|
605
|
+
if not current.get("configured"):
|
|
606
|
+
raise HTTPException(409, "Connect a custom API first.")
|
|
607
|
+
# A saved, unverified connection can choose its first model.
|
|
608
|
+
# This write does not verify it or make it usable by chats;
|
|
609
|
+
# the explicit /check operation must still succeed.
|
|
604
610
|
value = {key: current[key] for key in ("base_url", "api_key", "protocol", "auth_header")}
|
|
605
611
|
saved = store.write(backend, current["revision"], {**value, "model": model, "expected_revision": current["revision"]}, current["last_result"], checked_at=current["checked_at"])
|
|
606
612
|
cached = store.catalog_cache.get((backend, current["revision"]))
|
|
@@ -651,7 +657,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
|
|
|
651
657
|
result = "cli_update_required" if discovered.get("cli_update_required") else "verified" if discovered["discovery_status"] == "ready" and discovered["models"] else "connection_failed"
|
|
652
658
|
else:
|
|
653
659
|
result = await check(selected)
|
|
654
|
-
|
|
660
|
+
# Public catalogs cannot authenticate a key. Keep a private,
|
|
661
|
+
# explicitly unverified setup so the client can list models
|
|
662
|
+
# before choosing one for a subsequent explicit check.
|
|
663
|
+
pending_model = result == "model_required" and not selected.get("model") and backend != "cursor"
|
|
664
|
+
if request.method == "PUT" and result != "verified" and not pending_model:
|
|
655
665
|
return reply({"ok": False, "status": result})
|
|
656
666
|
configuration = await asyncio.to_thread(store.write, backend, expected, selected, result)
|
|
657
667
|
if backend == "cursor" and result == "verified":
|
|
@@ -138,6 +138,26 @@ def read_config(instance: Instance) -> dict[str, str]:
|
|
|
138
138
|
return result
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
def split_layout(instance: Instance, platform: str = sys.platform):
|
|
142
|
+
"""Default-only split detection; invalid evidence never falls back to legacy."""
|
|
143
|
+
if instance.name != "default":
|
|
144
|
+
return None
|
|
145
|
+
marker = instance.runtime / "execution-layout.json"
|
|
146
|
+
if not marker.exists() and not marker.is_symlink():
|
|
147
|
+
return None
|
|
148
|
+
import execution_install as files
|
|
149
|
+
from execution_uninstall import _service_files
|
|
150
|
+
layout = files.installed_layout(instance.runtime)
|
|
151
|
+
expected_platform = "Darwin" if platform == "darwin" else "Linux" if platform.startswith("linux") else None
|
|
152
|
+
if ((layout.install_root, layout.config_root, layout.state_root, layout.home, layout.platform)
|
|
153
|
+
!= (instance.runtime, instance.config, instance.state, instance.home, expected_platform)):
|
|
154
|
+
raise ValueError("Default execution roots/platform do not match this instance.")
|
|
155
|
+
for role in ("worker", "gateway"):
|
|
156
|
+
check_path(layout.service_path(role), instance.home)
|
|
157
|
+
_service_files(layout)
|
|
158
|
+
return layout
|
|
159
|
+
|
|
160
|
+
|
|
141
161
|
def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
|
|
142
162
|
for path in (instance.runtime, instance.config, instance.state, instance.logs, instance.service_file(platform)):
|
|
143
163
|
check_path(path, instance.home)
|
|
@@ -145,6 +165,8 @@ def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
|
|
|
145
165
|
for key, value in instance.environment().items():
|
|
146
166
|
if key in env and env[key] != value:
|
|
147
167
|
raise ValueError(f"{instance.name}: {key} does not match this instance; refusing service changes.")
|
|
168
|
+
if split_layout(instance, platform) is not None:
|
|
169
|
+
return
|
|
148
170
|
service = instance.service_file(platform)
|
|
149
171
|
if service.exists():
|
|
150
172
|
data = read_regular(service)
|
|
@@ -370,6 +392,22 @@ def run(command: list[str], **kwargs):
|
|
|
370
392
|
|
|
371
393
|
|
|
372
394
|
def service_status(instance: Instance, platform: str = sys.platform) -> str:
|
|
395
|
+
try:
|
|
396
|
+
layout = split_layout(instance, platform)
|
|
397
|
+
if layout is not None:
|
|
398
|
+
from execution_manage import NativeServices, read_lifecycle_intent
|
|
399
|
+
native = NativeServices(layout)
|
|
400
|
+
native.validate_registered_bindings()
|
|
401
|
+
snapshot = native.snapshot()
|
|
402
|
+
states = {item["state"] for item in snapshot.values()}
|
|
403
|
+
intent = read_lifecycle_intent(layout)
|
|
404
|
+
if states <= {"absent", "stopped"}:
|
|
405
|
+
return "stopped"
|
|
406
|
+
if states == {"running"}:
|
|
407
|
+
return "pending" if intent is not None else "running"
|
|
408
|
+
return "partial"
|
|
409
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
410
|
+
return "unknown"
|
|
373
411
|
command = ["launchctl", "print", f"gui/{os.getuid()}/{launchd_label(instance.name)}"] if platform == "darwin" else ["systemctl", "--user", "show", service_name(instance.name) + ".service", "--property=ActiveState", "--value"]
|
|
374
412
|
try:
|
|
375
413
|
result = subprocess.run(command, capture_output=True, text=True, timeout=3, check=False)
|
|
@@ -863,6 +901,11 @@ def release_uninstalled_name(instance: Instance, registry: Registry) -> None:
|
|
|
863
901
|
|
|
864
902
|
def control(instance: Instance, action: str, platform: str = sys.platform):
|
|
865
903
|
validate_binding(instance, platform)
|
|
904
|
+
layout = split_layout(instance, platform)
|
|
905
|
+
if layout is not None:
|
|
906
|
+
from execution_manage import LifecycleController
|
|
907
|
+
LifecycleController(layout).run(action)
|
|
908
|
+
return
|
|
866
909
|
if not instance.service_file(platform).exists():
|
|
867
910
|
raise ValueError(f"{instance.name}: no installed service.")
|
|
868
911
|
if platform == "darwin":
|
|
@@ -1080,13 +1123,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1080
1123
|
control(item, args.command)
|
|
1081
1124
|
if args.command != "remove":
|
|
1082
1125
|
print(f"{item.name}: {args.command} completed", flush=True)
|
|
1083
|
-
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as exc:
|
|
1126
|
+
except (OSError, ValueError, RuntimeError, KeyError, subprocess.CalledProcessError) as exc:
|
|
1084
1127
|
print(f"{item.name}: failed ({exc})", file=sys.stderr)
|
|
1085
1128
|
failures += 1
|
|
1086
1129
|
if args.command == "remove" and not failures:
|
|
1087
1130
|
print("\n" + terminal_color("Successful!", "32"), flush=True)
|
|
1088
1131
|
return int(bool(failures))
|
|
1089
|
-
except (OSError, ValueError, KeyError) as exc:
|
|
1132
|
+
except (OSError, ValueError, RuntimeError, KeyError) as exc:
|
|
1090
1133
|
print(f"Instance manager: {exc}", file=sys.stderr)
|
|
1091
1134
|
return 1
|
|
1092
1135
|
|
package/server/update_handoff.py
CHANGED
|
@@ -30,9 +30,11 @@ def _no_activation(root: Path) -> None:
|
|
|
30
30
|
|
|
31
31
|
def _no_handoff_recovery_overlap(root: Path) -> None:
|
|
32
32
|
_no_activation(root)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
for name in (".execution-uninstall.json", ".execution-lifecycle.json"):
|
|
34
|
+
marker = root / name
|
|
35
|
+
if marker.exists() or marker.is_symlink():
|
|
36
|
+
owner = "uninstall" if name == ".execution-uninstall.json" else "native lifecycle"
|
|
37
|
+
raise RuntimeError(owner + " already owns execution recovery")
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
def _read_handoff(root: Path, path: Path) -> dict[str, Any]:
|
|
@@ -68,14 +70,7 @@ def _read_handoff(root: Path, path: Path) -> dict[str, Any]:
|
|
|
68
70
|
|
|
69
71
|
|
|
70
72
|
def _installed_layout(root: Path) -> files.ExecutionLayout:
|
|
71
|
-
|
|
72
|
-
value = json.loads(data)
|
|
73
|
-
if not isinstance(value, dict):
|
|
74
|
-
raise ValueError("installed execution layout is invalid")
|
|
75
|
-
layout = files.ExecutionLayout.from_dict(value.get("layout"))
|
|
76
|
-
if layout.install_root != root or value != files.layout_manifest(layout):
|
|
77
|
-
raise ValueError("installed execution layout changed")
|
|
78
|
-
return layout
|
|
73
|
+
return files.installed_layout(root)
|
|
79
74
|
|
|
80
75
|
|
|
81
76
|
def _matching_status(handoff: dict, layout: files.ExecutionLayout, record: dict,
|