Add guest-only workspace secrets
Add explicit workspace secrets across the CLI, SDK, and MCP, with create-time secret definitions and per-call secret-to-env mapping for exec, shell open, and service start. Persist only safe secret metadata in workspace records, materialize secret files under /run/pyro-secrets, and redact secret values from exec output, shell reads, service logs, and surfaced errors. Fix the remaining real-guest shell gap by shipping bundled guest init alongside the guest agent and patching both into guest-backed workspace rootfs images before boot. The new init mounts devpts so PTY shells work on Firecracker guests, while reset continues to recreate the sandbox and re-materialize secrets from stored task-local secret material. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; and a real guest-backed Firecracker smoke covering workspace create with secrets, secret-backed exec, shell, service, reset, and delete.
This commit is contained in:
parent
18b8fd2a7d
commit
fc72fcd3a1
32 changed files with 1980 additions and 181 deletions
|
|
@ -87,6 +87,7 @@ class Pyro:
|
|||
network: bool = False,
|
||||
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
|
||||
seed_path: str | Path | None = None,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.create_workspace(
|
||||
environment=environment,
|
||||
|
|
@ -96,6 +97,7 @@ class Pyro:
|
|||
network=network,
|
||||
allow_host_compat=allow_host_compat,
|
||||
seed_path=seed_path,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
def exec_workspace(
|
||||
|
|
@ -104,11 +106,13 @@ class Pyro:
|
|||
*,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=timeout_seconds,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
def status_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
|
|
@ -170,12 +174,14 @@ class Pyro:
|
|||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
def read_shell(
|
||||
|
|
@ -234,6 +240,7 @@ class Pyro:
|
|||
readiness: dict[str, Any] | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.start_service(
|
||||
workspace_id,
|
||||
|
|
@ -243,6 +250,7 @@ class Pyro:
|
|||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
def list_services(self, workspace_id: str) -> dict[str, Any]:
|
||||
|
|
@ -403,6 +411,7 @@ class Pyro:
|
|||
network: bool = False,
|
||||
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
|
||||
seed_path: str | None = None,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create and start a persistent workspace."""
|
||||
return self.create_workspace(
|
||||
|
|
@ -413,6 +422,7 @@ class Pyro:
|
|||
network=network,
|
||||
allow_host_compat=allow_host_compat,
|
||||
seed_path=seed_path,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
|
|
@ -420,12 +430,14 @@ class Pyro:
|
|||
workspace_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run one command inside an existing persistent workspace."""
|
||||
return self.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=timeout_seconds,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
|
|
@ -490,9 +502,16 @@ class Pyro:
|
|||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Open a persistent interactive shell inside one workspace."""
|
||||
return self.open_shell(workspace_id, cwd=cwd, cols=cols, rows=rows)
|
||||
return self.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
async def shell_read(
|
||||
|
|
@ -554,6 +573,7 @@ class Pyro:
|
|||
ready_command: str | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a named long-running service inside a workspace."""
|
||||
readiness: dict[str, Any] | None = None
|
||||
|
|
@ -573,6 +593,7 @@ class Pyro:
|
|||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
|
|
|
|||
|
|
@ -168,6 +168,18 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
|||
print(f"Workspace seed: {mode} from {seed_path}")
|
||||
else:
|
||||
print(f"Workspace seed: {mode}")
|
||||
secrets = payload.get("secrets")
|
||||
if isinstance(secrets, list) and secrets:
|
||||
secret_descriptions = []
|
||||
for secret in secrets:
|
||||
if not isinstance(secret, dict):
|
||||
continue
|
||||
secret_descriptions.append(
|
||||
f"{str(secret.get('name', 'unknown'))} "
|
||||
f"({str(secret.get('source_kind', 'literal'))})"
|
||||
)
|
||||
if secret_descriptions:
|
||||
print("Secrets: " + ", ".join(secret_descriptions))
|
||||
print(f"Execution mode: {str(payload.get('execution_mode', 'pending'))}")
|
||||
print(
|
||||
f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / "
|
||||
|
|
@ -671,6 +683,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace create debian:12
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --secret API_TOKEN=expected
|
||||
pyro workspace sync push WORKSPACE_ID ./changes
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
|
|
@ -724,6 +737,20 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"before the workspace is returned."
|
||||
),
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--secret",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=VALUE",
|
||||
help="Persist one literal UTF-8 secret for this workspace.",
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--secret-file",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=PATH",
|
||||
help="Persist one UTF-8 secret copied from a host file at create time.",
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -736,7 +763,14 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"Run one non-interactive command in the persistent `/workspace` "
|
||||
"for a workspace."
|
||||
),
|
||||
epilog="Example:\n pyro workspace exec WORKSPACE_ID -- cat note.txt",
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace exec WORKSPACE_ID -- cat note.txt
|
||||
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- \
|
||||
sh -lc 'test \"$API_TOKEN\" = \"expected\"'
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_exec_parser.add_argument(
|
||||
|
|
@ -750,6 +784,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=30,
|
||||
help="Maximum time allowed for the workspace command.",
|
||||
)
|
||||
workspace_exec_parser.add_argument(
|
||||
"--secret-env",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable for this exec.",
|
||||
)
|
||||
workspace_exec_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1016,6 +1057,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=30,
|
||||
help="Shell terminal height in rows.",
|
||||
)
|
||||
workspace_shell_open_parser.add_argument(
|
||||
"--secret-env",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable in the shell.",
|
||||
)
|
||||
workspace_shell_open_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1181,6 +1229,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN -- \
|
||||
sh -lc 'test \"$API_TOKEN\" = \"expected\"; touch .ready; \
|
||||
while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
"""
|
||||
|
|
@ -1222,6 +1273,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=DEFAULT_SERVICE_READY_INTERVAL_MS,
|
||||
help="Polling interval between readiness checks.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--secret-env",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable for this service.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1438,6 +1496,38 @@ def _require_command(command_args: list[str]) -> str:
|
|||
return shlex.join(command_args)
|
||||
|
||||
|
||||
def _parse_workspace_secret_option(value: str) -> dict[str, str]:
|
||||
name, sep, secret_value = value.partition("=")
|
||||
if sep == "" or name.strip() == "" or secret_value == "":
|
||||
raise ValueError("workspace secrets must use NAME=VALUE")
|
||||
return {"name": name.strip(), "value": secret_value}
|
||||
|
||||
|
||||
def _parse_workspace_secret_file_option(value: str) -> dict[str, str]:
|
||||
name, sep, file_path = value.partition("=")
|
||||
if sep == "" or name.strip() == "" or file_path.strip() == "":
|
||||
raise ValueError("workspace secret files must use NAME=PATH")
|
||||
return {"name": name.strip(), "file_path": file_path.strip()}
|
||||
|
||||
|
||||
def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]:
|
||||
parsed: dict[str, str] = {}
|
||||
for raw_value in values:
|
||||
secret_name, sep, env_name = raw_value.partition("=")
|
||||
normalized_secret_name = secret_name.strip()
|
||||
if normalized_secret_name == "":
|
||||
raise ValueError("workspace secret env mappings must name a secret")
|
||||
normalized_env_name = env_name.strip() if sep != "" else normalized_secret_name
|
||||
if normalized_env_name == "":
|
||||
raise ValueError("workspace secret env mappings must name an environment variable")
|
||||
if normalized_secret_name in parsed:
|
||||
raise ValueError(
|
||||
f"workspace secret env mapping references {normalized_secret_name!r} more than once"
|
||||
)
|
||||
parsed[normalized_secret_name] = normalized_env_name
|
||||
return parsed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
pyro = Pyro()
|
||||
|
|
@ -1529,6 +1619,16 @@ def main() -> None:
|
|||
return
|
||||
if args.command == "workspace":
|
||||
if args.workspace_command == "create":
|
||||
secrets = [
|
||||
*(
|
||||
_parse_workspace_secret_option(value)
|
||||
for value in getattr(args, "secret", [])
|
||||
),
|
||||
*(
|
||||
_parse_workspace_secret_file_option(value)
|
||||
for value in getattr(args, "secret_file", [])
|
||||
),
|
||||
]
|
||||
payload = pyro.create_workspace(
|
||||
environment=args.environment,
|
||||
vcpu_count=args.vcpu_count,
|
||||
|
|
@ -1537,6 +1637,7 @@ def main() -> None:
|
|||
network=args.network,
|
||||
allow_host_compat=args.allow_host_compat,
|
||||
seed_path=args.seed_path,
|
||||
secrets=secrets or None,
|
||||
)
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
|
|
@ -1545,12 +1646,14 @@ def main() -> None:
|
|||
return
|
||||
if args.workspace_command == "exec":
|
||||
command = _require_command(args.command_args)
|
||||
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
|
||||
if bool(args.json):
|
||||
try:
|
||||
payload = pyro.exec_workspace(
|
||||
args.workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
|
|
@ -1562,6 +1665,7 @@ def main() -> None:
|
|||
args.workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
|
|
@ -1703,12 +1807,14 @@ def main() -> None:
|
|||
return
|
||||
if args.workspace_command == "shell":
|
||||
if args.workspace_shell_command == "open":
|
||||
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
|
||||
try:
|
||||
payload = pyro.open_shell(
|
||||
args.workspace_id,
|
||||
cwd=args.cwd,
|
||||
cols=args.cols,
|
||||
rows=args.rows,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
@ -1825,6 +1931,7 @@ def main() -> None:
|
|||
elif args.ready_command is not None:
|
||||
readiness = {"type": "command", "command": args.ready_command}
|
||||
command = _require_command(args.command_args)
|
||||
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
|
||||
try:
|
||||
payload = pyro.start_service(
|
||||
args.workspace_id,
|
||||
|
|
@ -1834,6 +1941,7 @@ def main() -> None:
|
|||
readiness=readiness,
|
||||
ready_timeout_seconds=args.ready_timeout_seconds,
|
||||
ready_interval_ms=args.ready_interval_ms,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
|
|||
|
|
@ -30,8 +30,11 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
|
|||
"--network",
|
||||
"--allow-host-compat",
|
||||
"--seed-path",
|
||||
"--secret",
|
||||
"--secret-file",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
|
||||
|
|
@ -45,11 +48,18 @@ PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = (
|
|||
"--ready-command",
|
||||
"--ready-timeout-seconds",
|
||||
"--ready-interval-ms",
|
||||
"--secret-env",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ("--cwd", "--cols", "--rows", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
|
||||
"--cwd",
|
||||
"--cols",
|
||||
"--rows",
|
||||
"--secret-env",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class RuntimePaths:
|
|||
firecracker_bin: Path
|
||||
jailer_bin: Path
|
||||
guest_agent_path: Path | None
|
||||
guest_init_path: Path | None
|
||||
artifacts_dir: Path
|
||||
notice_path: Path
|
||||
manifest: dict[str, Any]
|
||||
|
|
@ -93,6 +94,7 @@ def resolve_runtime_paths(
|
|||
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
|
||||
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
|
||||
guest_agent_path: Path | None = None
|
||||
guest_init_path: Path | None = None
|
||||
guest = manifest.get("guest")
|
||||
if isinstance(guest, dict):
|
||||
agent_entry = guest.get("agent")
|
||||
|
|
@ -100,11 +102,18 @@ def resolve_runtime_paths(
|
|||
raw_agent_path = agent_entry.get("path")
|
||||
if isinstance(raw_agent_path, str):
|
||||
guest_agent_path = bundle_root / raw_agent_path
|
||||
init_entry = guest.get("init")
|
||||
if isinstance(init_entry, dict):
|
||||
raw_init_path = init_entry.get("path")
|
||||
if isinstance(raw_init_path, str):
|
||||
guest_init_path = bundle_root / raw_init_path
|
||||
artifacts_dir = bundle_root / "profiles"
|
||||
|
||||
required_paths = [firecracker_bin, jailer_bin]
|
||||
if guest_agent_path is not None:
|
||||
required_paths.append(guest_agent_path)
|
||||
if guest_init_path is not None:
|
||||
required_paths.append(guest_init_path)
|
||||
|
||||
for path in required_paths:
|
||||
if not path.exists():
|
||||
|
|
@ -126,12 +135,17 @@ def resolve_runtime_paths(
|
|||
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
|
||||
)
|
||||
if isinstance(guest, dict):
|
||||
agent_entry = guest.get("agent")
|
||||
if isinstance(agent_entry, dict):
|
||||
raw_path = agent_entry.get("path")
|
||||
raw_hash = agent_entry.get("sha256")
|
||||
for entry_name, malformed_message in (
|
||||
("agent", "runtime guest agent manifest entry is malformed"),
|
||||
("init", "runtime guest init manifest entry is malformed"),
|
||||
):
|
||||
guest_entry = guest.get(entry_name)
|
||||
if not isinstance(guest_entry, dict):
|
||||
continue
|
||||
raw_path = guest_entry.get("path")
|
||||
raw_hash = guest_entry.get("sha256")
|
||||
if not isinstance(raw_path, str) or not isinstance(raw_hash, str):
|
||||
raise RuntimeError("runtime guest agent manifest entry is malformed")
|
||||
raise RuntimeError(malformed_message)
|
||||
full_path = bundle_root / raw_path
|
||||
actual = _sha256(full_path)
|
||||
if actual != raw_hash:
|
||||
|
|
@ -145,6 +159,7 @@ def resolve_runtime_paths(
|
|||
firecracker_bin=firecracker_bin,
|
||||
jailer_bin=jailer_bin,
|
||||
guest_agent_path=guest_agent_path,
|
||||
guest_init_path=guest_init_path,
|
||||
artifacts_dir=artifacts_dir,
|
||||
notice_path=notice_path,
|
||||
manifest=manifest,
|
||||
|
|
@ -227,6 +242,7 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
|||
"firecracker_bin": str(paths.firecracker_bin),
|
||||
"jailer_bin": str(paths.jailer_bin),
|
||||
"guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None,
|
||||
"guest_init_path": str(paths.guest_init_path) if paths.guest_init_path else None,
|
||||
"artifacts_dir": str(paths.artifacts_dir),
|
||||
"artifacts_present": paths.artifacts_dir.exists(),
|
||||
"notice_path": str(paths.notice_path),
|
||||
|
|
|
|||
57
src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init
Normal file
57
src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
AGENT=/opt/pyro/bin/pyro_guest_agent.py
|
||||
|
||||
mount -t proc proc /proc || true
|
||||
mount -t sysfs sysfs /sys || true
|
||||
mount -t devtmpfs devtmpfs /dev || true
|
||||
mkdir -p /dev/pts /run /tmp
|
||||
mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true
|
||||
hostname pyro-vm || true
|
||||
|
||||
cmdline="$(cat /proc/cmdline 2>/dev/null || true)"
|
||||
|
||||
get_arg() {
|
||||
key="$1"
|
||||
for token in $cmdline; do
|
||||
case "$token" in
|
||||
"$key"=*)
|
||||
printf '%s' "${token#*=}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ip link set lo up || true
|
||||
if ip link show eth0 >/dev/null 2>&1; then
|
||||
ip link set eth0 up || true
|
||||
guest_ip="$(get_arg pyro.guest_ip || true)"
|
||||
gateway_ip="$(get_arg pyro.gateway_ip || true)"
|
||||
netmask="$(get_arg pyro.netmask || true)"
|
||||
dns_csv="$(get_arg pyro.dns || true)"
|
||||
if [ -n "$guest_ip" ] && [ -n "$netmask" ]; then
|
||||
ip addr add "$guest_ip/$netmask" dev eth0 || true
|
||||
fi
|
||||
if [ -n "$gateway_ip" ]; then
|
||||
ip route add default via "$gateway_ip" dev eth0 || true
|
||||
fi
|
||||
if [ -n "$dns_csv" ]; then
|
||||
: > /etc/resolv.conf
|
||||
old_ifs="$IFS"
|
||||
IFS=,
|
||||
for dns in $dns_csv; do
|
||||
printf 'nameserver %s\n' "$dns" >> /etc/resolv.conf
|
||||
done
|
||||
IFS="$old_ifs"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$AGENT" ]; then
|
||||
python3 "$AGENT" &
|
||||
fi
|
||||
|
||||
exec /bin/sh -lc 'trap : TERM INT; while true; do sleep 3600; done'
|
||||
|
|
@ -10,6 +10,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
|
|
@ -29,6 +30,7 @@ BUFFER_SIZE = 65536
|
|||
WORKSPACE_ROOT = PurePosixPath("/workspace")
|
||||
SHELL_ROOT = Path("/run/pyro-shells")
|
||||
SERVICE_ROOT = Path("/run/pyro-services")
|
||||
SECRET_ROOT = Path("/run/pyro-secrets")
|
||||
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
SHELL_SIGNAL_MAP = {
|
||||
"HUP": signal.SIGHUP,
|
||||
|
|
@ -42,6 +44,17 @@ _SHELLS: dict[str, "GuestShellSession"] = {}
|
|||
_SHELLS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _redact_text(text: str, redact_values: list[str]) -> str:
|
||||
redacted = text
|
||||
for secret_value in sorted(
|
||||
{item for item in redact_values if item != ""},
|
||||
key=len,
|
||||
reverse=True,
|
||||
):
|
||||
redacted = redacted.replace(secret_value, "[REDACTED]")
|
||||
return redacted
|
||||
|
||||
|
||||
def _read_request(conn: socket.socket) -> dict[str, Any]:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
|
|
@ -139,6 +152,15 @@ def _service_metadata_path(service_name: str) -> Path:
|
|||
return SERVICE_ROOT / f"{service_name}.json"
|
||||
|
||||
|
||||
def _normalize_secret_name(secret_name: str) -> str:
|
||||
normalized = secret_name.strip()
|
||||
if normalized == "":
|
||||
raise RuntimeError("secret name is required")
|
||||
if re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$", normalized) is None:
|
||||
raise RuntimeError("secret name is invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
|
||||
target = link_target.strip()
|
||||
if target == "":
|
||||
|
|
@ -215,6 +237,49 @@ def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _install_secrets_archive(payload: bytes) -> dict[str, Any]:
|
||||
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
for existing in SECRET_ROOT.iterdir():
|
||||
if existing.is_dir() and not existing.is_symlink():
|
||||
shutil.rmtree(existing, ignore_errors=True)
|
||||
else:
|
||||
existing.unlink(missing_ok=True)
|
||||
bytes_written = 0
|
||||
entry_count = 0
|
||||
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive:
|
||||
for member in archive.getmembers():
|
||||
member_name = _normalize_member_name(member.name)
|
||||
target_path = SECRET_ROOT.joinpath(*member_name.parts)
|
||||
entry_count += 1
|
||||
if member.isdir():
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
target_path.chmod(0o700)
|
||||
continue
|
||||
if member.isfile():
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.parent.chmod(0o700)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise RuntimeError(f"failed to read secret archive member: {member.name}")
|
||||
with target_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = source.read(BUFFER_SIZE)
|
||||
if chunk == b"":
|
||||
break
|
||||
handle.write(chunk)
|
||||
target_path.chmod(0o600)
|
||||
bytes_written += member.size
|
||||
continue
|
||||
if member.issym() or member.islnk():
|
||||
raise RuntimeError(f"secret archive may not contain links: {member.name}")
|
||||
raise RuntimeError(f"unsupported secret archive member type: {member.name}")
|
||||
return {
|
||||
"destination": str(SECRET_ROOT),
|
||||
"entry_count": entry_count,
|
||||
"bytes_written": bytes_written,
|
||||
}
|
||||
|
||||
|
||||
def _inspect_archive(archive_path: Path) -> tuple[int, int]:
|
||||
entry_count = 0
|
||||
bytes_written = 0
|
||||
|
|
@ -263,13 +328,22 @@ def _prepare_export_archive(path: str) -> dict[str, Any]:
|
|||
raise
|
||||
|
||||
|
||||
def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]:
|
||||
def _run_command(
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
command_env = os.environ.copy()
|
||||
if env is not None:
|
||||
command_env.update(env)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["/bin/sh", "-lc", command],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=command_env,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
|
@ -293,6 +367,16 @@ def _set_pty_size(fd: int, rows: int, cols: int) -> None:
|
|||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
def _shell_argv(*, interactive: bool) -> list[str]:
|
||||
shell_program = shutil.which("bash") or "/bin/sh"
|
||||
argv = [shell_program]
|
||||
if shell_program.endswith("bash"):
|
||||
argv.extend(["--noprofile", "--norc"])
|
||||
if interactive:
|
||||
argv.append("-i")
|
||||
return argv
|
||||
|
||||
|
||||
class GuestShellSession:
|
||||
"""In-guest PTY-backed interactive shell session."""
|
||||
|
||||
|
|
@ -304,6 +388,8 @@ class GuestShellSession:
|
|||
cwd_text: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> None:
|
||||
self.shell_id = shell_id
|
||||
self.cwd = cwd_text
|
||||
|
|
@ -316,6 +402,7 @@ class GuestShellSession:
|
|||
self._lock = threading.RLock()
|
||||
self._output = ""
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
self._redact_values = list(redact_values or [])
|
||||
self._metadata_path = SHELL_ROOT / f"{shell_id}.json"
|
||||
self._log_path = SHELL_ROOT / f"{shell_id}.log"
|
||||
self._master_fd: int | None = None
|
||||
|
|
@ -331,8 +418,10 @@ class GuestShellSession:
|
|||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
if env_overrides is not None:
|
||||
env.update(env_overrides)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc", "-i"],
|
||||
_shell_argv(interactive=True),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
|
|
@ -371,8 +460,9 @@ class GuestShellSession:
|
|||
|
||||
def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
clamped_cursor = min(max(cursor, 0), len(self._output))
|
||||
output = self._output[clamped_cursor : clamped_cursor + max_chars]
|
||||
redacted_output = _redact_text(self._output, self._redact_values)
|
||||
clamped_cursor = min(max(cursor, 0), len(redacted_output))
|
||||
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
|
||||
next_cursor = clamped_cursor + len(output)
|
||||
payload = self.summary()
|
||||
payload.update(
|
||||
|
|
@ -380,7 +470,7 @@ class GuestShellSession:
|
|||
"cursor": clamped_cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"output": output,
|
||||
"truncated": next_cursor < len(self._output),
|
||||
"truncated": next_cursor < len(redacted_output),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
|
@ -514,6 +604,8 @@ def _create_shell(
|
|||
cwd_text: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> GuestShellSession:
|
||||
_, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
with _SHELLS_LOCK:
|
||||
|
|
@ -525,6 +617,8 @@ def _create_shell(
|
|||
cwd_text=cwd_text,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
_SHELLS[shell_id] = session
|
||||
return session
|
||||
|
|
@ -634,7 +728,12 @@ def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict
|
|||
return refreshed
|
||||
|
||||
|
||||
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
|
||||
def _run_readiness_probe(
|
||||
readiness: dict[str, Any] | None,
|
||||
*,
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
if readiness is None:
|
||||
return True
|
||||
readiness_type = str(readiness["type"])
|
||||
|
|
@ -658,11 +757,15 @@ def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool
|
|||
except (urllib.error.URLError, TimeoutError, ValueError):
|
||||
return False
|
||||
if readiness_type == "command":
|
||||
command_env = os.environ.copy()
|
||||
if env is not None:
|
||||
command_env.update(env)
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["/bin/sh", "-lc", str(readiness["command"])],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=command_env,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
|
@ -678,6 +781,7 @@ def _start_service(
|
|||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
|
|
@ -718,9 +822,13 @@ def _start_service(
|
|||
encoding="utf-8",
|
||||
)
|
||||
runner_path.chmod(0o700)
|
||||
service_env = os.environ.copy()
|
||||
if env is not None:
|
||||
service_env.update(env)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[str(runner_path)],
|
||||
cwd=str(cwd_path),
|
||||
env=service_env,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
|
@ -747,7 +855,7 @@ def _start_service(
|
|||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path):
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path, env=env):
|
||||
payload["ready_at"] = time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
|
|
@ -817,16 +925,38 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
destination = str(request.get("destination", "/workspace"))
|
||||
payload = _read_exact(conn, archive_size)
|
||||
return _extract_archive(payload, destination)
|
||||
if action == "install_secrets":
|
||||
archive_size = int(request.get("archive_size", 0))
|
||||
if archive_size < 0:
|
||||
raise RuntimeError("archive_size must not be negative")
|
||||
payload = _read_exact(conn, archive_size)
|
||||
return _install_secrets_archive(payload)
|
||||
if action == "open_shell":
|
||||
shell_id = str(request.get("shell_id", "")).strip()
|
||||
if shell_id == "":
|
||||
raise RuntimeError("shell_id is required")
|
||||
cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace")))
|
||||
env_payload = request.get("env")
|
||||
env_overrides = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("shell env must be a JSON object")
|
||||
env_overrides = {
|
||||
_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()
|
||||
}
|
||||
redact_values_payload = request.get("redact_values")
|
||||
redact_values: list[str] | None = None
|
||||
if redact_values_payload is not None:
|
||||
if not isinstance(redact_values_payload, list):
|
||||
raise RuntimeError("redact_values must be a list")
|
||||
redact_values = [str(item) for item in redact_values_payload]
|
||||
session = _create_shell(
|
||||
shell_id=shell_id,
|
||||
cwd_text=cwd_text,
|
||||
cols=int(request.get("cols", 120)),
|
||||
rows=int(request.get("rows", 30)),
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
return session.summary()
|
||||
if action == "read_shell":
|
||||
|
|
@ -866,6 +996,15 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
cwd_text = str(request.get("cwd", "/workspace"))
|
||||
readiness = request.get("readiness")
|
||||
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
|
||||
env_payload = request.get("env")
|
||||
env = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("service env must be a JSON object")
|
||||
env = {
|
||||
_normalize_secret_name(str(key)): str(value)
|
||||
for key, value in env_payload.items()
|
||||
}
|
||||
return _start_service(
|
||||
service_name=service_name,
|
||||
command=command,
|
||||
|
|
@ -873,6 +1012,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
readiness=readiness_payload,
|
||||
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
|
||||
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
|
||||
env=env,
|
||||
)
|
||||
if action == "status_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
|
|
@ -887,12 +1027,19 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
return _stop_service(service_name)
|
||||
command = str(request.get("command", ""))
|
||||
timeout_seconds = int(request.get("timeout_seconds", 30))
|
||||
return _run_command(command, timeout_seconds)
|
||||
env_payload = request.get("env")
|
||||
env = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("exec env must be a JSON object")
|
||||
env = {_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()}
|
||||
return _run_command(command, timeout_seconds, env=env)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
family = getattr(socket, "AF_VSOCK", None)
|
||||
if family is None:
|
||||
raise SystemExit("AF_VSOCK is unavailable")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@
|
|||
"guest": {
|
||||
"agent": {
|
||||
"path": "guest/pyro_guest_agent.py",
|
||||
"sha256": "58dd2e09d05538228540d8c667b1acb42c2e6c579f7883b70d483072570f2499"
|
||||
"sha256": "76a0bd05b523bb952ab9eaf5a3f2e0cbf1fc458d1e44894e2c0d206b05896328"
|
||||
},
|
||||
"init": {
|
||||
"path": "guest/pyro-init",
|
||||
"sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b"
|
||||
}
|
||||
},
|
||||
"platform": "linux-x86_64",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "2.8.0"
|
||||
DEFAULT_CATALOG_VERSION = "2.9.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ class VsockExecClient:
|
|||
command: str,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestExecResponse:
|
||||
payload = self._request_json(
|
||||
|
|
@ -88,6 +89,7 @@ class VsockExecClient:
|
|||
{
|
||||
"command": command,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"env": env,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
|
|
@ -136,6 +138,40 @@ class VsockExecClient:
|
|||
bytes_written=int(payload.get("bytes_written", 0)),
|
||||
)
|
||||
|
||||
def install_secrets(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
archive_path: Path,
|
||||
*,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestArchiveResponse:
|
||||
request = {
|
||||
"action": "install_secrets",
|
||||
"archive_size": archive_path.stat().st_size,
|
||||
}
|
||||
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
|
||||
try:
|
||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
||||
with archive_path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
sock.sendall(chunk)
|
||||
payload = self._recv_json_payload(sock)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("guest secret install response must be a JSON object")
|
||||
error = payload.get("error")
|
||||
if error is not None:
|
||||
raise RuntimeError(str(error))
|
||||
return GuestArchiveResponse(
|
||||
destination=str(payload.get("destination", "/run/pyro-secrets")),
|
||||
entry_count=int(payload.get("entry_count", 0)),
|
||||
bytes_written=int(payload.get("bytes_written", 0)),
|
||||
)
|
||||
|
||||
def export_archive(
|
||||
self,
|
||||
guest_cid: int,
|
||||
|
|
@ -191,6 +227,8 @@ class VsockExecClient:
|
|||
cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestShellSummary:
|
||||
|
|
@ -203,6 +241,8 @@ class VsockExecClient:
|
|||
"cwd": cwd,
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"env": env,
|
||||
"redact_values": redact_values,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
|
|
@ -336,6 +376,7 @@ class VsockExecClient:
|
|||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -350,6 +391,7 @@ class VsockExecClient:
|
|||
"readiness": readiness,
|
||||
"ready_timeout_seconds": ready_timeout_seconds,
|
||||
"ready_interval_ms": ready_interval_ms,
|
||||
"env": env,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import codecs
|
|||
import fcntl
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
|
|
@ -29,6 +30,27 @@ _LOCAL_SHELLS: dict[str, "LocalShellSession"] = {}
|
|||
_LOCAL_SHELLS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _shell_argv(*, interactive: bool) -> list[str]:
|
||||
shell_program = shutil.which("bash") or "/bin/sh"
|
||||
argv = [shell_program]
|
||||
if shell_program.endswith("bash"):
|
||||
argv.extend(["--noprofile", "--norc"])
|
||||
if interactive:
|
||||
argv.append("-i")
|
||||
return argv
|
||||
|
||||
|
||||
def _redact_text(text: str, redact_values: list[str]) -> str:
|
||||
redacted = text
|
||||
for secret_value in sorted(
|
||||
{item for item in redact_values if item != ""},
|
||||
key=len,
|
||||
reverse=True,
|
||||
):
|
||||
redacted = redacted.replace(secret_value, "[REDACTED]")
|
||||
return redacted
|
||||
|
||||
|
||||
def _set_pty_size(fd: int, rows: int, cols: int) -> None:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
|
@ -45,6 +67,8 @@ class LocalShellSession:
|
|||
display_cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> None:
|
||||
self.shell_id = shell_id
|
||||
self.cwd = display_cwd
|
||||
|
|
@ -63,6 +87,7 @@ class LocalShellSession:
|
|||
self._reader: threading.Thread | None = None
|
||||
self._waiter: threading.Thread | None = None
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
self._redact_values = list(redact_values or [])
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
|
|
@ -71,13 +96,15 @@ class LocalShellSession:
|
|||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
if env_overrides is not None:
|
||||
env.update(env_overrides)
|
||||
|
||||
process: subprocess.Popen[bytes]
|
||||
try:
|
||||
master_fd, slave_fd = os.openpty()
|
||||
except OSError:
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc"],
|
||||
_shell_argv(interactive=False),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
|
|
@ -93,7 +120,7 @@ class LocalShellSession:
|
|||
try:
|
||||
_set_pty_size(slave_fd, rows, cols)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc", "-i"],
|
||||
_shell_argv(interactive=True),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
|
|
@ -133,8 +160,9 @@ class LocalShellSession:
|
|||
|
||||
def read(self, *, cursor: int, max_chars: int) -> dict[str, object]:
|
||||
with self._lock:
|
||||
clamped_cursor = min(max(cursor, 0), len(self._output))
|
||||
output = self._output[clamped_cursor : clamped_cursor + max_chars]
|
||||
redacted_output = _redact_text(self._output, self._redact_values)
|
||||
clamped_cursor = min(max(cursor, 0), len(redacted_output))
|
||||
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
|
||||
next_cursor = clamped_cursor + len(output)
|
||||
payload = self.summary()
|
||||
payload.update(
|
||||
|
|
@ -142,7 +170,7 @@ class LocalShellSession:
|
|||
"cursor": clamped_cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"output": output,
|
||||
"truncated": next_cursor < len(self._output),
|
||||
"truncated": next_cursor < len(redacted_output),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
|
@ -287,6 +315,8 @@ def create_local_shell(
|
|||
display_cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> LocalShellSession:
|
||||
session_key = f"{workspace_id}:{shell_id}"
|
||||
with _LOCAL_SHELLS_LOCK:
|
||||
|
|
@ -298,6 +328,8 @@ def create_local_shell(
|
|||
display_cwd=display_cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
_LOCAL_SHELLS[session_key] = session
|
||||
return session
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue