@mifune/agro 0.20.0-rc.2 → 0.21.0

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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/agro.js +236 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ The **AGRO CLI** (`agro`) — create an [AGRO](https://github.com/mifunedev/agro
4
4
  Docker sandbox for coding agents and drive its lifecycle from the command line.
5
5
 
6
6
  AGRO is a portable harness for running coding agents (Claude Code, Codex, Pi,
7
- and others) in an isolated Docker sandbox, with the agent's identity, skills, and crons
7
+ and others) in an isolated Docker sandbox, with the agent's identity and skills
8
8
  versioned in git. This package is the standalone `agro` CLI that creates sandboxes and
9
9
  manages their lifecycle.
10
10
 
@@ -54,7 +54,7 @@ To bind one of your own checkouts into the sandbox, equip it and pass `--checkou
54
54
 
55
55
  ```bash
56
56
  cd your-project
57
- agro vendor # vendor .agro/ + crons/ — and nothing else (compatibility window)
57
+ agro vendor # vendor .agro/ — and nothing else (compatibility window)
58
58
  agro sandbox install docker --checkout "$PWD" --name your-project
59
59
  ```
60
60
 
@@ -133,7 +133,7 @@ conflicting `AGRO_SANDBOX_IMAGE` values in the host shell or entry `.env` before
133
133
 
134
134
  `agro update` upgrades only the installed CLI. It does not recreate the sandbox
135
135
  or change its image. During the compatibility window `agro vendor` vendors
136
- `.agro/` + `crons/` into the current directory, equipping an empty checkout and upgrading an
136
+ `.agro/` into the current directory, equipping an empty checkout and upgrading an
137
137
  equipped one (`--from <dir>` / `--from-remote [--ref <ref>]`, `--dry-run`, `--force`); it
138
138
  writes nothing else and never prompts. `agro update` rejects those flags and points at
139
139
  `agro vendor`. `agro vendor` prefers the payload bundled into the CLI itself; with `--from-remote`
package/dist/agro.js CHANGED
@@ -908,6 +908,7 @@ var RESERVED_HOME_PATHS = [
908
908
  "/usr",
909
909
  "/var"
910
910
  ];
911
+ var EGRESS_MODES = ["open", "allowlist"];
911
912
  var SANDBOX_RUNTIMES = PROVISIONABLE_RUNTIMES;
912
913
  function configCheckout(config) {
913
914
  const value = config.checkout ?? config.repo;
@@ -927,11 +928,11 @@ function defaultAgroConfig(name) {
927
928
  ssh: false,
928
929
  sshPort: 2222,
929
930
  sshPasswordAuth: false,
930
- dockerSocket: false
931
+ dockerSocket: false,
932
+ egress: "open",
933
+ egressAllow: []
931
934
  },
932
935
  hermesDashboard: { enabled: false, port: 9119 },
933
- cron: { agentBin: "claude" },
934
- build: { skipPnpmInstall: false },
935
936
  image: { mode: "build", pullPolicy: "missing" },
936
937
  langfuse: {},
937
938
  composeOverrides: []
@@ -1019,16 +1020,14 @@ function validateAgroConfig(value) {
1019
1020
  expectBoolean(access, "sshPasswordAuth", "access.");
1020
1021
  expectString(access, "sshAuthorizedKeys", "access.");
1021
1022
  expectBoolean(access, "dockerSocket", "access.");
1023
+ expectEnum(access, "egress", "access.", EGRESS_MODES);
1024
+ expectStringArray(access, "egressAllow", "access.");
1022
1025
  }
1023
1026
  const dashboard = expectSection(record, "hermesDashboard");
1024
1027
  if (dashboard) {
1025
1028
  expectBoolean(dashboard, "enabled", "hermesDashboard.");
1026
1029
  expectPort(dashboard, "port", "hermesDashboard.");
1027
1030
  }
1028
- const cron = expectSection(record, "cron");
1029
- if (cron) expectString(cron, "agentBin", "cron.");
1030
- const build = expectSection(record, "build");
1031
- if (build) expectBoolean(build, "skipPnpmInstall", "build.");
1032
1031
  const image = expectSection(record, "image");
1033
1032
  if (image) {
1034
1033
  expectString(image, "ref", "image.");
@@ -1086,6 +1085,16 @@ function expectPort(record, key, prefix = "") {
1086
1085
  throw fieldError(`${prefix}${key}`, "must be an integer between 1 and 65535");
1087
1086
  }
1088
1087
  }
1088
+ function expectStringArray(record, key, prefix = "") {
1089
+ const value = record[key];
1090
+ if (value === void 0) return;
1091
+ if (!Array.isArray(value)) throw fieldError(`${prefix}${key}`, "must be an array of strings");
1092
+ for (const entry of value) {
1093
+ if (typeof entry !== "string") {
1094
+ throw fieldError(`${prefix}${key}`, "must be an array of strings");
1095
+ }
1096
+ }
1097
+ }
1089
1098
  function expectEnum(record, key, prefix, allowed) {
1090
1099
  const value = record[key];
1091
1100
  if (value === void 0) return;
@@ -1106,10 +1115,10 @@ var AGRO_CONFIG_FIELDS = [
1106
1115
  { path: "access.sshPasswordAuth", type: "boolean" },
1107
1116
  { path: "access.sshAuthorizedKeys", type: "string" },
1108
1117
  { path: "access.dockerSocket", type: "boolean" },
1118
+ { path: "access.egress", type: "enum", values: EGRESS_MODES },
1119
+ { path: "access.egressAllow", type: "list" },
1109
1120
  { path: "hermesDashboard.enabled", type: "boolean" },
1110
1121
  { path: "hermesDashboard.port", type: "port" },
1111
- { path: "cron.agentBin", type: "string" },
1112
- { path: "build.skipPnpmInstall", type: "boolean" },
1113
1122
  { path: "image.ref", type: "string" },
1114
1123
  { path: "image.mode", type: "enum", values: ["build", "image"] },
1115
1124
  { path: "image.pullPolicy", type: "enum", values: ["missing", "always", "never"] },
@@ -1462,10 +1471,10 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync as read
1462
1471
  import { dirname as dirname4, join as join4, resolve as resolve5, sep as sep2 } from "node:path";
1463
1472
 
1464
1473
  // agro-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.yml
1465
- var docker_compose_default = 'name: ${SANDBOX_NAME:-agro}\n\nservices:\n sandbox:\n image: ${AGRO_SANDBOX_IMAGE:-sandbox-${SANDBOX_NAME:-agro}}\n pull_policy: ${AGRO_PULL_POLICY:-missing}\n container_name: ${SANDBOX_NAME:-agro}\n build:\n context: ${AGRO_REPO_DIR:-..}\n dockerfile: .devcontainer/Dockerfile\n volumes:\n - ${AGRO_HOME_MOUNT:-workspace}:/home/sandbox\n - ${AGRO_REPO_DIR:-..}:/home/sandbox/harness\n extra_hosts:\n - "host.docker.internal:host-gateway"\n environment:\n - SANDBOX_NAME=${SANDBOX_NAME:-agro}\n - SANDBOX_PASSWORD=${SANDBOX_PASSWORD:-test1234}\n - TZ=${TZ:-America/Los_Angeles}\n - CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=true\n - CC_SAFETY_NET_STRICT=1\n - CC_SAFETY_NET_WORKTREE=1\n - GIT_USER_NAME=${GIT_USER_NAME:-}\n - GIT_USER_EMAIL=${GIT_USER_EMAIL:-}\n - GH_TOKEN=${GH_TOKEN:-}\n - TYPESAFE_API_KEY=${TYPESAFE_API_KEY:-}\n stdin_open: true\n tty: true\n cgroup: private\n cap_add:\n - SYS_ADMIN\n security_opt:\n - apparmor=unconfined\n tmpfs:\n - /run\n - /run/lock\n - /sys/fs\n healthcheck:\n test: ["CMD", "bash", "-c", "exec bash /home/sandbox/harness/.agro/scripts/sandbox-healthcheck.sh"]\n interval: 30s\n timeout: 10s\n retries: 3\n start_period: 600s\n restart: unless-stopped\n\nvolumes:\n workspace:\n';
1474
+ var docker_compose_default = 'name: ${SANDBOX_NAME:-agro}\n\nservices:\n sandbox:\n image: ${AGRO_SANDBOX_IMAGE:-sandbox-${SANDBOX_NAME:-agro}}\n pull_policy: ${AGRO_PULL_POLICY:-missing}\n container_name: ${SANDBOX_NAME:-agro}\n build:\n context: ${AGRO_REPO_DIR:-..}\n dockerfile: .devcontainer/Dockerfile\n volumes:\n - ${AGRO_HOME_MOUNT:-workspace}:/home/sandbox\n - ${AGRO_REPO_DIR:-..}:/home/sandbox/harness\n extra_hosts:\n - "host.docker.internal:host-gateway"\n environment:\n - SANDBOX_NAME=${SANDBOX_NAME:-agro}\n - SANDBOX_PASSWORD=${SANDBOX_PASSWORD:-}\n - TZ=${TZ:-America/Los_Angeles}\n - CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=true\n - CC_SAFETY_NET_STRICT=1\n - CC_SAFETY_NET_WORKTREE=1\n - GIT_USER_NAME=${GIT_USER_NAME:-}\n - GIT_USER_EMAIL=${GIT_USER_EMAIL:-}\n - GH_TOKEN=${GH_TOKEN:-}\n - TYPESAFE_API_KEY=${TYPESAFE_API_KEY:-}\n stdin_open: true\n tty: true\n init: true\n entrypoint: ["/usr/local/bin/sandbox-main.sh"]\n stop_signal: SIGTERM\n healthcheck:\n test: ["CMD", "bash", "-c", "exec bash /home/sandbox/harness/.agro/scripts/sandbox-healthcheck.sh"]\n interval: 30s\n timeout: 10s\n retries: 3\n start_period: 600s\n restart: unless-stopped\n\nvolumes:\n workspace:\n';
1466
1475
 
1467
1476
  // agro-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.image-only.yml
1468
- var docker_compose_image_only_default = 'name: ${SANDBOX_NAME:-agro}\n\nservices:\n sandbox:\n image: ${AGRO_SANDBOX_IMAGE:-ghcr.io/mifunedev/agro:latest}\n pull_policy: ${AGRO_PULL_POLICY:-always}\n container_name: ${SANDBOX_NAME:-agro}\n volumes:\n - ${AGRO_HOME_MOUNT:-workspace}:/home/sandbox\n extra_hosts:\n - "host.docker.internal:host-gateway"\n environment:\n - SANDBOX_NAME=${SANDBOX_NAME:-agro}\n - SANDBOX_PASSWORD=${SANDBOX_PASSWORD:-test1234}\n - TZ=${TZ:-America/Los_Angeles}\n - CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=true\n - CC_SAFETY_NET_STRICT=1\n - CC_SAFETY_NET_WORKTREE=1\n - GIT_USER_NAME=${GIT_USER_NAME:-}\n - GIT_USER_EMAIL=${GIT_USER_EMAIL:-}\n - GH_TOKEN=${GH_TOKEN:-}\n - TYPESAFE_API_KEY=${TYPESAFE_API_KEY:-}\n stdin_open: true\n tty: true\n cgroup: private\n cap_add:\n - SYS_ADMIN\n security_opt:\n - apparmor=unconfined\n tmpfs:\n - /run\n - /run/lock\n - /sys/fs\n healthcheck:\n test: ["CMD", "bash", "-c", "exec bash /home/sandbox/harness/.agro/scripts/sandbox-healthcheck.sh"]\n interval: 30s\n timeout: 10s\n retries: 3\n start_period: 600s\n restart: unless-stopped\n\nvolumes:\n workspace:\n';
1477
+ var docker_compose_image_only_default = 'name: ${SANDBOX_NAME:-agro}\n\nservices:\n sandbox:\n image: ${AGRO_SANDBOX_IMAGE:-ghcr.io/mifunedev/agro:latest}\n pull_policy: ${AGRO_PULL_POLICY:-always}\n container_name: ${SANDBOX_NAME:-agro}\n volumes:\n - ${AGRO_HOME_MOUNT:-workspace}:/home/sandbox\n extra_hosts:\n - "host.docker.internal:host-gateway"\n environment:\n - SANDBOX_NAME=${SANDBOX_NAME:-agro}\n - SANDBOX_PASSWORD=${SANDBOX_PASSWORD:-}\n - TZ=${TZ:-America/Los_Angeles}\n - CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=true\n - CC_SAFETY_NET_STRICT=1\n - CC_SAFETY_NET_WORKTREE=1\n - GIT_USER_NAME=${GIT_USER_NAME:-}\n - GIT_USER_EMAIL=${GIT_USER_EMAIL:-}\n - GH_TOKEN=${GH_TOKEN:-}\n - TYPESAFE_API_KEY=${TYPESAFE_API_KEY:-}\n stdin_open: true\n tty: true\n init: true\n entrypoint: ["/usr/local/bin/sandbox-main.sh"]\n stop_signal: SIGTERM\n healthcheck:\n test: ["CMD", "bash", "-c", "exec bash /home/sandbox/harness/.agro/scripts/sandbox-healthcheck.sh"]\n interval: 30s\n timeout: 10s\n retries: 3\n start_period: 600s\n restart: unless-stopped\n\nvolumes:\n workspace:\n';
1469
1478
 
1470
1479
  // agro-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.ssh.yml
1471
1480
  var docker_compose_ssh_default = 'services:\n sandbox:\n ports:\n - "127.0.0.1:${SANDBOX_SSH_PORT:-2222}:22"\n';
@@ -1473,6 +1482,9 @@ var docker_compose_ssh_default = 'services:\n sandbox:\n ports:\n - "12
1473
1482
  // agro-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.docker-sock.yml
1474
1483
  var docker_compose_docker_sock_default = "services:\n sandbox:\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n";
1475
1484
 
1485
+ // agro-asset:/home/runner/work/agro/agro/.devcontainer/docker-compose.egress.yml
1486
+ var docker_compose_egress_default = 'services:\n sandbox:\n networks:\n - egress-internal\n depends_on:\n - proxy\n environment:\n - HTTPS_PROXY=http://proxy:3128\n - HTTP_PROXY=http://proxy:3128\n - https_proxy=http://proxy:3128\n - http_proxy=http://proxy:3128\n - NO_PROXY=localhost,127.0.0.1\n - no_proxy=localhost,127.0.0.1\n - NODE_USE_ENV_PROXY=1\n\n proxy:\n image: ubuntu/squid@sha256:6a097f68bae708cedbabd6188d68c7e2e7a38cedd05a176e1cc0ba29e3bbe029\n networks:\n - egress-internal\n - egress-out\n configs:\n - source: egress-squid\n target: /etc/squid/squid.conf\n volumes:\n - type: bind\n source: ../egress-allow.txt\n target: /etc/squid/egress-allow.txt\n read_only: true\n restart: unless-stopped\n\nconfigs:\n egress-squid:\n content: |\n http_port 3128\n acl SSL_ports port 443\n acl Safe_ports port 80\n acl Safe_ports port 443\n acl CONNECT method CONNECT\n acl egress_allow dstdomain "/etc/squid/egress-allow.txt"\n http_access deny !Safe_ports\n http_access deny CONNECT !SSL_ports\n http_access allow egress_allow\n http_access deny all\n\nnetworks:\n egress-internal:\n internal: true\n egress-out: {}\n';
1487
+
1476
1488
  // agro-asset:/home/runner/work/agro/agro/.agro/scripts/docker-compose.sh
1477
1489
  var docker_compose_default2 = `#!/usr/bin/env bash
1478
1490
 
@@ -1612,6 +1624,11 @@ fi
1612
1624
 
1613
1625
  args+=(-f "$(compose_path ".devcontainer/docker-compose.yml")")
1614
1626
 
1627
+ egress_value=\${AGRO_EGRESS:-$(read_env_value AGRO_EGRESS)}
1628
+ if [ "$(printf '%s' "$egress_value" | tr '[:upper:]' '[:lower:]')" = "allowlist" ]; then
1629
+ args+=(-f "$(compose_path ".devcontainer/docker-compose.egress.yml")")
1630
+ fi
1631
+
1615
1632
  docker_socket_value=\${DOCKER_SOCKET:-$(read_env_value DOCKER_SOCKET)}
1616
1633
  if truthy "$docker_socket_value"; then
1617
1634
  args+=(-f "$(compose_path ".devcontainer/docker-compose.docker-sock.yml")")
@@ -1803,7 +1820,7 @@ exit 0
1803
1820
  `;
1804
1821
 
1805
1822
  // agro-asset:/home/runner/work/agro/agro/.devcontainer/openshell-policy.yaml
1806
- var openshell_policy_default = "version: 1\n\nfilesystem_policy:\n include_workdir: true\n read_only:\n - /bin\n - /usr\n - /lib\n - /etc\n - /opt\n - /proc\n - /var/log\n - /dev/urandom\n read_write:\n - /home/sandbox\n - /tmp\n - /dev/null\n\nprocess:\n run_as_user: sandbox\n run_as_group: sandbox\n\nnetwork_policies:\n github:\n endpoints:\n - host: github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n - host: api.github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n - host: release-assets.githubusercontent.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n binaries:\n - path: /usr/bin/git\n - path: /usr/bin/gh\n - path: /usr/bin/curl\n npm:\n endpoints:\n - host: registry.npmjs.org\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n allow_encoded_slash: true\n binaries:\n - path: /usr/local/bin/node\n anthropic:\n endpoints:\n - host: api.anthropic.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n binaries:\n - path: /home/sandbox/.local/lib/node_modules/@anthropic-ai/claude-code/bin/*\n";
1823
+ var openshell_policy_default = "version: 1\n\nfilesystem_policy:\n include_workdir: true\n read_only:\n - /bin\n - /usr\n - /lib\n - /etc\n - /opt\n - /proc\n - /var/log\n - /dev/urandom\n read_write:\n - /home/sandbox\n - /tmp\n - /dev/null\n\nprocess:\n run_as_user: sandbox\n run_as_group: sandbox\n\nnetwork_policies:\n github:\n endpoints:\n - host: github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n - host: api.github.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n - host: release-assets.githubusercontent.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n binaries:\n - path: /usr/bin/git\n - path: /usr/bin/gh\n - path: /usr/bin/curl\n npm:\n endpoints:\n - host: registry.npmjs.org\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-only\n allow_encoded_slash: true\n binaries:\n - path: /usr/local/bin/node\n anthropic:\n endpoints:\n - host: api.anthropic.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n - host: platform.claude.com\n port: 443\n protocol: rest\n enforcement: enforce\n access: read-write\n binaries:\n - path: /home/sandbox/.local/lib/node_modules/@anthropic-ai/claude-code/bin/*\n";
1807
1824
 
1808
1825
  // src/lib/registry.ts
1809
1826
  var SANDBOX_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
@@ -1864,9 +1881,29 @@ function nextDefaultName(run = spawnRunner) {
1864
1881
  function openshellFiles(entry) {
1865
1882
  return [{ dest: join4(entry, "openshell-policy.yaml"), body: openshell_policy_default, mode: 420 }];
1866
1883
  }
1867
- function dockerFiles(entry, opts) {
1884
+ function policyHosts(policyText) {
1885
+ const marker = "network_policies:";
1886
+ const start = policyText.indexOf(marker);
1887
+ const section = start === -1 ? "" : policyText.slice(start);
1888
+ const hosts = /* @__PURE__ */ new Set();
1889
+ for (const match of section.matchAll(/^\s*-\s*host:\s*(\S+)/gm)) hosts.add(match[1]);
1890
+ return [...hosts];
1891
+ }
1892
+ function egressAllowHosts(config) {
1893
+ const hosts = new Set(policyHosts(openshell_policy_default));
1894
+ for (const host of config.access?.egressAllow ?? []) hosts.add(host);
1895
+ return [...hosts].sort();
1896
+ }
1897
+ function safeReadAgroConfig(entry) {
1898
+ try {
1899
+ return readAgroConfig(agroConfigPath(entry));
1900
+ } catch {
1901
+ return { version: 1 };
1902
+ }
1903
+ }
1904
+ function dockerFiles(entry, opts, config) {
1868
1905
  const scripts = join4(resolveProjectLayout(entry).controlDir, "scripts");
1869
- return [
1906
+ const files = [
1870
1907
  {
1871
1908
  dest: join4(entry, ".devcontainer", "docker-compose.yml"),
1872
1909
  body: (opts.checkout ?? opts.repo) === void 0 ? docker_compose_image_only_default : docker_compose_default,
@@ -1874,14 +1911,25 @@ function dockerFiles(entry, opts) {
1874
1911
  },
1875
1912
  { dest: join4(entry, ".devcontainer", "docker-compose.ssh.yml"), body: docker_compose_ssh_default, mode: 420 },
1876
1913
  { dest: join4(entry, ".devcontainer", "docker-compose.docker-sock.yml"), body: docker_compose_docker_sock_default, mode: 420 },
1914
+ { dest: join4(entry, ".devcontainer", "docker-compose.egress.yml"), body: docker_compose_egress_default, mode: 420 },
1877
1915
  { dest: join4(scripts, "docker-compose.sh"), body: docker_compose_default2, mode: 493 },
1878
1916
  { dest: join4(scripts, "paths.sh"), body: paths_default, mode: 420 },
1879
1917
  { dest: join4(scripts, "check-host-port.sh"), body: check_host_port_default, mode: 493 }
1880
1918
  ];
1919
+ if ((config.access?.egress ?? "open") === "allowlist") {
1920
+ files.push({
1921
+ dest: join4(entry, "egress-allow.txt"),
1922
+ body: `${egressAllowHosts(config).join("\n")}
1923
+ `,
1924
+ mode: 420
1925
+ });
1926
+ }
1927
+ return files;
1881
1928
  }
1882
1929
  function materialize(root, opts = {}) {
1883
1930
  const entry = resolve5(root);
1884
- const files = entryRuntime(entry) === "openshell" ? openshellFiles(entry) : dockerFiles(entry, opts);
1931
+ const config = safeReadAgroConfig(entry);
1932
+ const files = entryRuntime(entry) === "openshell" ? openshellFiles(entry) : dockerFiles(entry, opts, config);
1885
1933
  for (const file of files) {
1886
1934
  const dest = resolve5(file.dest);
1887
1935
  if (!dest.startsWith(entry + sep2)) {
@@ -1921,6 +1969,7 @@ function resolveSandboxRoot(opts = {}) {
1921
1969
  }
1922
1970
 
1923
1971
  // src/lib/secrets.ts
1972
+ import { randomBytes } from "node:crypto";
1924
1973
  import { existsSync as existsSync8, readFileSync as readFileSync6 } from "node:fs";
1925
1974
  import { resolve as resolve6 } from "node:path";
1926
1975
 
@@ -2020,6 +2069,10 @@ function setSecret(root, key, value) {
2020
2069
  const { content } = setKeyInEnv(original, key, value);
2021
2070
  writeEnvFile(file, content);
2022
2071
  }
2072
+ function ensureSandboxPassword(root) {
2073
+ if (readSecret(root, "SANDBOX_PASSWORD") !== void 0) return;
2074
+ setSecret(root, "SANDBOX_PASSWORD", randomBytes(24).toString("base64url"));
2075
+ }
2023
2076
  function listSecretKeys(root) {
2024
2077
  const file = secretsFilePath(root);
2025
2078
  if (!existsSync8(file)) return [];
@@ -2046,7 +2099,7 @@ import { basename as basename6, join as join6, sep as sep3 } from "node:path";
2046
2099
  import { basename as basename4, resolve as resolve7 } from "node:path";
2047
2100
 
2048
2101
  // src/lib/version.ts
2049
- var AGRO_VERSION = true ? "0.20.0-rc.2" : "0.0.0-dev";
2102
+ var AGRO_VERSION = true ? "0.21.0" : "0.0.0-dev";
2050
2103
  var OFFICIAL_IMAGE = "ghcr.io/mifunedev/agro";
2051
2104
  var RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z][a-z0-9]*\.[0-9]+)?$/;
2052
2105
  function parseReleaseVersion(value) {
@@ -2621,6 +2674,7 @@ function renderComposeVars(config) {
2621
2674
  put("GIT_USER_NAME", config.git?.userName);
2622
2675
  put("GIT_USER_EMAIL", config.git?.userEmail);
2623
2676
  put("DOCKER_SOCKET", config.access?.dockerSocket);
2677
+ put("AGRO_EGRESS", config.access?.egress);
2624
2678
  put("SANDBOX_SSH", config.access?.ssh);
2625
2679
  put("SANDBOX_SSH_PORT", config.access?.sshPort);
2626
2680
  put("AGRO_SANDBOX_IMAGE", sandboxImageRef(config));
@@ -2848,6 +2902,7 @@ async function runSandbox(opts, io) {
2848
2902
  }
2849
2903
  await maybePromptDockerSocket(root, io, opts.bin);
2850
2904
  requireLifecycleScript(root, "docker-compose.sh");
2905
+ ensureSandboxPassword(root);
2851
2906
  if (imageRef !== void 0) {
2852
2907
  io.stdout(`image mode: ${imageRef} (skipping local build)
2853
2908
  `);
@@ -3388,7 +3443,26 @@ ${secretKeyList()}
3388
3443
  `);
3389
3444
  return 1;
3390
3445
  }
3391
- io.stdout(`.env: set ${key}=${redact(value)}
3446
+ io.stdout(`.env: set ${key}
3447
+ `);
3448
+ return 0;
3449
+ }
3450
+ async function runSecretGet(key, opts, io) {
3451
+ const root = secretsRoot(opts);
3452
+ let value;
3453
+ try {
3454
+ value = readSecret(root, key);
3455
+ } catch (error) {
3456
+ io.stderr(`${error instanceof Error ? error.message : String(error)}
3457
+ `);
3458
+ return 1;
3459
+ }
3460
+ if (value === void 0) {
3461
+ io.stderr(`${opts.bin} secret get: ${key} has no value
3462
+ `);
3463
+ return 1;
3464
+ }
3465
+ io.stdout(`${value}
3392
3466
  `);
3393
3467
  return 0;
3394
3468
  }
@@ -3403,7 +3477,7 @@ async function runSecretList(opts, io) {
3403
3477
  const width = Math.max(...keys.map((key) => key.length));
3404
3478
  for (const key of keys) {
3405
3479
  const value = readSecret(root, key);
3406
- io.stdout(`${key.padEnd(width)} ${value === void 0 ? "\u2014" : redact(value)}
3480
+ io.stdout(`${key.padEnd(width)} ${value === void 0 ? "\u2014" : "set"}
3407
3481
  `);
3408
3482
  }
3409
3483
  return 0;
@@ -3751,7 +3825,7 @@ function quoteFragmentValue(key, value) {
3751
3825
  if (value === "") throw new Error(`${key} must not be empty`);
3752
3826
  if (UNCARRIABLE_IN_VALUE.test(value)) {
3753
3827
  throw new Error(
3754
- `${key} must not contain a newline, a control character, or a single quote \u2014 zsh and systemd read the same fragment and neither carries that value safely`
3828
+ `${key} must not contain a newline, a control character, or a single quote \u2014 zsh reads the fragment and does not carry that value safely`
3755
3829
  );
3756
3830
  }
3757
3831
  return BARE_SAFE_VALUE.test(value) ? value : `'${value}'`;
@@ -4968,6 +5042,59 @@ async function runSandboxCommand(rest, bin) {
4968
5042
  );
4969
5043
  }
4970
5044
 
5045
+ // src/commands/egress.ts
5046
+ import { basename as basename7 } from "node:path";
5047
+ var EGRESS_LOG_PATH = "/var/log/squid/access.log";
5048
+ var EGRESS_LOG_LINES = 1e3;
5049
+ var PROXY_EXEC = ["exec", "-T", "proxy"];
5050
+ var EGRESS_COMMANDS = Object.freeze({
5051
+ log: Object.freeze([...PROXY_EXEC, "tail", "-n", String(EGRESS_LOG_LINES), EGRESS_LOG_PATH]),
5052
+ reload: Object.freeze([...PROXY_EXEC, "squid", "-k", "reconfigure"])
5053
+ });
5054
+ var RUNTIME_REFUSAL = Object.freeze({
5055
+ docker: void 0,
5056
+ openshell: (name) => `the sandbox ${name} uses runtime openshell; access.egress allowlist applies to the docker runtime`
5057
+ });
5058
+ function markDenied(text) {
5059
+ return text.split("\n").map((line) => line.includes("TCP_DENIED") ? `DENIED ${line}` : line).join("\n");
5060
+ }
5061
+ function refusal(root, name) {
5062
+ const runtimeRefusal = RUNTIME_REFUSAL[entryRuntime(root)];
5063
+ if (runtimeRefusal !== void 0) return runtimeRefusal(name);
5064
+ const egress = readAgroConfig(agroConfigPath(root)).access?.egress ?? "open";
5065
+ if (egress !== "allowlist") return `the sandbox ${name} uses access.egress ${egress}; set access.egress allowlist`;
5066
+ return void 0;
5067
+ }
5068
+ function rematerialize(root) {
5069
+ const repo = readAgroConfig(agroConfigPath(root)).repo;
5070
+ materialize(root, repo === void 0 || repo === "" ? {} : { repo });
5071
+ }
5072
+ function runEgress(verb, opts, io) {
5073
+ const run = opts.run ?? spawnRunner;
5074
+ const root = resolveSandboxRoot({ name: opts.name, cwd: opts.cwd });
5075
+ const name = basename7(root);
5076
+ const refused = refusal(root, name);
5077
+ if (refused !== void 0) {
5078
+ io.stderr(`${opts.bin} egress ${verb}: ${refused}
5079
+ `);
5080
+ return 1;
5081
+ }
5082
+ rematerialize(root);
5083
+ const script = requireLifecycleScript(root, "docker-compose.sh");
5084
+ const capture2 = verb === "log";
5085
+ return withComposeEnvFile(root, (extraArgs) => {
5086
+ const r = run("bash", [script, ...extraArgs, ...EGRESS_COMMANDS[verb]], {
5087
+ stdio: capture2 ? "capture" : "inherit"
5088
+ });
5089
+ assertSpawned(r, `bash ${script} egress ${verb}`);
5090
+ if (capture2) {
5091
+ if (r.stdout) io.stdout(markDenied(r.stdout));
5092
+ if (r.stderr) io.stderr(r.stderr);
5093
+ }
5094
+ return r.status ?? 1;
5095
+ });
5096
+ }
5097
+
4971
5098
  // src/commands/harness.ts
4972
5099
  import { existsSync as existsSync20 } from "node:fs";
4973
5100
  import { homedir as homedir3 } from "node:os";
@@ -4985,7 +5112,7 @@ function sourceDocsUrl(docsPath) {
4985
5112
 
4986
5113
  // src/lib/host-config.ts
4987
5114
  import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync12, renameSync as renameSync4, unlinkSync as unlinkSync6, writeFileSync as writeFileSync9 } from "node:fs";
4988
- import { basename as basename7, dirname as dirname8, isAbsolute, join as join15, resolve as resolve10 } from "node:path";
5115
+ import { basename as basename8, dirname as dirname8, isAbsolute, join as join15, resolve as resolve10 } from "node:path";
4989
5116
  var HOST_CONFIG_MODE = 420;
4990
5117
  var HOST_CONFIG_FILE = "config.json";
4991
5118
  var WORKSPACES_SUBDIR = "workspaces";
@@ -5091,7 +5218,7 @@ function readHostConfig(env, home) {
5091
5218
  try {
5092
5219
  parsed = JSON.parse(raw);
5093
5220
  } catch {
5094
- throw new Error(`${basename7(path4)} is not valid JSON: ${path4}`);
5221
+ throw new Error(`${basename8(path4)} is not valid JSON: ${path4}`);
5095
5222
  }
5096
5223
  return validateHostConfig(parsed);
5097
5224
  }
@@ -5453,6 +5580,19 @@ async function validateHermesHome(target, io, bin, user, inheritedHome, root = s
5453
5580
  }
5454
5581
  return result.exitCode;
5455
5582
  }
5583
+ async function seedHermes(target, io, bin, user, root = sandboxTargetRoot(target)) {
5584
+ const result = await target.exec({
5585
+ argv: remoteControlDirScript(root, "scripts/hermes-workspace.sh", ["seed", root, `${root}/.hermes`]),
5586
+ env: hermesEnv(root),
5587
+ ...user ? { user } : {},
5588
+ stdio: "inherit"
5589
+ });
5590
+ if (result.exitCode !== 0) {
5591
+ io.stderr(`${bin} harness: could not write the Hermes defaults to ${root}/.hermes/config.yaml (exit ${result.exitCode}); nothing was installed.
5592
+ `);
5593
+ }
5594
+ return result.exitCode;
5595
+ }
5456
5596
  async function configureHermes(target, io, bin, user, root = sandboxTargetRoot(target)) {
5457
5597
  const result = await target.exec({
5458
5598
  argv: remoteControlDirScript(root, "scripts/hermes-workspace.sh", [
@@ -5655,6 +5795,10 @@ Run: bash ${root}/.agro/scripts/link-providers.sh --init
5655
5795
  if (openclaw) openclawLaunch(io, root);
5656
5796
  return 0;
5657
5797
  }
5798
+ if (hermes) {
5799
+ const code = await seedHermes(target, io, bin, void 0, root);
5800
+ if (code !== 0) return code;
5801
+ }
5658
5802
  io.stdout(`${present && hasReceipt ? "updating" : "installing"} ${entry.title} on the host\u2026
5659
5803
  `);
5660
5804
  const r = await target.exec({
@@ -5863,6 +6007,10 @@ async function runHarnessInstall(name, opts, io) {
5863
6007
  io.stderr("docker is required to install into the running sandbox but was not found on PATH\n");
5864
6008
  return 1;
5865
6009
  }
6010
+ if (hermes) {
6011
+ const code = await seedHermes(target, io, opts.bin, "sandbox");
6012
+ if (code !== 0) return code;
6013
+ }
5866
6014
  if (already && await sandboxMarkerExists(target, entry)) {
5867
6015
  io.stdout(`updating ${entry.title} in the sandbox\u2026
5868
6016
  `);
@@ -7101,12 +7249,13 @@ function fetchRemoteSource(opts) {
7101
7249
  // src/command-table.ts
7102
7250
  var CONFIG_VERBS = ["show", "set", "repo"];
7103
7251
  var CONFIG_INTEGRATIONS = ["langfuse"];
7104
- var SECRET_VERBS = ["set", "list"];
7252
+ var SECRET_VERBS = ["set", "get", "list"];
7105
7253
  var LANGFUSE_VERBS = ["apply", "status", "disable"];
7106
7254
  var SANDBOX_SUBCOMMANDS = ["install", "list", "upgrade"];
7107
7255
  var WORKSPACE_SUBCOMMANDS = ["create", "list"];
7108
7256
  var HARNESS_SUBCOMMANDS = ["list", "install", "status", "uninstall"];
7109
7257
  var TOOL_SUBCOMMANDS = ["list", "install", "status", "uninstall"];
7258
+ var EGRESS_VERBS = ["log", "reload"];
7110
7259
  function isOneOf(values, value) {
7111
7260
  return value !== void 0 && values.includes(value);
7112
7261
  }
@@ -7124,6 +7273,7 @@ var AGRO_COMMANDS = Object.freeze({
7124
7273
  harness: HARNESS_SUBCOMMANDS,
7125
7274
  tool: TOOL_SUBCOMMANDS,
7126
7275
  gateway: null,
7276
+ egress: EGRESS_VERBS,
7127
7277
  ...Object.fromEntries(composeVerbs().map((verb) => [verb, null]))
7128
7278
  });
7129
7279
 
@@ -7161,7 +7311,7 @@ Usage:
7161
7311
  ${bin} sandbox <args...> Create, list, and upgrade sandboxes (install|list|upgrade)
7162
7312
  ${bin} shell [name] Open a zsh shell in the running sandbox container
7163
7313
  ${bin} config <args...> Read and write ${stateNames(bin).configFile} (show|set), or run a wizard
7164
- ${bin} secret <args...> Read and write the gitignored root .env (set|list)
7314
+ ${bin} secret <args...> Read and write the gitignored root .env (set|get|list)
7165
7315
  ${bin} langfuse <args...> Render, check, or disable Langfuse tracing files (apply|status|disable)
7166
7316
  ${bin} self-upgrade Upgrade the installed ${bin} CLI
7167
7317
  ${bin} vendor Vendor or upgrade the ${stateNames(bin).controlDir}/ control plane
@@ -7171,6 +7321,7 @@ Usage:
7171
7321
  ${bin} ps [name] Show sandbox service status
7172
7322
  ${bin} destroy [name] Remove the sandbox and wipe its named volumes
7173
7323
  ${bin} compose config Print the resolved docker compose configuration
7324
+ ${bin} egress <args...> Read the egress proxy log or reload its allowlist (log|reload)
7174
7325
  ${bin} workspace <args...> Create and list host AGRO workspaces (create|list)
7175
7326
  ${bin} harness <args...> Install and inspect agent CLI harnesses
7176
7327
  ${bin} tool <args...> Install and inspect sandbox tooling
@@ -7214,14 +7365,16 @@ function printSecretHelp(bin = AGRO_PRODUCT.bin) {
7214
7365
 
7215
7366
  Usage:
7216
7367
  ${bin} secret set <KEY> [--sandbox <name>] Prompt for the value (input hidden)
7368
+ ${bin} secret get <KEY> [--sandbox <name>] Print the raw value
7217
7369
  ${bin} secret list [--sandbox <name>] List the keys that hold a value
7218
7370
 
7219
- Without --sandbox both verbs write the equipped project root; with it they write
7220
- the registry entry \`${bin} sandbox list\` names.
7371
+ Without --sandbox all three verbs act on the equipped project root; with it they
7372
+ act on the registry entry \`${bin} sandbox list\` names.
7221
7373
 
7222
7374
  The value is never read from the command line \u2014 an argument would land in your
7223
- shell history. \`${bin} secret list\` never prints a raw value. .env is mode 0600 and
7224
- gitignored; every non-secret setting belongs in ${stateNames(bin).configFile} (\`${bin} config set\`).
7375
+ shell history. \`${bin} secret get\` is the only command that prints a raw value;
7376
+ \`${bin} secret list\` never does. .env is mode 0600 and gitignored; every non-secret
7377
+ setting belongs in ${stateNames(bin).configFile} (\`${bin} config set\`).
7225
7378
 
7226
7379
  Keys:
7227
7380
  ${secretKeyList()}
@@ -7236,7 +7389,7 @@ Usage:
7236
7389
  ${bin} langfuse disable Set langfuse.enabled=false, delete the credential fragment, rewrite the files
7237
7390
 
7238
7391
  \`${bin} config langfuse\` is the interactive wizard that writes these settings.
7239
- \`apply\` never prompts and never installs anything; bootstrap, systemd, and cron run it.
7392
+ \`apply\` never prompts and never installs anything; the bootstrap runs it.
7240
7393
  It reads langfuse.* from ${stateNames(bin).configFile} and the keys from the gitignored .env,
7241
7394
  and writes nothing when langfuse.enabled is not true. The generated files live in the
7242
7395
  sandbox home, so \`apply\` and \`disable\` refuse to touch them from the host.
@@ -7279,7 +7432,7 @@ Flags:
7279
7432
  --dry-run Report the installation kind, target, and versions without
7280
7433
  changing anything.
7281
7434
 
7282
- Project payload vendoring (${stateNames(bin).controlDir}/ + crons/) is \`${bin} vendor\`; its --from,
7435
+ Project payload vendoring (${stateNames(bin).controlDir}/) is \`${bin} vendor\`; its --from,
7283
7436
  --from-remote, --ref and --force flags are not accepted here.
7284
7437
  `);
7285
7438
  }
@@ -7289,7 +7442,7 @@ function printVendorHelp(bin = AGRO_PRODUCT.bin) {
7289
7442
  Usage:
7290
7443
  ${bin} vendor [--from <dir> | --from-remote [--ref <ref>]] [--dry-run] [--force]
7291
7444
 
7292
- Writes ONLY the ${stateNames(bin).controlDir}/ control plane and crons/ (skills, scripts, CLI) into the
7445
+ Writes ONLY the ${stateNames(bin).controlDir}/ control plane (skills, scripts, CLI) into the
7293
7446
  current directory. An empty directory is equipped from scratch; everything else
7294
7447
  in your project is left untouched \u2014 it writes no ${stateNames(bin).configFile}, .env, AGENTS.md,
7295
7448
  .gitignore or .devcontainer/.
@@ -7488,6 +7641,38 @@ Flags:
7488
7641
  See ${sourceDocsUrl("docs/lifecycle-commands.md")} for the full mapping.
7489
7642
  `);
7490
7643
  }
7644
+ function printEgressHelp(bin = AGRO_PRODUCT.bin) {
7645
+ process.stdout.write(`${bin} egress \u2014 Read the egress proxy log or reload its allowlist
7646
+
7647
+ Usage:
7648
+ ${bin} egress log [name] Print the last ${EGRESS_LOG_LINES} lines of the proxy access log; prefix denied lines with DENIED
7649
+ ${bin} egress reload [name] Rewrite egress-allow.txt and send the proxy \`squid -k reconfigure\`
7650
+
7651
+ Both verbs run ${stateNames(bin).controlDir}/scripts/docker-compose.sh \`exec proxy\` in the sandbox entry.
7652
+ The log is ${EGRESS_LOG_PATH} in the proxy container. \`reload\` keeps the sandbox running.
7653
+ Both verbs need a docker sandbox with access.egress allowlist; any other sandbox exits 1.
7654
+ [name] is a sandbox entry from \`${bin} sandbox list\`; with exactly one registered sandbox it can be omitted.
7655
+
7656
+ See ${sourceDocsUrl("docs/lifecycle-commands.md")} for every verb.
7657
+ `);
7658
+ }
7659
+ function parseEgressArgs(rest, bin = AGRO_PRODUCT.bin) {
7660
+ if (rest.length === 0 || isHelpFlag2(rest[0])) return { ok: true, args: { help: true } };
7661
+ const [head, ...tail] = rest;
7662
+ if (!isOneOf(EGRESS_VERBS, head)) {
7663
+ return {
7664
+ ok: false,
7665
+ error: `${bin} egress: unknown subcommand "${head}" \u2014 expected ${EGRESS_VERBS.join(" or ")}`,
7666
+ showHelp: true
7667
+ };
7668
+ }
7669
+ if (isHelpFlag2(tail[0])) return { ok: true, args: { help: true } };
7670
+ if (tail.length > 0 && tail[0].startsWith("-")) {
7671
+ return { ok: false, error: `${bin} egress ${head}: unknown flag "${tail[0]}"` };
7672
+ }
7673
+ if (tail.length > 1) return { ok: false, error: `${bin} egress ${head}: unexpected argument "${tail[1]}"` };
7674
+ return { ok: true, args: tail.length === 0 ? { help: false, verb: head } : { help: false, verb: head, name: tail[0] } };
7675
+ }
7491
7676
  function printComposeHelp(bin = AGRO_PRODUCT.bin) {
7492
7677
  process.stdout.write(`${bin} compose \u2014 Inspect the resolved docker compose setup
7493
7678
 
@@ -7603,7 +7788,7 @@ function parseSecretArgs(input, bin = AGRO_PRODUCT.bin) {
7603
7788
  if (!SECRET_VERBS.includes(head)) {
7604
7789
  return {
7605
7790
  ok: false,
7606
- error: `${bin} secret: unknown subcommand "${head}" \u2014 expected set or list`,
7791
+ error: `${bin} secret: unknown subcommand "${head}" \u2014 expected set, list, or get`,
7607
7792
  showHelp: true
7608
7793
  };
7609
7794
  }
@@ -7617,12 +7802,12 @@ function parseSecretArgs(input, bin = AGRO_PRODUCT.bin) {
7617
7802
  }
7618
7803
  const [key, ...extra] = tail;
7619
7804
  if (key === void 0) {
7620
- return { ok: false, error: `${bin} secret set: a key is required`, showHelp: true };
7805
+ return { ok: false, error: `${bin} secret ${verb}: a key is required`, showHelp: true };
7621
7806
  }
7622
7807
  if (extra.length > 0) {
7623
7808
  return {
7624
7809
  ok: false,
7625
- error: `${bin} secret set: takes only a key \u2014 the value is prompted for, never passed on the command line where your shell history would keep it`
7810
+ error: verb === "get" ? `${bin} secret get: takes only a key` : `${bin} secret set: takes only a key \u2014 the value is prompted for, never passed on the command line where your shell history would keep it`
7626
7811
  };
7627
7812
  }
7628
7813
  return { ok: true, args: { ...args, verb, key } };
@@ -8095,6 +8280,7 @@ async function main(argv) {
8095
8280
  };
8096
8281
  const scope = a.sandbox === void 0 ? { bin } : { bin, sandbox: a.sandbox };
8097
8282
  if (a.verb === "list") return await runSecretList(scope, io);
8283
+ if (a.verb === "get") return await runSecretGet(a.key, scope, io);
8098
8284
  return await runSecretSet(a.key, scope, io);
8099
8285
  }
8100
8286
  if (first === "langfuse") {
@@ -8219,6 +8405,21 @@ async function main(argv) {
8219
8405
  }
8220
8406
  return runComposeConfig({ bin }, parsed.args.passthrough);
8221
8407
  }
8408
+ if (first === "egress") {
8409
+ const parsed = parseEgressArgs(argv.slice(1), bin);
8410
+ if (!parsed.ok) {
8411
+ process.stderr.write(`${parsed.error}
8412
+ `);
8413
+ if (parsed.showHelp) printEgressHelp(bin);
8414
+ return 1;
8415
+ }
8416
+ const a = parsed.args;
8417
+ if (a.help || a.verb === void 0) {
8418
+ printEgressHelp(bin);
8419
+ return second === void 0 ? 1 : 0;
8420
+ }
8421
+ return runEgress(a.verb, a.name === void 0 ? { bin } : { bin, name: a.name }, lifecycleIo());
8422
+ }
8222
8423
  if (first === "workspace") {
8223
8424
  const parsed = parseWorkspaceArgs(argv.slice(1), bin);
8224
8425
  if (!parsed.ok) {
@@ -8400,6 +8601,7 @@ export {
8400
8601
  parseComposeArgs,
8401
8602
  parseConfigArgs,
8402
8603
  parseDestroyArgs,
8604
+ parseEgressArgs,
8403
8605
  parseGatewayArgs,
8404
8606
  parseHarnessArgs,
8405
8607
  parseLangfuseArgs,
@@ -8414,6 +8616,7 @@ export {
8414
8616
  printComposeHelp,
8415
8617
  printComposeVerbHelp,
8416
8618
  printDestroyHelp,
8619
+ printEgressHelp,
8417
8620
  printGatewayHelp,
8418
8621
  printHarnessHelp,
8419
8622
  printLangfuseHelp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mifune/agro",
3
- "version": "0.20.0-rc.2",
3
+ "version": "0.21.0",
4
4
  "description": "AGRO CLI — create and manage sandboxes for coding agents.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/mifunedev/agro#readme",