lighthouse-cli 0.16.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.
- lighthouse/__init__.py +6 -0
- lighthouse/__main__.py +9 -0
- lighthouse/commands/__init__.py +0 -0
- lighthouse/commands/agents.py +310 -0
- lighthouse/commands/builds.py +85 -0
- lighthouse/commands/collectors.py +147 -0
- lighthouse/commands/connect.py +717 -0
- lighthouse/commands/connections.py +201 -0
- lighthouse/commands/evals.py +198 -0
- lighthouse/commands/kb.py +224 -0
- lighthouse/commands/language.py +56 -0
- lighthouse/commands/limits.py +61 -0
- lighthouse/commands/login.py +207 -0
- lighthouse/commands/models.py +61 -0
- lighthouse/commands/runs.py +157 -0
- lighthouse/commands/spend.py +84 -0
- lighthouse/commands/stack.py +103 -0
- lighthouse/commands/terms.py +50 -0
- lighthouse/commands/whoami.py +80 -0
- lighthouse/config.py +141 -0
- lighthouse/credentials.py +233 -0
- lighthouse/device.py +124 -0
- lighthouse/exits.py +78 -0
- lighthouse/flags.py +61 -0
- lighthouse/http.py +355 -0
- lighthouse/i18n.py +250 -0
- lighthouse/locales/ar.json +343 -0
- lighthouse/locales/de.json +343 -0
- lighthouse/locales/en.json +343 -0
- lighthouse/locales/es.json +343 -0
- lighthouse/locales/fr.json +343 -0
- lighthouse/locales/ga.json +343 -0
- lighthouse/locales/it.json +343 -0
- lighthouse/locales/ja.json +343 -0
- lighthouse/locales/nl.json +343 -0
- lighthouse/locales/pt.json +343 -0
- lighthouse/locales/zh-Hans.json +343 -0
- lighthouse/locales/zh-Hant.json +343 -0
- lighthouse/main.py +229 -0
- lighthouse/out.py +169 -0
- lighthouse/py.typed +0 -0
- lighthouse/sealing.py +448 -0
- lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
- lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
- lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
- lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
- lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
- lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse stack`: Get my stack, as files in a directory.
|
|
4
|
+
|
|
5
|
+
lighthouse stack # what the stack holds
|
|
6
|
+
lighthouse stack --out ./lighthouse # every file, written there
|
|
7
|
+
lighthouse stack --out ./lh --cloud onprem --profile always_on --deployment edge-1
|
|
8
|
+
|
|
9
|
+
Reads `GET /api/v1/run-targets/stack`, as the person: the stack is a client
|
|
10
|
+
administrator's, at their own keyboard, so the request goes with the
|
|
11
|
+
person's own portal session (`cloudflared access login https://<host>`),
|
|
12
|
+
never a token. With `--out`, each file is written where the portal says it
|
|
13
|
+
goes (`gateway/policy.yaml`, `secrets/aal-reader-public-key`, ...), scripts
|
|
14
|
+
executable; then, on your own servers, `./lighthouse-start.sh` starts it.
|
|
15
|
+
|
|
16
|
+
**It overwrites nothing** unless `--force`, and it never touches a file the
|
|
17
|
+
stack does not name: the secrets `lighthouse-start.sh` made, and the ones
|
|
18
|
+
you filled in, stay as they are when you fetch a newer stack over an older
|
|
19
|
+
one with `--force`. Nothing it writes is a secret: Atlantic's two public keys
|
|
20
|
+
are public halves.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import os
|
|
26
|
+
import stat
|
|
27
|
+
from pathlib import Path, PurePosixPath
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from lighthouse.commands import connect as _connect
|
|
31
|
+
from lighthouse.exits import USAGE, CliError
|
|
32
|
+
from lighthouse.main import Command, Context
|
|
33
|
+
from lighthouse.out import Out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
37
|
+
ap.add_argument("--cloud", help="aws, azure, gcp or onprem (default: the one chosen)")
|
|
38
|
+
ap.add_argument("--region", help="the region (default: the one chosen)")
|
|
39
|
+
ap.add_argument("--profile", help="always_on or ephemeral (default: the one chosen)")
|
|
40
|
+
ap.add_argument("--deployment",
|
|
41
|
+
help="which of your active deployments the files are for")
|
|
42
|
+
ap.add_argument("--out", help="write every file into this directory")
|
|
43
|
+
ap.add_argument("--force", action="store_true",
|
|
44
|
+
help="overwrite files the stack names that already exist there")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _where(out: Path, name: str) -> Path:
|
|
48
|
+
"""The file's path under `out`. A name is relative and stays inside: the
|
|
49
|
+
service's answer is trusted to describe files, never to place them."""
|
|
50
|
+
rel = PurePosixPath(name)
|
|
51
|
+
if rel.is_absolute() or ".." in rel.parts or not rel.parts:
|
|
52
|
+
raise CliError(f"the stack names a file outside its directory: {name}", error="refused")
|
|
53
|
+
return out.joinpath(*rel.parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def stack(ctx: Context) -> dict[str, Any]:
|
|
57
|
+
a = ctx.args
|
|
58
|
+
host = ctx.host
|
|
59
|
+
params = {k: getattr(a, k) for k in ("cloud", "region", "profile", "deployment")
|
|
60
|
+
if getattr(a, k)}
|
|
61
|
+
client = ctx.new_client(host, None, session=_connect.person_session(host))
|
|
62
|
+
try:
|
|
63
|
+
body = client.request("GET", "/api/v1/run-targets/stack", params=params) or {}
|
|
64
|
+
finally:
|
|
65
|
+
client.close()
|
|
66
|
+
files = body.get("files") or []
|
|
67
|
+
rows = [{"name": f.get("name"), "format": f.get("format"), "what": f.get("what"),
|
|
68
|
+
"written": False} for f in files]
|
|
69
|
+
out = Path(a.out) if a.out else None
|
|
70
|
+
if out is not None:
|
|
71
|
+
targets = [_where(out, str(f.get("name") or "")) for f in files]
|
|
72
|
+
exists = [str(t) for t in targets if t.exists()]
|
|
73
|
+
if exists and not a.force:
|
|
74
|
+
raise CliError(f"{len(exists)} of the stack's files already exist in {out}",
|
|
75
|
+
code=USAGE, error="exists",
|
|
76
|
+
hint="--force overwrites the files the stack names, and nothing else")
|
|
77
|
+
for f, target, row in zip(files, targets, rows, strict=True):
|
|
78
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
if target.parent.name == "secrets":
|
|
80
|
+
os.chmod(target.parent, 0o700)
|
|
81
|
+
target.write_text(str(f.get("content") or ""), encoding="utf-8")
|
|
82
|
+
if f.get("format") == "shell":
|
|
83
|
+
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
84
|
+
row["written"] = True
|
|
85
|
+
start = next((r["name"] for r in rows if r["name"] == "lighthouse-start.sh"), None)
|
|
86
|
+
return {"ok": True, "cloud": body.get("cloud"), "region": body.get("region"),
|
|
87
|
+
"profile": body.get("profile"), "infra_ref": body.get("infra_ref"),
|
|
88
|
+
"out": str(out) if out is not None else None, "files": rows,
|
|
89
|
+
"next": (f"cd {out} && ./{start}" if out is not None and start else
|
|
90
|
+
body.get("applies"))}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
94
|
+
out.pairs([("where", f"{body['cloud']} {body['region']}"), ("profile", body["profile"]),
|
|
95
|
+
("infrastructure", body["infra_ref"]), ("written to", body["out"])])
|
|
96
|
+
out.table(["file", "kind", "written"],
|
|
97
|
+
[[r["name"], r["format"], "yes" if r["written"] else "no"] for r in body["files"]])
|
|
98
|
+
if body.get("next"):
|
|
99
|
+
out.note(str(body["next"]))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
COMMANDS = (Command("stack", "Get my stack: the files that run LightHouse where you chose",
|
|
103
|
+
_configure, stack, _show),)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse terms`: the commercial terms in force for this tenant.
|
|
4
|
+
|
|
5
|
+
lighthouse terms
|
|
6
|
+
lighthouse terms --json
|
|
7
|
+
|
|
8
|
+
Reads `GET /api/v1/billing/terms`: the payment model,
|
|
9
|
+
the credit limit, the payment terms, the margins, the suspension notice, and
|
|
10
|
+
for each one whether it is AAL's published default or something agreed for this
|
|
11
|
+
client, with the version and when it took effect.
|
|
12
|
+
|
|
13
|
+
**Read only.** Accepting a term is a person's act, recorded against them with
|
|
14
|
+
the version and the time; a token never accepts anything
|
|
15
|
+
(`docs/adr/0012-delegated-credentials.md`). This command exists because a
|
|
16
|
+
developer writing a billing integration needs to read the terms their code must
|
|
17
|
+
honour, not because terms should be changed from a shell.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from lighthouse.main import Command, Context
|
|
25
|
+
from lighthouse.out import Out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
29
|
+
ap.add_argument("--term", help="one term by its key")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def terms(ctx: Context) -> dict[str, Any]:
|
|
33
|
+
body = ctx.client().get("/api/v1/billing/terms")
|
|
34
|
+
rows = body.get("terms") or []
|
|
35
|
+
if ctx.args.term:
|
|
36
|
+
rows = [r for r in rows if r.get("key") == ctx.args.term]
|
|
37
|
+
return {"ok": True, **body, "terms": rows}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
41
|
+
out.table(["term", "meaning", "set by", "direction", "version", "in force from"],
|
|
42
|
+
[[r.get("key"), r.get("label"), (r.get("in_force") or {}).get("set_by"),
|
|
43
|
+
(r.get("in_force") or {}).get("direction"),
|
|
44
|
+
(r.get("in_force") or {}).get("version"),
|
|
45
|
+
(r.get("in_force") or {}).get("effective_from")] for r in body["terms"]])
|
|
46
|
+
out.note("accepting a term is a person's act, recorded against them in the portal")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
COMMANDS = (Command("terms", "the commercial terms in force for this tenant",
|
|
50
|
+
_configure, terms, _show),)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse whoami`: who the CLI is, where, and with what reach.
|
|
4
|
+
|
|
5
|
+
lighthouse whoami
|
|
6
|
+
lighthouse whoami --json
|
|
7
|
+
|
|
8
|
+
The first thing anybody runs when a pipeline misbehaves, so it answers the four
|
|
9
|
+
questions that matter — which tenant, which person, which role, which scopes —
|
|
10
|
+
and where the credential came from (`keychain` or `environment`) and what kind it
|
|
11
|
+
is (a delegated token or a machine key). It makes one
|
|
12
|
+
request, `GET /api/v1/me`, and exits 3 when there is no credential, so
|
|
13
|
+
`lighthouse whoami >/dev/null || lighthouse login` is a sound idiom.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from lighthouse import i18n
|
|
21
|
+
from lighthouse.i18n import t
|
|
22
|
+
from lighthouse.main import Command, Context
|
|
23
|
+
from lighthouse.out import Out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
27
|
+
ap.add_argument("--profiles", action="store_true",
|
|
28
|
+
help="list every host signed in on this machine as well")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def whoami(ctx: Context) -> dict[str, Any]:
|
|
32
|
+
host = ctx.host
|
|
33
|
+
credential = ctx.store.require(host)
|
|
34
|
+
me = ctx.client().get("/api/v1/me")
|
|
35
|
+
# The viewer's effective language (contract 1.29), kept so the next run
|
|
36
|
+
# speaks it without a request (i18n.py).
|
|
37
|
+
i18n.remember(ctx.config.path.parent, host, ((me.get("locale") or {}).get("language")))
|
|
38
|
+
viewer = me.get("viewer") or {}
|
|
39
|
+
client = me.get("client") or {}
|
|
40
|
+
out: dict[str, Any] = {
|
|
41
|
+
"ok": True, "host": host,
|
|
42
|
+
"tenant": {"name": client.get("name"), "tenant_id": client.get("tenant_id"),
|
|
43
|
+
"portal_url": client.get("portal_url")},
|
|
44
|
+
"viewer": {"email": viewer.get("email"), "name": viewer.get("name"),
|
|
45
|
+
"role": viewer.get("role"), "kind": viewer.get("kind"),
|
|
46
|
+
"provider": (viewer.get("provider") or {}).get("name")},
|
|
47
|
+
"credential": credential.redacted(),
|
|
48
|
+
"contract_revision": me.get("contract_revision"),
|
|
49
|
+
}
|
|
50
|
+
if getattr(ctx.args, "profiles", False):
|
|
51
|
+
out["profiles"] = [{"name": p.name, "host": p.host,
|
|
52
|
+
"current": p.name == ctx.config.current}
|
|
53
|
+
for p in sorted(ctx.config.profiles.values(), key=lambda p: p.name)]
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
58
|
+
credential = body["credential"]
|
|
59
|
+
out.pairs([("tenant", body["tenant"]["name"]),
|
|
60
|
+
("host", body["host"]),
|
|
61
|
+
("person", body["viewer"]["email"]),
|
|
62
|
+
("role", body["viewer"]["role"]),
|
|
63
|
+
("signed in with", body["viewer"]["provider"]),
|
|
64
|
+
("scopes", " ".join(credential["scopes"]) or t("whoami.all_reach")),
|
|
65
|
+
("credential from", credential["source"]),
|
|
66
|
+
("credential kind", t("whoami.machine_key") if credential.get("kind") == "machine"
|
|
67
|
+
else t("whoami.delegated_token")),
|
|
68
|
+
("expires", credential["expires_at"] or (
|
|
69
|
+
t("whoami.never_by_time") if credential.get("kind") == "machine" else None))])
|
|
70
|
+
if credential["source"] == "environment":
|
|
71
|
+
if credential.get("kind") == "machine":
|
|
72
|
+
out.note(t("whoami.note_machine_key"))
|
|
73
|
+
else:
|
|
74
|
+
out.note(t("whoami.note_delegated_token"))
|
|
75
|
+
for profile in body.get("profiles") or []:
|
|
76
|
+
out.say(f" {'*' if profile['current'] else ' '} {profile['name']} {profile['host']}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
COMMANDS = (Command("whoami", "who this CLI is signed in as, and with what reach",
|
|
80
|
+
_configure, whoami, _show),)
|
lighthouse/config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Which tenant a command talks to, and the profile file that remembers it.
|
|
4
|
+
|
|
5
|
+
A LightHouse tenant *is* a hostname: `https://<tenant id>.atlanticagenticlabs.com`
|
|
6
|
+
(`docs/adr/0001-opaque-tenant-ids.md`). So every command needs a host, and
|
|
7
|
+
gets one from the first of these that is set:
|
|
8
|
+
|
|
9
|
+
1. `--host` on the command line;
|
|
10
|
+
2. `LIGHTHOUSE_HOST` in the environment;
|
|
11
|
+
3. the current profile in `~/.config/lighthouse/config.toml`
|
|
12
|
+
(`$XDG_CONFIG_HOME` is honoured where it is set);
|
|
13
|
+
4. nothing — and the command says so and stops, rather than guessing.
|
|
14
|
+
|
|
15
|
+
**The file holds no secrets**: host names, a profile name, and which profile is
|
|
16
|
+
current. Credentials are in the OS keychain (credentials.py). It is written
|
|
17
|
+
with mode 0600 all the same, because it names a company's tenants.
|
|
18
|
+
|
|
19
|
+
Written as TOML and read with the standard library's `tomllib`, so the CLI
|
|
20
|
+
carries no parser of its own for a file a person may edit by hand.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import tomllib
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from lighthouse.exits import USAGE, CliError
|
|
32
|
+
from lighthouse.i18n import t
|
|
33
|
+
|
|
34
|
+
ENV_HOST = "LIGHTHOUSE_HOST"
|
|
35
|
+
ENV_CONFIG = "LIGHTHOUSE_CONFIG"
|
|
36
|
+
DEFAULT_BASE = "atlanticagenticlabs.com"
|
|
37
|
+
# A hostname, nothing else: no scheme, no path, no port. A command that took a
|
|
38
|
+
# URL would let a mistyped one send a credential somewhere unintended.
|
|
39
|
+
_HOST = re.compile(r"^[a-z0-9]([a-z0-9-]{0,62}\.)+[a-z]{2,}$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NoHost(CliError):
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
super().__init__(t("err.no_host"), code=USAGE, error="no_host", hint=t("hint.no_host"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clean_host(given: str) -> str:
|
|
48
|
+
"""A bare hostname from what a person typed. A URL is accepted and reduced
|
|
49
|
+
to its host, because everyone pastes one; anything else is refused."""
|
|
50
|
+
value = (given or "").strip().lower()
|
|
51
|
+
value = re.sub(r"^https?://", "", value).split("/")[0].split("?")[0]
|
|
52
|
+
value = value.rsplit(":", 1)[0] if value.count(":") == 1 else value
|
|
53
|
+
if "." not in value:
|
|
54
|
+
value = f"{value}.{DEFAULT_BASE}" # a bare tenant id
|
|
55
|
+
if not _HOST.match(value):
|
|
56
|
+
raise CliError(t("err.bad_host", given=repr(given)), code=USAGE, error="bad_host",
|
|
57
|
+
hint=t("hint.bad_host"))
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Profile:
|
|
63
|
+
name: str
|
|
64
|
+
host: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Config:
|
|
69
|
+
path: Path
|
|
70
|
+
current: str | None = None
|
|
71
|
+
profiles: dict[str, Profile] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
# -- reading ---------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def directory(cls, env: dict[str, str]) -> Path:
|
|
77
|
+
if env.get(ENV_CONFIG):
|
|
78
|
+
return Path(env[ENV_CONFIG]).expanduser().parent
|
|
79
|
+
base = env.get("XDG_CONFIG_HOME") or str(Path(env.get("HOME", "~")) / ".config")
|
|
80
|
+
return Path(base).expanduser() / "lighthouse"
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def load(cls, env: dict[str, str] | None = None) -> Config:
|
|
84
|
+
env = env if env is not None else dict(os.environ)
|
|
85
|
+
path = Path(env[ENV_CONFIG]).expanduser() if env.get(ENV_CONFIG) \
|
|
86
|
+
else cls.directory(env) / "config.toml"
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return cls(path)
|
|
89
|
+
try:
|
|
90
|
+
data: dict[str, Any] = tomllib.loads(path.read_text())
|
|
91
|
+
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
92
|
+
raise CliError(t("err.bad_config", path=path, error=e), error="bad_config",
|
|
93
|
+
hint=t("hint.bad_config")) from None
|
|
94
|
+
profiles = {}
|
|
95
|
+
for name, row in (data.get("profiles") or {}).items():
|
|
96
|
+
if isinstance(row, dict) and row.get("host"):
|
|
97
|
+
profiles[str(name)] = Profile(str(name), clean_host(str(row["host"])))
|
|
98
|
+
current = data.get("current")
|
|
99
|
+
return cls(path, str(current) if current else None, profiles)
|
|
100
|
+
|
|
101
|
+
# -- writing ---------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def save(self) -> None:
|
|
104
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
lines = []
|
|
106
|
+
if self.current:
|
|
107
|
+
lines.append(f'current = "{self.current}"\n')
|
|
108
|
+
for name in sorted(self.profiles):
|
|
109
|
+
lines.append(f'\n[profiles."{name}"]\nhost = "{self.profiles[name].host}"\n')
|
|
110
|
+
self.path.write_text("# Written by `lighthouse`. Host names only: credentials are in "
|
|
111
|
+
"the operating system keychain.\n" + "".join(lines))
|
|
112
|
+
os.chmod(self.path, 0o600)
|
|
113
|
+
|
|
114
|
+
def remember(self, host: str, *, name: str | None = None, make_current: bool = True) -> Profile:
|
|
115
|
+
profile = Profile(name or host.split(".")[0], host)
|
|
116
|
+
self.profiles[profile.name] = profile
|
|
117
|
+
if make_current or self.current is None:
|
|
118
|
+
self.current = profile.name
|
|
119
|
+
self.save()
|
|
120
|
+
return profile
|
|
121
|
+
|
|
122
|
+
def forget(self, host: str) -> None:
|
|
123
|
+
for name in [n for n, p in self.profiles.items() if p.host == host]:
|
|
124
|
+
del self.profiles[name]
|
|
125
|
+
if self.current == name:
|
|
126
|
+
self.current = next(iter(sorted(self.profiles)), None)
|
|
127
|
+
self.save()
|
|
128
|
+
|
|
129
|
+
# -- resolution ---------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def host_for(self, given: str | None, env: dict[str, str] | None = None) -> str:
|
|
132
|
+
env = env if env is not None else {}
|
|
133
|
+
if given:
|
|
134
|
+
return clean_host(given)
|
|
135
|
+
if env.get(ENV_HOST):
|
|
136
|
+
return clean_host(env[ENV_HOST])
|
|
137
|
+
if self.current and self.current in self.profiles:
|
|
138
|
+
return self.profiles[self.current].host
|
|
139
|
+
if len(self.profiles) == 1:
|
|
140
|
+
return next(iter(self.profiles.values())).host
|
|
141
|
+
raise NoHost()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Where the credential lives: the operating system's keychain, and nowhere else.
|
|
4
|
+
|
|
5
|
+
`lighthouse login` stores the access and refresh tokens through `keyring`, so
|
|
6
|
+
on macOS they are in the login Keychain, on Linux in the Secret Service
|
|
7
|
+
(GNOME Keyring, KWallet) and on Windows in the Credential Locker. **No token is
|
|
8
|
+
ever written to a file**, in the repository or out of it: a dotfile of
|
|
9
|
+
credentials is the thing that ends up committed, copied into a container image,
|
|
10
|
+
or read by every process on a shared machine.
|
|
11
|
+
|
|
12
|
+
One entry per host, under the service name `lighthouse` and the account name
|
|
13
|
+
`<host>`, so a developer can be signed in to several tenants at once and
|
|
14
|
+
`lighthouse logout --all` can find them all. The profile file
|
|
15
|
+
(`~/.config/lighthouse/config.toml`, config.py) records *which hosts exist* and
|
|
16
|
+
which is current — names, never secrets.
|
|
17
|
+
|
|
18
|
+
**In CI there is no keychain**, and a pipeline must not be asked to unlock one.
|
|
19
|
+
So `LIGHTHOUSE_TOKEN` is read first and, when it is set, nothing is read from
|
|
20
|
+
or written to the keychain at all. That is the documented way to use this CLI
|
|
21
|
+
in a pipeline: put a token in a masked variable, and the CLI never touches a
|
|
22
|
+
credential store.
|
|
23
|
+
|
|
24
|
+
**A machine key is the CI credential.** `LIGHTHOUSE_TOKEN` takes either a
|
|
25
|
+
delegated token (`lhat-...`, eight hours, never refreshed under the variable) or
|
|
26
|
+
a machine key (`aalcp-...` from the portal's Keys page: no refresh, and no
|
|
27
|
+
expiry by time). Because every tenant host is behind Cloudflare Access except
|
|
28
|
+
the OAuth endpoints, a pipeline also sets `LIGHTHOUSE_ACCESS_CLIENT_ID` and
|
|
29
|
+
`LIGHTHOUSE_ACCESS_CLIENT_SECRET`, the tenant's Access service token. `login`
|
|
30
|
+
can keep that pair in the keychain beside the delegated token.
|
|
31
|
+
|
|
32
|
+
If the keychain is unreachable (a headless Linux box with no Secret Service,
|
|
33
|
+
say), the CLI says so and names the environment variable, rather than falling
|
|
34
|
+
back to a file. A silent fallback to disk would be the one behaviour nobody
|
|
35
|
+
would notice and everybody would regret.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import logging
|
|
41
|
+
from dataclasses import dataclass, field, replace
|
|
42
|
+
from datetime import UTC, datetime, timedelta
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from lighthouse.exits import USAGE, CliError, NotSignedIn
|
|
46
|
+
from lighthouse.i18n import t
|
|
47
|
+
|
|
48
|
+
log = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
SERVICE = "lighthouse"
|
|
51
|
+
ENV_TOKEN = "LIGHTHOUSE_TOKEN" # noqa: S105 # nosec B105 - a variable name
|
|
52
|
+
# The tenant's Cloudflare Access service token. Every tenant host sits behind
|
|
53
|
+
# Cloudflare Access except the OAuth endpoints, so every other request carries
|
|
54
|
+
# these two as `CF-Access-Client-Id` and `CF-Access-Client-Secret`.
|
|
55
|
+
ENV_ACCESS_ID = "LIGHTHOUSE_ACCESS_CLIENT_ID"
|
|
56
|
+
ENV_ACCESS_SECRET = "LIGHTHOUSE_ACCESS_CLIENT_SECRET" # noqa: S105 # nosec B105 - a variable name
|
|
57
|
+
MACHINE_PREFIX = "aalcp-"
|
|
58
|
+
# Refresh this long before expiry rather than on the first 401, so a long
|
|
59
|
+
# pipeline does not fail at the moment the token lapses.
|
|
60
|
+
EARLY = timedelta(minutes=5)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Credential:
|
|
65
|
+
host: str
|
|
66
|
+
access_token: str
|
|
67
|
+
refresh_token: str | None
|
|
68
|
+
expires_at: datetime | None
|
|
69
|
+
email: str | None
|
|
70
|
+
scopes: tuple[str, ...]
|
|
71
|
+
source: str = "keychain" # keychain | environment
|
|
72
|
+
# The tenant's Cloudflare Access service token, when one is configured.
|
|
73
|
+
access_client_id: str | None = None
|
|
74
|
+
access_client_secret: str | None = field(default=None, repr=False)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def kind(self) -> str:
|
|
78
|
+
"""`machine` for a key from the portal's Keys page (`aalcp-...`, never
|
|
79
|
+
expires by time, never refreshed); `delegated` for a token a person
|
|
80
|
+
approved (`lhat-...`, eight hours)."""
|
|
81
|
+
return "machine" if self.access_token.startswith(MACHINE_PREFIX) else "delegated"
|
|
82
|
+
|
|
83
|
+
def access(self) -> tuple[str, str] | None:
|
|
84
|
+
if self.access_client_id and self.access_client_secret:
|
|
85
|
+
return self.access_client_id, self.access_client_secret
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def stale(self, now: datetime | None = None) -> bool:
|
|
89
|
+
if self.expires_at is None:
|
|
90
|
+
return False
|
|
91
|
+
return self.expires_at - EARLY <= (now or datetime.now(UTC))
|
|
92
|
+
|
|
93
|
+
def redacted(self) -> dict[str, Any]:
|
|
94
|
+
"""Everything about the credential except the credential."""
|
|
95
|
+
return {"host": self.host, "email": self.email, "scopes": list(self.scopes),
|
|
96
|
+
"kind": self.kind, "access_service_token": self.access() is not None,
|
|
97
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
98
|
+
"source": self.source, "refreshable": self.refresh_token is not None}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class KeychainUnavailable(CliError):
|
|
102
|
+
def __init__(self, detail: str) -> None:
|
|
103
|
+
super().__init__(f"the operating system keychain is not available: {detail}",
|
|
104
|
+
error="keychain_unavailable",
|
|
105
|
+
hint=f"in CI, set {ENV_TOKEN} to a machine key instead; "
|
|
106
|
+
f"this CLI never writes a credential to a file")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def access_from_env(env: dict[str, str]) -> tuple[str, str] | None:
|
|
110
|
+
"""The Access service token named by the environment, or None.
|
|
111
|
+
|
|
112
|
+
Both or neither: one without the other can never authenticate, and a CI job
|
|
113
|
+
that sets half of it should hear so now rather than from Cloudflare later.
|
|
114
|
+
"""
|
|
115
|
+
client_id = (env.get(ENV_ACCESS_ID) or "").strip()
|
|
116
|
+
secret = (env.get(ENV_ACCESS_SECRET) or "").strip()
|
|
117
|
+
if client_id and secret:
|
|
118
|
+
return client_id, secret
|
|
119
|
+
if client_id or secret:
|
|
120
|
+
missing = ENV_ACCESS_SECRET if client_id else ENV_ACCESS_ID
|
|
121
|
+
raise CliError(t("err.access_token_incomplete", missing=missing),
|
|
122
|
+
code=USAGE, error="access_token_incomplete",
|
|
123
|
+
hint=t("hint.access_token_incomplete", id=ENV_ACCESS_ID, secret=ENV_ACCESS_SECRET))
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Store:
|
|
128
|
+
"""The keychain, or the environment. `backend` is injected by the tests;
|
|
129
|
+
nothing in the test suite touches a real keychain."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, backend: Any = None, env: dict[str, str] | None = None) -> None:
|
|
132
|
+
self._backend = backend
|
|
133
|
+
self.env = env if env is not None else {}
|
|
134
|
+
|
|
135
|
+
# -- the backend ---------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def backend(self) -> Any:
|
|
139
|
+
if self._backend is None:
|
|
140
|
+
try:
|
|
141
|
+
import keyring
|
|
142
|
+
except Exception as e: # pragma: no cover - import failure
|
|
143
|
+
raise KeychainUnavailable(str(e)) from None
|
|
144
|
+
self._backend = keyring
|
|
145
|
+
return self._backend
|
|
146
|
+
|
|
147
|
+
def _get(self, host: str) -> str | None:
|
|
148
|
+
try:
|
|
149
|
+
got = self.backend.get_password(SERVICE, host)
|
|
150
|
+
return str(got) if got is not None else None
|
|
151
|
+
except Exception as e:
|
|
152
|
+
raise KeychainUnavailable(type(e).__name__) from None
|
|
153
|
+
|
|
154
|
+
def _set(self, host: str, blob: str) -> None:
|
|
155
|
+
try:
|
|
156
|
+
self.backend.set_password(SERVICE, host, blob)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
raise KeychainUnavailable(type(e).__name__) from None
|
|
159
|
+
|
|
160
|
+
def _delete(self, host: str) -> None:
|
|
161
|
+
try:
|
|
162
|
+
self.backend.delete_password(SERVICE, host)
|
|
163
|
+
except Exception as e: # noqa: S110 # nosec B110 - see below
|
|
164
|
+
# Deleting what is not there is success, and every backend raises a
|
|
165
|
+
# different exception for it. `logout` must not fail because the
|
|
166
|
+
# entry had already gone: the point of the command is that nothing
|
|
167
|
+
# is left, and nothing is.
|
|
168
|
+
log.debug("nothing to delete from the keychain: %s", type(e).__name__)
|
|
169
|
+
|
|
170
|
+
# -- reading and writing ---------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def load(self, host: str) -> Credential | None:
|
|
173
|
+
"""The credential for `host`: the environment first, then the keychain.
|
|
174
|
+
|
|
175
|
+
`LIGHTHOUSE_TOKEN` wins and short-circuits: when it is set the keychain
|
|
176
|
+
is not consulted at all, so a pipeline never prompts for one and a
|
|
177
|
+
developer can override what is stored for one command.
|
|
178
|
+
"""
|
|
179
|
+
from_env = access_from_env(self.env)
|
|
180
|
+
token = (self.env.get(ENV_TOKEN) or "").strip()
|
|
181
|
+
if token:
|
|
182
|
+
return Credential(host, token, None, None, None, (), source="environment",
|
|
183
|
+
access_client_id=from_env[0] if from_env else None,
|
|
184
|
+
access_client_secret=from_env[1] if from_env else None)
|
|
185
|
+
blob = self._get(host)
|
|
186
|
+
if not blob:
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(blob)
|
|
190
|
+
except ValueError:
|
|
191
|
+
log.debug("the stored credential is not readable; treating it as absent")
|
|
192
|
+
return None
|
|
193
|
+
expires = data.get("expires_at")
|
|
194
|
+
cred = Credential(
|
|
195
|
+
host, data.get("access_token", ""), data.get("refresh_token"),
|
|
196
|
+
datetime.fromisoformat(expires) if expires else None,
|
|
197
|
+
data.get("email"), tuple(data.get("scopes") or ()),
|
|
198
|
+
access_client_id=data.get("access_client_id"),
|
|
199
|
+
access_client_secret=data.get("access_client_secret"))
|
|
200
|
+
if from_env: # the environment overrides what was stored
|
|
201
|
+
cred = replace(cred, access_client_id=from_env[0], access_client_secret=from_env[1])
|
|
202
|
+
return cred
|
|
203
|
+
|
|
204
|
+
def save(self, host: str, token: dict[str, Any], *, email: str | None = None,
|
|
205
|
+
now: datetime | None = None, access_client_id: str | None = None,
|
|
206
|
+
access_client_secret: str | None = None) -> Credential:
|
|
207
|
+
"""Store what the token endpoint returned. `expires_in` is turned into
|
|
208
|
+
an absolute time here, so a credential read tomorrow is not believed."""
|
|
209
|
+
now = now or datetime.now(UTC)
|
|
210
|
+
expires = now + timedelta(seconds=int(token.get("expires_in") or 0)) \
|
|
211
|
+
if token.get("expires_in") else None
|
|
212
|
+
cred = Credential(host, str(token["access_token"]), token.get("refresh_token"),
|
|
213
|
+
expires, email, tuple((token.get("scope") or "").split()),
|
|
214
|
+
access_client_id=access_client_id,
|
|
215
|
+
access_client_secret=access_client_secret)
|
|
216
|
+
blob = {
|
|
217
|
+
"access_token": cred.access_token, "refresh_token": cred.refresh_token,
|
|
218
|
+
"expires_at": cred.expires_at.isoformat() if cred.expires_at else None,
|
|
219
|
+
"email": cred.email, "scopes": list(cred.scopes)}
|
|
220
|
+
if cred.access():
|
|
221
|
+
blob["access_client_id"] = cred.access_client_id
|
|
222
|
+
blob["access_client_secret"] = cred.access_client_secret
|
|
223
|
+
self._set(host, json.dumps(blob))
|
|
224
|
+
return cred
|
|
225
|
+
|
|
226
|
+
def forget(self, host: str) -> None:
|
|
227
|
+
self._delete(host)
|
|
228
|
+
|
|
229
|
+
def require(self, host: str) -> Credential:
|
|
230
|
+
cred = self.load(host)
|
|
231
|
+
if cred is None or not cred.access_token:
|
|
232
|
+
raise NotSignedIn(host)
|
|
233
|
+
return cred
|