agentgov-cli 0.1.0__tar.gz → 0.1.3__tar.gz
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.
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/PKG-INFO +1 -1
- agentgov_cli-0.1.3/agentgov_cli/commands/gateway.py +79 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/commands/install.py +43 -5
- agentgov_cli-0.1.3/agentgov_cli/commands/login.py +159 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/commands/register_device.py +28 -9
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/commands/wrap.py +58 -0
- agentgov_cli-0.1.3/agentgov_cli/gateway_process.py +261 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/main.py +2 -1
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/pyproject.toml +17 -1
- agentgov_cli-0.1.0/agentgov_cli/commands/login.py +0 -74
- agentgov_cli-0.1.0/tests/test_pathguard.py +0 -84
- agentgov_cli-0.1.0/uv.lock +0 -886
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/.gitignore +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/README.md +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/commands/doctor.py +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/commands/status.py +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/commands/workitem.py +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.0 → agentgov_cli-0.1.3}/agentgov_cli/port_resolver.py +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`agentgov gateway` — manage the local gateway process.
|
|
2
|
+
|
|
3
|
+
`install` is the recommended path: a supervised service (systemd --user on
|
|
4
|
+
Linux/WSL, launchd on macOS) that starts at login and restarts on failure, so
|
|
5
|
+
the port Claude Code targets is bound whenever the developer is working.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from .. import gateway_process as gp
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="gateway", help="Manage the local AgentGov gateway process.")
|
|
18
|
+
|
|
19
|
+
_GATEWAY_OPT = typer.Option(None, "--gateway", envvar="AGENTGOV_GATEWAY_URL")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _url(gateway_url: str | None) -> str:
|
|
23
|
+
return gateway_url or "http://localhost:8000"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("install")
|
|
27
|
+
def install(gateway_url: str = _GATEWAY_OPT) -> None:
|
|
28
|
+
"""Install + start the gateway as a supervised background service."""
|
|
29
|
+
url = _url(gateway_url)
|
|
30
|
+
ok, detail = gp.install_service()
|
|
31
|
+
if not ok:
|
|
32
|
+
console.print(f"[yellow]Could not install a service:[/] {detail}")
|
|
33
|
+
console.print("Falling back to a detached one-off start.")
|
|
34
|
+
ok, detail = gp.ensure_running(url)
|
|
35
|
+
if not ok:
|
|
36
|
+
console.print(f"[red]Gateway did not start.[/] {detail}")
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
console.print(f"[green]Gateway running[/] at {url} ({detail})")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
console.print(f"[green]{detail}[/]")
|
|
42
|
+
if gp.is_healthy(url, timeout_sec=10.0):
|
|
43
|
+
console.print(f"[green]Gateway healthy[/] at {url}")
|
|
44
|
+
else:
|
|
45
|
+
# The service manager accepted the unit but the process is not
|
|
46
|
+
# answering — surface the log rather than claiming success.
|
|
47
|
+
console.print(f"[yellow]Service installed but {url}/health did not answer yet.[/]")
|
|
48
|
+
console.print(gp.log_tail())
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("start")
|
|
53
|
+
def start(gateway_url: str = _GATEWAY_OPT) -> None:
|
|
54
|
+
"""Start the gateway now, detached from this terminal."""
|
|
55
|
+
url = _url(gateway_url)
|
|
56
|
+
ok, detail = gp.ensure_running(url)
|
|
57
|
+
if not ok:
|
|
58
|
+
console.print(f"[red]Gateway did not start.[/] {detail}")
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
console.print(f"[green]Gateway running[/] at {url} ({detail})")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("status")
|
|
64
|
+
def status(gateway_url: str = _GATEWAY_OPT) -> None:
|
|
65
|
+
"""Report whether the gateway is answering."""
|
|
66
|
+
url = _url(gateway_url)
|
|
67
|
+
if gp.is_healthy(url):
|
|
68
|
+
console.print(f"[green]RUNNING[/] — {url}/health answered.")
|
|
69
|
+
return
|
|
70
|
+
console.print(f"[red]NOT RUNNING[/] — nothing answering at {url}/health.")
|
|
71
|
+
console.print("Start it with: [cyan]agentgov gateway install[/]")
|
|
72
|
+
console.print(gp.log_tail())
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("logs")
|
|
77
|
+
def logs(lines: int = typer.Option(40, "--lines", "-n")) -> None:
|
|
78
|
+
"""Show the tail of the gateway log."""
|
|
79
|
+
console.print(gp.log_tail(lines))
|
|
@@ -14,6 +14,7 @@ import shutil
|
|
|
14
14
|
import sys
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
import typer
|
|
17
18
|
from rich.console import Console
|
|
18
19
|
|
|
19
20
|
console = Console()
|
|
@@ -28,10 +29,22 @@ ASSET_ROOTS = [
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def run(
|
|
31
|
-
gateway_url: str =
|
|
32
|
-
|
|
32
|
+
gateway_url: str = typer.Option(
|
|
33
|
+
"http://localhost:8000",
|
|
34
|
+
"--gateway-url",
|
|
35
|
+
"--gateway", # alias — earlier docs called it --gateway
|
|
36
|
+
envvar="AGENTGOV_GATEWAY_URL",
|
|
37
|
+
help="URL of the local AgentGov gateway (usually http://localhost:8000).",
|
|
38
|
+
),
|
|
39
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing hooks/statusline."),
|
|
33
40
|
) -> None:
|
|
34
|
-
"""Install hooks + statusline + slash commands into ~/.claude/.
|
|
41
|
+
"""Install hooks + statusline + slash commands into ~/.claude/.
|
|
42
|
+
|
|
43
|
+
Also writes a ready-to-copy managed-settings.json to
|
|
44
|
+
~/.agentgov/managed-settings.json — save that file at the OS-managed path
|
|
45
|
+
(needs sudo/admin) to force ALL Claude Code processes on this machine —
|
|
46
|
+
terminal AND VS Code extension — through the gateway.
|
|
47
|
+
"""
|
|
35
48
|
src = _find_assets()
|
|
36
49
|
dest = Path.home() / ".claude"
|
|
37
50
|
dest.mkdir(exist_ok=True)
|
|
@@ -62,9 +75,34 @@ def run(
|
|
|
62
75
|
console.print(f"[green]Copied[/] /workitem slash command → {commands_dest / 'workitem.md'}")
|
|
63
76
|
|
|
64
77
|
settings = _build_managed_settings(gateway_url, hooks_dest, statusline_dest)
|
|
78
|
+
settings_json = json.dumps(settings, indent=2)
|
|
79
|
+
|
|
80
|
+
# Write to a stable local path so the admin can `sudo cp` it into place
|
|
81
|
+
# without having to redirect stdout carefully.
|
|
82
|
+
local_settings_path = Path.home() / ".agentgov" / "managed-settings.json"
|
|
83
|
+
local_settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
local_settings_path.write_text(settings_json + "\n")
|
|
85
|
+
local_settings_path.chmod(0o644)
|
|
86
|
+
|
|
87
|
+
target_path = _managed_settings_path()
|
|
65
88
|
console.rule("[bold]managed-settings.json[/]")
|
|
66
|
-
console.print(f"
|
|
67
|
-
console.print(
|
|
89
|
+
console.print(f"Written locally to: [cyan]{local_settings_path}[/]")
|
|
90
|
+
console.print(f"Target install path: [cyan]{target_path}[/]\n")
|
|
91
|
+
console.print("[bold]To govern the VS Code extension too, run:[/]")
|
|
92
|
+
if platform.system() == "Windows":
|
|
93
|
+
console.print(
|
|
94
|
+
f' Copy-Item "{local_settings_path}" "{target_path}" -Force'
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
parent = str(Path(target_path).parent)
|
|
98
|
+
console.print(
|
|
99
|
+
f" sudo mkdir -p {parent} && sudo cp {local_settings_path} {target_path}"
|
|
100
|
+
)
|
|
101
|
+
console.print(
|
|
102
|
+
"\nThen fully quit VS Code and reopen — the extension now routes through the gateway."
|
|
103
|
+
)
|
|
104
|
+
console.print("\n[dim]Contents (for reference):[/]")
|
|
105
|
+
console.print(settings_json)
|
|
68
106
|
|
|
69
107
|
|
|
70
108
|
def _find_assets() -> Path:
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""`agentgov login` — obtain an AgentGov dashboard session (CLAUDE.md §13.5).
|
|
2
|
+
|
|
3
|
+
Two paths, mutually exclusive:
|
|
4
|
+
|
|
5
|
+
1. --invite <inv_...> Accept an emailed invite: prompts for a username,
|
|
6
|
+
POSTs /api/invites/<token>, and stores the returned
|
|
7
|
+
sess_ token as the identity.
|
|
8
|
+
|
|
9
|
+
2. --token <sess_...|agentgov-dev-admin>
|
|
10
|
+
Paste-in a pre-obtained token. Verified by hitting
|
|
11
|
+
/api/devices with it (any authenticated GET works).
|
|
12
|
+
|
|
13
|
+
The Auth0 device-code flow lands in Phase 5 and slots into path (2).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
import typer
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
tower_url: str = typer.Option(
|
|
31
|
+
None, "--tower", envvar="CONTROL_TOWER_URL", help="Control tower URL"
|
|
32
|
+
),
|
|
33
|
+
token: str = typer.Option(
|
|
34
|
+
None,
|
|
35
|
+
"--token",
|
|
36
|
+
help="Paste a session token (sess_...) or 'agentgov-dev-admin' in dev.",
|
|
37
|
+
),
|
|
38
|
+
invite: str = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--invite",
|
|
41
|
+
help="Accept an invite token (inv_...) from an email link.",
|
|
42
|
+
),
|
|
43
|
+
username: str = typer.Option(
|
|
44
|
+
None,
|
|
45
|
+
"--username",
|
|
46
|
+
help="Username to claim when accepting an invite. Prompted if omitted.",
|
|
47
|
+
),
|
|
48
|
+
display_name: str = typer.Option(
|
|
49
|
+
None, "--display-name", help="Optional display name when accepting an invite."
|
|
50
|
+
),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Attach this machine to a user in the AgentGov control tower."""
|
|
53
|
+
tower = (tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000").rstrip("/")
|
|
54
|
+
|
|
55
|
+
if invite and token:
|
|
56
|
+
console.print("[red]--invite and --token are mutually exclusive.[/]")
|
|
57
|
+
raise typer.Exit(2)
|
|
58
|
+
|
|
59
|
+
if invite:
|
|
60
|
+
resolved = _accept_invite(tower, invite.strip(), username, display_name)
|
|
61
|
+
else:
|
|
62
|
+
resolved = _paste_token(tower, token)
|
|
63
|
+
|
|
64
|
+
identity_path = Path.home() / ".agentgov" / "identity.json"
|
|
65
|
+
identity_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
identity_path.write_text(json.dumps(resolved, indent=2))
|
|
67
|
+
identity_path.chmod(0o600)
|
|
68
|
+
console.print(f"[green]✅ Logged in.[/] Identity saved to {identity_path}")
|
|
69
|
+
console.print("Next: [bold]agentgov register-device --name 'my-laptop'[/]")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _paste_token(tower: str, token: str | None) -> dict[str, object]:
|
|
73
|
+
if not token:
|
|
74
|
+
console.print(
|
|
75
|
+
"[bold]Paste your session token[/] (sess_... from signup/invite, or 'agentgov-dev-admin' in dev)."
|
|
76
|
+
)
|
|
77
|
+
token = typer.prompt("token", hide_input=True).strip()
|
|
78
|
+
else:
|
|
79
|
+
token = token.strip()
|
|
80
|
+
|
|
81
|
+
# Verify.
|
|
82
|
+
try:
|
|
83
|
+
r = httpx.get(
|
|
84
|
+
f"{tower}/api/devices",
|
|
85
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
86
|
+
timeout=10.0,
|
|
87
|
+
)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
console.print(f"[red]Cannot reach control tower at {tower}: {e}[/]")
|
|
90
|
+
raise typer.Exit(2) from e
|
|
91
|
+
if r.status_code == 401:
|
|
92
|
+
console.print("[red]Token rejected by control tower.[/]")
|
|
93
|
+
raise typer.Exit(2)
|
|
94
|
+
if r.status_code >= 400:
|
|
95
|
+
console.print(f"[red]Unexpected response: {r.status_code} {r.text}[/]")
|
|
96
|
+
raise typer.Exit(2)
|
|
97
|
+
|
|
98
|
+
return {"tower_url": tower, "auth0_token": token}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _accept_invite(
|
|
102
|
+
tower: str,
|
|
103
|
+
invite_token: str,
|
|
104
|
+
username: str | None,
|
|
105
|
+
display_name: str | None,
|
|
106
|
+
) -> dict[str, object]:
|
|
107
|
+
# Peek so the user sees what they're joining.
|
|
108
|
+
try:
|
|
109
|
+
peek = httpx.get(f"{tower}/api/invites/{invite_token}", timeout=10.0)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
console.print(f"[red]Cannot reach control tower at {tower}: {e}[/]")
|
|
112
|
+
raise typer.Exit(2) from e
|
|
113
|
+
if peek.status_code != 200:
|
|
114
|
+
console.print(f"[red]Invite not valid: {peek.status_code} {peek.text}[/]")
|
|
115
|
+
raise typer.Exit(2)
|
|
116
|
+
preview = peek.json()
|
|
117
|
+
console.print(
|
|
118
|
+
f"Invite for [bold]{preview.get('email')}[/] to join "
|
|
119
|
+
f"[bold]{preview.get('tenant_name')}[/] as {preview.get('role')}."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not username:
|
|
123
|
+
username = typer.prompt("Choose a username").strip()
|
|
124
|
+
if not display_name:
|
|
125
|
+
display_name = typer.prompt("Display name (optional)", default="").strip() or None
|
|
126
|
+
|
|
127
|
+
body: dict[str, object] = {"username": username}
|
|
128
|
+
if display_name:
|
|
129
|
+
body["display_name"] = display_name
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
r = httpx.post(
|
|
133
|
+
f"{tower}/api/invites/{invite_token}",
|
|
134
|
+
json=body,
|
|
135
|
+
timeout=15.0,
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
console.print(f"[red]Accept failed: {e}[/]")
|
|
139
|
+
raise typer.Exit(2) from e
|
|
140
|
+
if r.status_code >= 400:
|
|
141
|
+
console.print(f"[red]Accept rejected: {r.status_code} {r.text}[/]")
|
|
142
|
+
raise typer.Exit(2)
|
|
143
|
+
|
|
144
|
+
resp = r.json()
|
|
145
|
+
session_token = resp.get("session_token")
|
|
146
|
+
if not session_token:
|
|
147
|
+
console.print("[red]Server did not return a session token.[/]")
|
|
148
|
+
raise typer.Exit(2)
|
|
149
|
+
|
|
150
|
+
console.print(
|
|
151
|
+
f"[green]Joined {resp.get('tenant_name')} as {resp.get('user', {}).get('username')}.[/]"
|
|
152
|
+
)
|
|
153
|
+
return {
|
|
154
|
+
"tower_url": tower,
|
|
155
|
+
"auth0_token": session_token,
|
|
156
|
+
"tenant_id": resp.get("tenant_id"),
|
|
157
|
+
"tenant_slug": resp.get("tenant_slug"),
|
|
158
|
+
"username": resp.get("user", {}).get("username"),
|
|
159
|
+
}
|
|
@@ -57,14 +57,27 @@ def run(
|
|
|
57
57
|
console.print(f"[red]Invalid --mode {upstream_mode}[/]")
|
|
58
58
|
raise typer.Exit(2)
|
|
59
59
|
|
|
60
|
-
tower = tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000"
|
|
61
|
-
|
|
62
60
|
identity_path = Path.home() / ".agentgov" / "identity.json"
|
|
63
61
|
if not identity_path.exists():
|
|
64
62
|
console.print("[red]No AgentGov identity found.[/] Run [bold]agentgov login[/] first.")
|
|
65
63
|
raise typer.Exit(2)
|
|
66
64
|
identity = json.loads(identity_path.read_text())
|
|
67
65
|
token = identity.get("auth0_token") or ""
|
|
66
|
+
|
|
67
|
+
# Tower URL precedence: explicit --tower > CONTROL_TOWER_URL env >
|
|
68
|
+
# the tower we logged in against (identity.json) > localhost dev default.
|
|
69
|
+
#
|
|
70
|
+
# The identity.json fallback matters: `agentgov login --tower https://...`
|
|
71
|
+
# persists the tower, but a NEW shell won't have CONTROL_TOWER_URL set.
|
|
72
|
+
# Without this fallback register-device silently tried localhost:3000 and
|
|
73
|
+
# died with "Connection refused" even though login had just succeeded.
|
|
74
|
+
tower = (
|
|
75
|
+
tower_url
|
|
76
|
+
or os.environ.get("CONTROL_TOWER_URL")
|
|
77
|
+
or identity.get("tower_url")
|
|
78
|
+
or "http://localhost:3000"
|
|
79
|
+
)
|
|
80
|
+
console.print(f"[dim]Registering against control tower:[/] {tower}")
|
|
68
81
|
if not token:
|
|
69
82
|
console.print("[red]Identity file is missing auth0_token.[/] Re-run `agentgov login`.")
|
|
70
83
|
raise typer.Exit(2)
|
|
@@ -110,16 +123,24 @@ def run(
|
|
|
110
123
|
device = payload.get("device", {})
|
|
111
124
|
gtw_token = payload.get("gateway_service_token") or ""
|
|
112
125
|
|
|
113
|
-
# Persist gateway
|
|
126
|
+
# Persist a COMPLETE gateway config so `agentgov-gateway` boots with zero
|
|
127
|
+
# environment variables. config.py reads this file when the corresponding
|
|
128
|
+
# env vars are absent; env still wins if explicitly set.
|
|
114
129
|
creds_path = Path.home() / ".agentgov" / "gateway_creds.json"
|
|
115
130
|
creds_path.write_text(
|
|
116
131
|
json.dumps(
|
|
117
132
|
{
|
|
118
133
|
"tower_url": tower,
|
|
134
|
+
"control_tower_url": tower,
|
|
119
135
|
"device_id": device.get("id"),
|
|
120
136
|
"device_name": device.get("name"),
|
|
121
|
-
"upstream_mode": device.get("upstream_mode"),
|
|
137
|
+
"upstream_mode": device.get("upstream_mode") or upstream_mode,
|
|
122
138
|
"gateway_service_token": gtw_token,
|
|
139
|
+
# Public verify-only key; the tower returns it at registration.
|
|
140
|
+
"policy_signing_public_key_b64": payload.get(
|
|
141
|
+
"policy_signing_public_key_b64", ""
|
|
142
|
+
),
|
|
143
|
+
"device_key_path": str(priv_path),
|
|
123
144
|
},
|
|
124
145
|
indent=2,
|
|
125
146
|
)
|
|
@@ -129,9 +150,7 @@ def run(
|
|
|
129
150
|
console.print(
|
|
130
151
|
f"[green]✅ Device registered.[/] id={device.get('id')} mode={device.get('upstream_mode')}"
|
|
131
152
|
)
|
|
132
|
-
console.print(f"
|
|
153
|
+
console.print(f"Gateway config saved to {creds_path}")
|
|
133
154
|
console.print()
|
|
134
|
-
console.print("
|
|
135
|
-
console.print(
|
|
136
|
-
console.print(f" DEVICE_KEY_PATH={priv_path}")
|
|
137
|
-
console.print(f" UPSTREAM_MODE={upstream_mode}")
|
|
155
|
+
console.print("[bold]Start the gateway — no environment variables needed:[/]")
|
|
156
|
+
console.print(" agentgov-gateway")
|
|
@@ -78,6 +78,13 @@ def run(
|
|
|
78
78
|
target = list(cmd) if cmd else ["claude"]
|
|
79
79
|
gateway = gateway_url or "http://localhost:8000"
|
|
80
80
|
|
|
81
|
+
# Never hand Claude Code a dead base URL. We are about to set
|
|
82
|
+
# ANTHROPIC_BASE_URL=<gateway>; if nothing is listening there, Claude Code
|
|
83
|
+
# fails with an opaque "API Error: Connection refused" from inside its own
|
|
84
|
+
# request path, with no mention of AgentGov. Verify first, and start a
|
|
85
|
+
# local gateway on demand.
|
|
86
|
+
_require_gateway(gateway)
|
|
87
|
+
|
|
81
88
|
# Register the session with the gateway. Also send hook hashes so the
|
|
82
89
|
# gateway can compare against expected values from the signed snapshot
|
|
83
90
|
# (gap-P0-4 hook integrity check). Mismatch = warning + audit row;
|
|
@@ -130,6 +137,57 @@ def _wait_for_port(listener: LoopbackListener, timeout_sec: float = 2.0) -> None
|
|
|
130
137
|
_time.sleep(0.01)
|
|
131
138
|
|
|
132
139
|
|
|
140
|
+
def _is_loopback(gateway: str) -> bool:
|
|
141
|
+
"""True if `gateway` points at this machine (so we may start it ourselves)."""
|
|
142
|
+
from urllib.parse import urlparse
|
|
143
|
+
|
|
144
|
+
host = (urlparse(gateway).hostname or "").lower()
|
|
145
|
+
return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _require_gateway(gateway: str) -> None:
|
|
149
|
+
"""Ensure a healthy gateway at `gateway`, or exit with actionable guidance.
|
|
150
|
+
|
|
151
|
+
Local gateways are started on demand (detached, so they outlive this
|
|
152
|
+
terminal). Remote gateways (§12 enterprise-remote) are never started by us —
|
|
153
|
+
we only report that they are unreachable.
|
|
154
|
+
"""
|
|
155
|
+
from .. import gateway_process as gp
|
|
156
|
+
|
|
157
|
+
if gp.is_healthy(gateway):
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if not _is_loopback(gateway):
|
|
161
|
+
console.print(
|
|
162
|
+
f"[red]AgentGov gateway at {gateway} is not reachable.[/]\n"
|
|
163
|
+
"This is a remote gateway, so this machine cannot start it. "
|
|
164
|
+
"Check the URL and your network, or contact your administrator."
|
|
165
|
+
)
|
|
166
|
+
raise typer.Exit(2)
|
|
167
|
+
|
|
168
|
+
console.print(f"[yellow]Gateway not running at {gateway} — starting it...[/]")
|
|
169
|
+
ok, detail = gp.start_detached(gateway)
|
|
170
|
+
if ok:
|
|
171
|
+
console.print(f"[green]Gateway started.[/] {detail}")
|
|
172
|
+
console.print(
|
|
173
|
+
"Tip: run [cyan]agentgov gateway install[/] once to have it start "
|
|
174
|
+
"automatically at login and restart on failure."
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Refuse to launch rather than hand Claude Code a dead URL.
|
|
179
|
+
console.print(f"[red]Could not start the AgentGov gateway at {gateway}.[/]\n{detail}")
|
|
180
|
+
console.print(
|
|
181
|
+
"\nClaude Code was NOT launched, because it would have failed with an "
|
|
182
|
+
"opaque connection error.\n"
|
|
183
|
+
"Fix the gateway, then retry:\n"
|
|
184
|
+
" [cyan]agentgov gateway install[/] # install as a supervised service\n"
|
|
185
|
+
" [cyan]agentgov gateway logs[/] # see why it is failing\n"
|
|
186
|
+
" [cyan]agentgov doctor[/] # full diagnosis"
|
|
187
|
+
)
|
|
188
|
+
raise typer.Exit(2)
|
|
189
|
+
|
|
190
|
+
|
|
133
191
|
def _resolve_repo() -> tuple[Path, str]:
|
|
134
192
|
try:
|
|
135
193
|
root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
|