stacktrace-cli 0.11.0__py3-none-any.whl → 0.13.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.
- stacktrace/__init__.py +1 -1
- stacktrace/cli.py +14 -10
- stacktrace/cloud/__init__.py +12 -35
- stacktrace/cloud/boot.py +186 -0
- stacktrace/cloud/profile.py +71 -0
- stacktrace/cloud/setup.sh +27 -311
- stacktrace/correlate/composition.py +22 -1
- stacktrace/correlate/observed.py +49 -5
- stacktrace/cost/baseline.py +16 -1
- stacktrace/cost/copies.py +90 -0
- stacktrace/cost/ledger.py +8 -2
- stacktrace/cost/measures.py +3 -2
- stacktrace/cost/models.py +41 -3
- stacktrace/cost/output.py +123 -0
- stacktrace/cost/price_class.py +48 -0
- stacktrace/cost/reasons.py +3 -2
- stacktrace/cost/rules.py +4 -193
- stacktrace/cost/usage.py +224 -16
- stacktrace/cost/weights.py +120 -0
- stacktrace/daemon/client.py +10 -4
- stacktrace/daemon/config.py +0 -42
- stacktrace/daemon/costs.py +7 -10
- stacktrace/daemon/db.py +168 -15
- stacktrace/daemon/fleet.py +9 -9
- stacktrace/daemon/policy_collect.py +306 -0
- stacktrace/daemon/policy_inputs.py +278 -0
- stacktrace/daemon/policy_runtime.py +569 -0
- stacktrace/daemon/presentation.py +40 -9
- stacktrace/daemon/protocol.py +46 -2
- stacktrace/daemon/runtime.py +10 -11
- stacktrace/daemon/server.py +76 -2
- stacktrace/daemon/store.py +155 -29
- stacktrace/daemon/usage.py +85 -35
- stacktrace/daemon/webhook.py +24 -0
- stacktrace/detector/blocked.py +102 -0
- stacktrace/detector/cache.py +2 -8
- stacktrace/detector/deterministic.py +426 -103
- stacktrace/detector/finding.py +35 -6
- stacktrace/detector/history.py +4 -13
- stacktrace/detector/jev.py +1 -14
- stacktrace/detector/personal_data.py +328 -0
- stacktrace/detector/prompts/__init__.py +3 -2
- stacktrace/detector/prompts/jev-v3/stacktrace-deceptive-completion.json +16 -0
- stacktrace/detector/prompts/jev-v3/stacktrace-infra-unsafe-action.json +10 -0
- stacktrace/detector/reasoning.py +6 -28
- stacktrace/detector/render.py +10 -0
- stacktrace/detector/rules.py +80 -15
- stacktrace/detector/run.py +6 -0
- stacktrace/detector/secrets.py +88 -3
- stacktrace/finding_contract.py +236 -0
- stacktrace/finding_titles.py +57 -0
- stacktrace/gateway/formats/_json.py +1 -5
- stacktrace/gateway/session.py +20 -2
- stacktrace/hooks/__init__.py +1 -0
- stacktrace/hooks/claude.py +239 -0
- stacktrace/hooks/session.py +76 -0
- stacktrace/monitor/costs.py +116 -15
- stacktrace/monitor/render.py +33 -4
- stacktrace/monitor/site/app.js +44 -10
- stacktrace/monitor/site/cost.js +168 -25
- stacktrace/monitor/site/index.html +1 -1
- stacktrace/monitor/site/styles.css +14 -2
- stacktrace/policy_display.py +129 -0
- stacktrace/remote/cli.py +8 -44
- stacktrace/remote/client.py +159 -15
- stacktrace/remote/config.py +61 -2
- stacktrace/remote/detect_payload.py +37 -4
- stacktrace/remote/hook_policy.py +148 -0
- stacktrace/remote/policy_show.py +109 -0
- stacktrace/remote/sync.py +14 -2
- stacktrace/remote/upload_contract.py +35 -0
- stacktrace/sessions/entrypoint.py +43 -0
- stacktrace/sessions/protocols.py +5 -39
- stacktrace/telemetry/events.py +1 -0
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.13.0.dist-info}/METADATA +15 -7
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.13.0.dist-info}/RECORD +78 -57
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.13.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.13.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py
CHANGED
stacktrace/cli.py
CHANGED
|
@@ -17,7 +17,6 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import re
|
|
19
19
|
from datetime import UTC, datetime, timedelta
|
|
20
|
-
from importlib.metadata import version
|
|
21
20
|
from pathlib import Path
|
|
22
21
|
from typing import Final
|
|
23
22
|
|
|
@@ -52,8 +51,6 @@ PASSTHROUGH: Final = ("scan", "bom", "policy")
|
|
|
52
51
|
#: and `telemetry on` already says what it did with `installed`.
|
|
53
52
|
UNCOUNTED: Final = frozenset({"telemetry"})
|
|
54
53
|
|
|
55
|
-
HOMEPAGE = "https://stacktrace.ai"
|
|
56
|
-
|
|
57
54
|
#: The unit is required here and optional in `analysis.parse_since`. That is
|
|
58
55
|
#: the whole of the difference between the two spellings, and it is deliberate:
|
|
59
56
|
#: the forgiving parser also answers the upload path, whose value comes from a
|
|
@@ -122,7 +119,7 @@ def _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
|
|
|
122
119
|
return
|
|
123
120
|
# A build from a branch or PR reports its commit as a PEP 440 local label,
|
|
124
121
|
# `0.4.0+86eef86`; a release install prints the bare version.
|
|
125
|
-
click.echo(f"stacktrace {version_label()}
|
|
122
|
+
click.echo(f"stacktrace {version_label()}")
|
|
126
123
|
ctx.exit()
|
|
127
124
|
|
|
128
125
|
|
|
@@ -197,11 +194,11 @@ class SectionedGroup(click.Group):
|
|
|
197
194
|
raise
|
|
198
195
|
except (click.exceptions.Exit, click.ClickException) as error:
|
|
199
196
|
cause = error.__cause__
|
|
200
|
-
if cause is not None and
|
|
197
|
+
if cause is not None and not _uncounted(ctx):
|
|
201
198
|
emit_error(cause, "cli")
|
|
202
199
|
raise
|
|
203
200
|
except Exception as error:
|
|
204
|
-
if
|
|
201
|
+
if not _uncounted(ctx):
|
|
205
202
|
emit_error(error, "cli")
|
|
206
203
|
raise
|
|
207
204
|
_count_run(ctx)
|
|
@@ -234,9 +231,15 @@ class SectionedGroup(click.Group):
|
|
|
234
231
|
formatter.write_dl(rows)
|
|
235
232
|
|
|
236
233
|
|
|
234
|
+
def _uncounted(ctx: click.Context) -> bool:
|
|
235
|
+
# An internal synchronous native hook is not a developer command and must
|
|
236
|
+
# never initialize an analytics client on the action path (ADR-0131).
|
|
237
|
+
return ctx.invoked_subcommand in UNCOUNTED or ctx.meta.get("internal_admission") is True
|
|
238
|
+
|
|
239
|
+
|
|
237
240
|
def _count_run(ctx: click.Context) -> None:
|
|
238
241
|
name = ctx.invoked_subcommand
|
|
239
|
-
if
|
|
242
|
+
if not _uncounted(ctx):
|
|
240
243
|
emit("command_run", command=command_of(name))
|
|
241
244
|
|
|
242
245
|
|
|
@@ -247,7 +250,7 @@ def _count_run(ctx: click.Context) -> None:
|
|
|
247
250
|
expose_value=False,
|
|
248
251
|
is_eager=True,
|
|
249
252
|
callback=_show_version,
|
|
250
|
-
help="Show the stacktrace
|
|
253
|
+
help="Show the stacktrace version (and build commit) and exit.",
|
|
251
254
|
)
|
|
252
255
|
def main() -> None:
|
|
253
256
|
"""Stacktrace: agent composition analysis, detection and upload."""
|
|
@@ -433,8 +436,9 @@ def detect(
|
|
|
433
436
|
One network call is made on every run, and it is not the detector's:
|
|
434
437
|
correlation matches advisories by sending the package coordinates of the
|
|
435
438
|
components a session invoked to osv.dev. Coordinates only, so this is not
|
|
436
|
-
an offline run, and without stage three it is the only thing
|
|
437
|
-
|
|
439
|
+
an offline run, and without stage three it is the only thing detection
|
|
440
|
+
sends. Usage telemetry, on by default, is separate and carries no session
|
|
441
|
+
content (ADR-0039); `stacktrace telemetry show` prints all of it.
|
|
438
442
|
"""
|
|
439
443
|
analyzer = resolve_analyzer(analyzer, reasoning)
|
|
440
444
|
try:
|
stacktrace/cloud/__init__.py
CHANGED
|
@@ -1,38 +1,10 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Rather than paste that into the setup-script box, a setup script installs the
|
|
10
|
-
CLI and runs this:
|
|
11
|
-
|
|
12
|
-
UV_TOOL_DIR=/usr/local/share/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin \
|
|
13
|
-
uv tool install stacktrace-cli
|
|
14
|
-
stacktrace-cloud-setup
|
|
15
|
-
|
|
16
|
-
Or, equivalently, one hosted line that wraps exactly those two steps (see
|
|
17
|
-
`stacktrace-site`'s `public/remote-install.sh`). Download before running rather
|
|
18
|
-
than piping directly into `sh`: without `pipefail` (the default in the `sh`
|
|
19
|
-
that a setup-script box runs), a failed `curl` still lets `sh` read an empty
|
|
20
|
-
script and exit 0, so a broken download would otherwise snapshot an
|
|
21
|
-
environment with neither the CLI nor the boot hook and report success:
|
|
22
|
-
|
|
23
|
-
curl -fsSL https://stacktrace.ai/remote-install.sh -o /tmp/remote-install.sh \
|
|
24
|
-
&& sh /tmp/remote-install.sh
|
|
25
|
-
|
|
26
|
-
Both variables matter: setup runs as root, and `uv tool install`'s root
|
|
27
|
-
defaults put the tool's environment under `/root/.local/share/uv/tools` and the
|
|
28
|
-
linked executable under `/root/.local/bin` — both inside `/root`, which a later
|
|
29
|
-
non-root session user cannot traverse. `UV_TOOL_BIN_DIR` alone moves the
|
|
30
|
-
symlink but not the environment it points into, so `command -v stacktrace` would
|
|
31
|
-
find the binary while running it still fails; both must move.
|
|
32
|
-
|
|
33
|
-
The work itself is `setup.sh`, shipped beside this module so it travels in the
|
|
34
|
-
wheel. This shim only locates it and hands off, so the logic stays one auditable
|
|
35
|
-
bash file rather than being reimplemented in Python.
|
|
1
|
+
"""Prepare plugin-owned hooks and asset identity from a remote startup script.
|
|
2
|
+
|
|
3
|
+
The shared installer calls `stacktrace-cloud-setup --user-profile` as the
|
|
4
|
+
selected user. This prepares identity and update metadata without system
|
|
5
|
+
writes. Legacy provisioning retains asset and update records for existing
|
|
6
|
+
recipes. Agent installation belongs to the shared installer.
|
|
7
|
+
SessionStart restores the daemon after process loss. See ADR-0143.
|
|
36
8
|
"""
|
|
37
9
|
|
|
38
10
|
from __future__ import annotations
|
|
@@ -53,6 +25,11 @@ from pathlib import Path
|
|
|
53
25
|
|
|
54
26
|
|
|
55
27
|
def main() -> None:
|
|
28
|
+
if "--user-profile" in sys.argv[1:]:
|
|
29
|
+
from stacktrace.cloud.profile import main as prepare_profile
|
|
30
|
+
|
|
31
|
+
prepare_profile(sys.argv[1:])
|
|
32
|
+
return
|
|
56
33
|
script = Path(__file__).with_name("setup.sh")
|
|
57
34
|
# execvp so the process *becomes* bash: its exit status is the setup
|
|
58
35
|
# script's, which is what the environment reads to decide the session may
|
stacktrace/cloud/boot.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Recover the shared daemon on every host (ADR-0143). Remote recovery can
|
|
2
|
+
update the client before startup (ADR-0123). The SessionStart adapter runs
|
|
3
|
+
this worker detached with the state directory and installer update record.
|
|
4
|
+
|
|
5
|
+
One worker at a time holds `boot.lock`, from its own `daemon status` check
|
|
6
|
+
until the daemon it starts answers. So a worker never replaces the client's
|
|
7
|
+
files while a daemon runs or is starting: a second hook's worker waits, checks
|
|
8
|
+
again, and finds the daemon up.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import os
|
|
13
|
+
import pwd
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
STATE, UPDATE_ENV = "", ""
|
|
21
|
+
#: How long an update may take before the installed client runs instead.
|
|
22
|
+
UPDATE_SECONDS = 120
|
|
23
|
+
#: How long the lock waits for the daemon this worker started to answer.
|
|
24
|
+
START_SECONDS = 30
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def note(text):
|
|
28
|
+
with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
|
|
29
|
+
log.write(f"{datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} {text}\n") # noqa: UP017 - legacy wrapper uses system Python, including 3.10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def running():
|
|
33
|
+
"""What `daemon status` says: yes, no, unresponsive, or "" for no answer."""
|
|
34
|
+
try:
|
|
35
|
+
status = subprocess.run(
|
|
36
|
+
["stacktrace", "daemon", "status"],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
timeout=30,
|
|
40
|
+
check=False,
|
|
41
|
+
).stdout
|
|
42
|
+
except (OSError, subprocess.SubprocessError):
|
|
43
|
+
return ""
|
|
44
|
+
for line in status.splitlines():
|
|
45
|
+
if line.startswith("running: "):
|
|
46
|
+
return line[len("running: ") :].strip()
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def recorded():
|
|
51
|
+
"""The uv and the UV_* directories setup wrote, as `KEY=value` lines."""
|
|
52
|
+
try:
|
|
53
|
+
with open(UPDATE_ENV, encoding="utf-8") as stream:
|
|
54
|
+
lines = stream.read().splitlines()
|
|
55
|
+
except OSError:
|
|
56
|
+
return {}
|
|
57
|
+
values = {}
|
|
58
|
+
for line in lines:
|
|
59
|
+
words = shlex.split(line)
|
|
60
|
+
if words[:1] == ["export"]:
|
|
61
|
+
words = words[1:]
|
|
62
|
+
if len(words) == 1 and "=" in words[0]:
|
|
63
|
+
key, value = words[0].split("=", 1)
|
|
64
|
+
values[key] = value
|
|
65
|
+
return values
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def update():
|
|
69
|
+
"""Upgrade the tool install setup recorded, as a user who can write it;
|
|
70
|
+
else say why not. No file: the client is pinned, or setup found no uv."""
|
|
71
|
+
values = recorded()
|
|
72
|
+
if not values:
|
|
73
|
+
return
|
|
74
|
+
uv = values.get("STACKTRACE_UV", "")
|
|
75
|
+
directories = [values[key] for key in ("UV_TOOL_DIR", "UV_TOOL_BIN_DIR") if values.get(key)]
|
|
76
|
+
if not (uv and os.access(uv, os.X_OK)) or not all(os.access(d, os.W_OK) for d in directories):
|
|
77
|
+
user = pwd.getpwuid(os.getuid()).pw_name
|
|
78
|
+
note(
|
|
79
|
+
f"not updating: {user} cannot run uv or write the install; "
|
|
80
|
+
"rebuild the environment to update the client"
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
environment = {**os.environ, **{k: v for k, v in values.items() if k.startswith("UV_")}}
|
|
84
|
+
with open(os.path.join(STATE, "update.log"), "a", encoding="utf-8") as log:
|
|
85
|
+
try:
|
|
86
|
+
subprocess.run(
|
|
87
|
+
[uv, "tool", "upgrade", "stacktrace-cli"],
|
|
88
|
+
env=environment,
|
|
89
|
+
stdin=subprocess.DEVNULL,
|
|
90
|
+
stdout=log,
|
|
91
|
+
stderr=subprocess.STDOUT,
|
|
92
|
+
timeout=UPDATE_SECONDS,
|
|
93
|
+
check=False,
|
|
94
|
+
)
|
|
95
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
96
|
+
note(f"update stopped: {type(error).__name__}; the installed client runs")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def configure():
|
|
100
|
+
"""Session credentials belong to this user; subscribers fail independently."""
|
|
101
|
+
commands = []
|
|
102
|
+
if os.environ.get("STACKTRACE_REMOTE_TOKEN"):
|
|
103
|
+
# Click reads credentials from the environment, not the process list.
|
|
104
|
+
commands.append(["stacktrace", "remote", "configure"])
|
|
105
|
+
webhook = os.environ.get("STACKTRACE_SLACK_WEBHOOK_URL")
|
|
106
|
+
if webhook:
|
|
107
|
+
commands.append(
|
|
108
|
+
[
|
|
109
|
+
"stacktrace",
|
|
110
|
+
"webhook",
|
|
111
|
+
"configure",
|
|
112
|
+
"--url",
|
|
113
|
+
webhook,
|
|
114
|
+
"--secret",
|
|
115
|
+
os.environ.get("STACKTRACE_WEBHOOK_SECRET", ""),
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
for arguments in commands:
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
arguments,
|
|
122
|
+
stdin=subprocess.DEVNULL,
|
|
123
|
+
stdout=subprocess.DEVNULL,
|
|
124
|
+
stderr=subprocess.DEVNULL,
|
|
125
|
+
timeout=30,
|
|
126
|
+
check=False,
|
|
127
|
+
)
|
|
128
|
+
if result.returncode:
|
|
129
|
+
note("session configuration failed; using saved configuration")
|
|
130
|
+
except (OSError, subprocess.SubprocessError):
|
|
131
|
+
note("session configuration unavailable; using saved configuration")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ensure_daemon():
|
|
135
|
+
lock = os.open(os.path.join(STATE, "boot.lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
|
136
|
+
try:
|
|
137
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
138
|
+
configure()
|
|
139
|
+
_ensure_daemon()
|
|
140
|
+
finally:
|
|
141
|
+
os.close(lock)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ensure_daemon():
|
|
145
|
+
answer = running()
|
|
146
|
+
if answer == "yes":
|
|
147
|
+
return
|
|
148
|
+
# Only a status of no daemon lets the update run: `unresponsive` is a
|
|
149
|
+
# daemon holding its lock that has not answered yet, and an error or no
|
|
150
|
+
# answer says nothing. Either may be a live client.
|
|
151
|
+
if answer == "no":
|
|
152
|
+
if os.environ.get("CLAUDE_CODE_REMOTE") == "true":
|
|
153
|
+
update()
|
|
154
|
+
else:
|
|
155
|
+
note(f"not updating: daemon status said {answer or 'nothing'}, not that no daemon runs")
|
|
156
|
+
with open(os.path.join(STATE, "daemon.log"), "a", encoding="utf-8") as out:
|
|
157
|
+
subprocess.Popen(
|
|
158
|
+
["stacktrace", "daemon", "start", "--no-reasoning"],
|
|
159
|
+
stdin=subprocess.DEVNULL,
|
|
160
|
+
stdout=out,
|
|
161
|
+
stderr=subprocess.STDOUT,
|
|
162
|
+
start_new_session=True,
|
|
163
|
+
)
|
|
164
|
+
deadline = time.monotonic() + START_SECONDS
|
|
165
|
+
while time.monotonic() < deadline and running() != "yes":
|
|
166
|
+
time.sleep(0.5)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
os.makedirs(STATE, mode=0o700, exist_ok=True)
|
|
171
|
+
os.chmod(STATE, 0o700)
|
|
172
|
+
ensure_daemon()
|
|
173
|
+
if len(sys.argv) > 3:
|
|
174
|
+
import json
|
|
175
|
+
|
|
176
|
+
from stacktrace.hooks.session import register
|
|
177
|
+
|
|
178
|
+
register(json.loads(sys.argv[3]))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
STATE, UPDATE_ENV = sys.argv[1], sys.argv[2]
|
|
183
|
+
try:
|
|
184
|
+
main()
|
|
185
|
+
except Exception: # noqa: BLE001 - never expose native payloads or credentials
|
|
186
|
+
note("session bootstrap failed; inspect daemon status and rerun setup")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Prepare remote state as the selected user, without privileged system files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from stacktrace.daemon.paths import RuntimePaths
|
|
15
|
+
from stacktrace.private_state import ensure_private_directory
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write(path: Path, value: str) -> None:
|
|
19
|
+
descriptor, name = tempfile.mkstemp(dir=path.parent)
|
|
20
|
+
try:
|
|
21
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
22
|
+
stream.write(value)
|
|
23
|
+
os.replace(name, path)
|
|
24
|
+
finally:
|
|
25
|
+
Path(name).unlink(missing_ok=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def prepare(environment: Mapping[str, str]) -> None:
|
|
29
|
+
config = Path.home() / ".config" / "stacktrace"
|
|
30
|
+
ensure_private_directory(config)
|
|
31
|
+
identity = config / "asset-id"
|
|
32
|
+
imposed = environment.get("STACKTRACE_ASSET_EXTERNAL_ID", "")
|
|
33
|
+
if imposed and (
|
|
34
|
+
len(imposed) > 255 or not imposed.isprintable() or any(c.isspace() for c in imposed)
|
|
35
|
+
):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"asset id must contain at most 255 printable characters without whitespace"
|
|
38
|
+
)
|
|
39
|
+
if imposed or not identity.exists():
|
|
40
|
+
_write(identity, (imposed or str(uuid.uuid4())) + "\n")
|
|
41
|
+
_write(config / "asset-display-prefix", "cloud\n")
|
|
42
|
+
|
|
43
|
+
state = RuntimePaths.from_environment().state.parent
|
|
44
|
+
ensure_private_directory(state)
|
|
45
|
+
uv = shutil.which("uv")
|
|
46
|
+
values = {}
|
|
47
|
+
if uv and environment.get("STACKTRACE_VERSION", "latest") in ("", "latest"):
|
|
48
|
+
values["STACKTRACE_UV"] = uv
|
|
49
|
+
for key in (
|
|
50
|
+
"UV_TOOL_DIR",
|
|
51
|
+
"UV_TOOL_BIN_DIR",
|
|
52
|
+
"UV_PYTHON_INSTALL_DIR",
|
|
53
|
+
"UV_PYTHON_PREFERENCE",
|
|
54
|
+
):
|
|
55
|
+
if environment.get(key):
|
|
56
|
+
values[key] = environment[key]
|
|
57
|
+
# Keep an empty record for a pin. Removing it would enable the legacy fallback.
|
|
58
|
+
_write(
|
|
59
|
+
state / "install-update.env", "".join(f"{k}={shlex.quote(v)}\n" for k, v in values.items())
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(arguments: list[str]) -> None:
|
|
64
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
65
|
+
parser.add_argument("--user-profile", action="store_true", required=True)
|
|
66
|
+
parser.add_argument("--asset-id")
|
|
67
|
+
options = parser.parse_args(arguments)
|
|
68
|
+
environment = dict(os.environ)
|
|
69
|
+
if options.asset_id is not None:
|
|
70
|
+
environment["STACKTRACE_ASSET_EXTERNAL_ID"] = options.asset_id
|
|
71
|
+
prepare(environment)
|