@codions/portta 0.0.0-develop.147.sha-1859ad1

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 (72) hide show
  1. package/dist/assets/skills/portta-workflows/SKILL.md +280 -0
  2. package/dist/assets/workflows/bake-off.workflow.js +171 -0
  3. package/dist/assets/workflows/code-review.workflow.js +179 -0
  4. package/dist/assets/workflows/deep-research.workflow.js +222 -0
  5. package/dist/assets/workflows/multi-provider-review.workflow.js +168 -0
  6. package/dist/assets/workflows/provider-debate.workflow.js +140 -0
  7. package/dist/assets/workflows/second-opinion.workflow.js +175 -0
  8. package/dist/cli.js +129793 -0
  9. package/dist/documentation.json +1 -0
  10. package/dist/host.js +73741 -0
  11. package/dist/runtime/.env.example +460 -0
  12. package/dist/runtime/VERSION +1 -0
  13. package/dist/runtime/config/traefik/dynamic/auth.example.yaml.disabled +24 -0
  14. package/dist/runtime/config/traefik/dynamic/middlewares.yaml +35 -0
  15. package/dist/runtime/config/traefik/dynamic/tcp.yaml +23 -0
  16. package/dist/runtime/docker/compose/attach/host.yaml +24 -0
  17. package/dist/runtime/docker/compose/attach/tailscale.yaml +70 -0
  18. package/dist/runtime/docker/compose/compose.yaml +214 -0
  19. package/dist/runtime/docker/compose/features/auth-build.yaml +19 -0
  20. package/dist/runtime/docker/compose/features/auth-dev.yaml +20 -0
  21. package/dist/runtime/docker/compose/features/cloudflare-tunnel.yaml +69 -0
  22. package/dist/runtime/docker/compose/features/dashboard-tailscale.yaml +21 -0
  23. package/dist/runtime/docker/compose/features/dashboard.yaml +21 -0
  24. package/dist/runtime/docker/compose/features/db.yaml +63 -0
  25. package/dist/runtime/docker/compose/features/panel-domain.yaml +42 -0
  26. package/dist/runtime/docker/compose/features/panel-public.yaml +47 -0
  27. package/dist/runtime/docker/compose/features/panel-webhook.yaml +46 -0
  28. package/dist/runtime/docker/compose/features/taskflow.yaml +38 -0
  29. package/dist/runtime/docker/compose/features/tcp-tailscale.yaml +30 -0
  30. package/dist/runtime/docker/compose/features/tcp.yaml +40 -0
  31. package/dist/runtime/docker/compose/features/web-bind.yaml +19 -0
  32. package/dist/runtime/docker/compose/features/web-build.yaml +21 -0
  33. package/dist/runtime/docker/compose/features/web-dev.yaml +54 -0
  34. package/dist/runtime/docker/compose/features/web-vpn.yaml +24 -0
  35. package/dist/runtime/docker/compose/features/web.yaml +283 -0
  36. package/dist/runtime/docker/compose/profiles/local-tls.yaml +23 -0
  37. package/dist/runtime/docker/compose/profiles/local.yaml +15 -0
  38. package/dist/runtime/docker/compose/profiles/public.yaml +19 -0
  39. package/dist/runtime/docker/compose/profiles/remote-tls-dns.yaml +26 -0
  40. package/dist/runtime/docker/compose/profiles/remote-tls-http.yaml +32 -0
  41. package/dist/runtime/docker/compose/profiles/remote-tls.yaml +31 -0
  42. package/dist/runtime/docker/compose/profiles/remote.yaml +23 -0
  43. package/dist/runtime/docker/images/README.md +12 -0
  44. package/dist/runtime/docker/images/apply/Dockerfile +45 -0
  45. package/dist/runtime/docker/images/sandbox/Dockerfile +79 -0
  46. package/dist/runtime/docker/images/sandbox/README.md +109 -0
  47. package/dist/runtime/docker/images/sandbox/VERSION +1 -0
  48. package/dist/runtime/docker/images/sandbox/agents/claude-code.sh +18 -0
  49. package/dist/runtime/docker/images/sandbox/agents/codex.sh +4 -0
  50. package/dist/runtime/docker/images/sandbox/agents/opencode.sh +4 -0
  51. package/dist/runtime/docker/images/sandbox/agents/pi.sh +4 -0
  52. package/dist/runtime/docker/images/sandbox/scripts/entrypoint.sh +8 -0
  53. package/dist/runtime/docker/images/sandbox/scripts/generate-manifest.mjs +81 -0
  54. package/dist/runtime/docker/images/sandbox/scripts/install-tools.sh +46 -0
  55. package/dist/runtime/docker/images/sandbox/scripts/verify-image.sh +18 -0
  56. package/dist/runtime/docker/images/toolbox/Dockerfile +38 -0
  57. package/dist/runtime/scripts/lib/runner-exec.sh +189 -0
  58. package/dist/runtime/templates/README.md +43 -0
  59. package/dist/runtime/templates/overlays/01-single-web.yaml +23 -0
  60. package/dist/runtime/templates/overlays/02-web-api.yaml +30 -0
  61. package/dist/runtime/templates/overlays/03-web-api-postgres.yaml +33 -0
  62. package/dist/runtime/templates/overlays/04-web-api-postgres-redis.yaml +30 -0
  63. package/dist/runtime/templates/overlays/05-multiple-apis.yaml +37 -0
  64. package/dist/runtime/templates/overlays/06-monorepo.yaml +63 -0
  65. package/dist/runtime/templates/overlays/07-worktree.env +47 -0
  66. package/dist/runtime/templates/overlays/08-nonstandard-port.yaml +41 -0
  67. package/dist/runtime/templates/overlays/09-tcp-routing.yaml +70 -0
  68. package/dist/runtime/templates/overlays/10-mailpit.yaml +24 -0
  69. package/dist/runtime/templates/overlays/11-rustfs.yaml +25 -0
  70. package/dist/runtime/templates/project/PORTTA.md +107 -0
  71. package/dist/supervisor.js +23928 -0
  72. package/package.json +63 -0
@@ -0,0 +1,45 @@
1
+ # ============================================================================
2
+ # Portta applier
3
+ # ============================================================================
4
+ # The one image that may drive Compose on the host, so that a settings change
5
+ # saved in the panel can be applied without a terminal.
6
+ #
7
+ # It exists because Traefik reads its static configuration from the environment
8
+ # its container was created with (docs/development/adr/0003-traefik-static-config-via-env.md):
9
+ # a saved setting needs the containers *recreated*, and recreating them means
10
+ # Compose, which the panel deliberately cannot reach
11
+ # (docs/development/adr/0008-web-panel-socket-proxy.md).
12
+ #
13
+ # Built on demand and cached locally, like the toolbox; never published. Off
14
+ # unless PORTTA_APPLY=true. See docs/development/adr/0026-applying-settings-from-the-panel.md.
15
+ #
16
+ # Node runs the packaged Portta CLI; Bash remains only for the closed project
17
+ # runner command.
18
+ #
19
+ # buildx, because `up` passes --build on a repository checkout. Nothing is built
20
+ # in here: the context is streamed over the mounted socket and the host daemon
21
+ # does the work, with the host's network and layer cache. The plugin is only how
22
+ # the compose CLI reaches that daemon, which is why --network none is no
23
+ # obstacle -- and why this image needs no toolchain of its own.
24
+ # ============================================================================
25
+ FROM alpine:3.24.1
26
+
27
+ ARG PORTTA_VERSION
28
+
29
+ # Pinned base, unpinned packages: Alpine's repositories only carry the current
30
+ # patch level for a release, so `apk add` inside 3.24.1 is already constrained.
31
+ RUN apk add --no-cache \
32
+ bash \
33
+ nodejs \
34
+ docker-cli \
35
+ docker-cli-buildx \
36
+ docker-cli-compose
37
+
38
+ LABEL org.opencontainers.image.title="portta-apply" \
39
+ org.opencontainers.image.description="Recreates Portta's own containers on the host" \
40
+ org.opencontainers.image.version="$PORTTA_VERSION" \
41
+ org.opencontainers.image.source="https://github.com/codions-labs/portta" \
42
+ org.opencontainers.image.licenses="MIT"
43
+
44
+ ENTRYPOINT []
45
+ CMD ["/bin/bash"]
@@ -0,0 +1,79 @@
1
+ # syntax=docker/dockerfile:1.7
2
+
3
+ FROM node:24-bookworm-slim
4
+
5
+ ENV AWS_DEFAULT_REGION=auto \
6
+ DEBIAN_FRONTEND=noninteractive \
7
+ DISABLE_UPDATES=1 \
8
+ NPM_CONFIG_PREFIX=/usr/local \
9
+ PATH="/root/.local/bin:/usr/local/bin:/root/.bun/bin:/root/.cargo/bin:${PATH}" \
10
+ PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
11
+ PORTTA_FLOW_SANDBOX_MANIFEST=/etc/taskflow-sandbox/manifest.json
12
+
13
+ RUN apt-get update \
14
+ && apt-get install -y --no-install-recommends \
15
+ build-essential \
16
+ ca-certificates \
17
+ curl \
18
+ git \
19
+ gnupg \
20
+ groff \
21
+ jq \
22
+ less \
23
+ openssh-client \
24
+ ripgrep \
25
+ sudo \
26
+ unzip \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ ARG TARGETARCH
30
+ ARG ASCIINEMA_VERSION=3.2.1
31
+ ARG ASCIINEMA_SHA256_AMD64=1b405bbda565b33c3c4718de67fedc3535580603c0694b1ff3fb04f363430a20
32
+ ARG ASCIINEMA_SHA256_ARM64=b516a6d896844c0ffbc96e0a55afe4cbcc79216abde0fc64fdda4e39bee421ea
33
+
34
+ RUN case "${TARGETARCH}" in \
35
+ amd64) target=x86_64; sha256="${ASCIINEMA_SHA256_AMD64}" ;; \
36
+ arm64) target=aarch64; sha256="${ASCIINEMA_SHA256_ARM64}" ;; \
37
+ *) echo "Unsupported asciinema architecture: ${TARGETARCH}" >&2; exit 1 ;; \
38
+ esac \
39
+ && curl -fsSL -o /usr/local/bin/asciinema \
40
+ "https://github.com/asciinema/asciinema/releases/download/v${ASCIINEMA_VERSION}/asciinema-${target}-unknown-linux-gnu" \
41
+ && echo "${sha256} /usr/local/bin/asciinema" | sha256sum -c - \
42
+ && chmod 0755 /usr/local/bin/asciinema
43
+
44
+ COPY --chmod=0755 agents/ /usr/local/lib/taskflow-sandbox/agents/
45
+ COPY --chmod=0755 scripts/ /usr/local/lib/taskflow-sandbox/scripts/
46
+
47
+ ARG BUILD_DATE=unknown
48
+ ARG BUILD_ID=local
49
+ ARG SANDBOX_VERSION=dev
50
+ ARG TOOLS_REFRESH=local
51
+ ARG VCS_REF=unknown
52
+
53
+ # TOOLS_REFRESH deliberately invalidates only the high-cadence tool layer.
54
+ RUN echo "Refreshing volatile tools: ${TOOLS_REFRESH}" \
55
+ && TARGETARCH="${TARGETARCH}" /usr/local/lib/taskflow-sandbox/scripts/install-tools.sh \
56
+ && install -d -m 0755 /etc/taskflow-sandbox \
57
+ && SANDBOX_VERSION="${SANDBOX_VERSION}" \
58
+ BUILD_DATE="${BUILD_DATE}" \
59
+ BUILD_ID="${BUILD_ID}" \
60
+ VCS_REF="${VCS_REF}" \
61
+ node /usr/local/lib/taskflow-sandbox/scripts/generate-manifest.mjs "${PORTTA_FLOW_SANDBOX_MANIFEST}" \
62
+ && /usr/local/lib/taskflow-sandbox/scripts/verify-image.sh
63
+
64
+ # Task Flow runs containers with the host UID/GID. Keep HOME traversable and
65
+ # writable while tools remain installed read-only under /usr/local.
66
+ RUN usermod -l agent -d /root -s /bin/bash node \
67
+ && echo "agent ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/agent \
68
+ && chmod 0440 /etc/sudoers.d/agent \
69
+ && chmod -R 0777 /root /ms-playwright \
70
+ && ln -sf /usr/local/lib/taskflow-sandbox/scripts/entrypoint.sh /usr/local/bin/entrypoint.sh
71
+
72
+ LABEL org.opencontainers.image.created="${BUILD_DATE}" \
73
+ org.opencontainers.image.description="Universal Task Flow agent sandbox" \
74
+ org.opencontainers.image.revision="${VCS_REF}" \
75
+ org.opencontainers.image.source="https://github.com/codions-labs/task-flow" \
76
+ org.opencontainers.image.title="Task Flow Sandbox" \
77
+ org.opencontainers.image.version="${SANDBOX_VERSION}"
78
+
79
+ CMD ["sleep", "infinity"]
@@ -0,0 +1,109 @@
1
+ # Task Flow Sandbox
2
+
3
+ The official universal sandbox image is published as:
4
+
5
+ ```text
6
+ ghcr.io/codions-labs/portta-sandbox
7
+ ```
8
+
9
+ It contains the four agent providers supported by Task Flow workflows: Codex,
10
+ Claude Code, OpenCode, and Pi. It also includes GitHub CLI, AWS CLI,
11
+ Playwright/Chromium, Mermaid CLI, asciinema, Git, Node.js 24, and common build
12
+ utilities.
13
+
14
+ ## Build locally
15
+
16
+ The build context is this directory. `TOOLS_REFRESH` invalidates the volatile
17
+ tool layer without invalidating the base OS/runtime layers.
18
+
19
+ ```bash
20
+ docker buildx build \
21
+ --load \
22
+ --platform linux/arm64 \
23
+ --build-arg SANDBOX_VERSION="$(tr -d '[:space:]' < VERSION)" \
24
+ --build-arg TOOLS_REFRESH="$(date -u +%Y%m%dT%H%M%SZ)" \
25
+ --tag portta-sandbox:local \
26
+ .
27
+ ```
28
+
29
+ Use `linux/amd64` on x86_64 hosts. Validate the result with:
30
+
31
+ ```bash
32
+ docker run --rm --entrypoint /usr/local/lib/taskflow-sandbox/scripts/verify-image.sh portta-sandbox:local
33
+ docker run --rm --entrypoint cat portta-sandbox:local /etc/taskflow-sandbox/manifest.json
34
+ ```
35
+
36
+ ## Versions and tags
37
+
38
+ `VERSION` is the version of the sandbox architecture, not the versions of the
39
+ agents. A rebuild can keep the same sandbox version while resolving newer
40
+ stable agent releases.
41
+
42
+ - `rolling`: latest main-branch, scheduled, or manually refreshed build.
43
+ - `edge`: latest develop-branch build.
44
+ - `build-YYYYMMDD-<run-id>-<attempt>`: immutable build identity.
45
+ - `X.Y.Z`: immutable structural release.
46
+ - `X.Y`, `X`, and `latest`: aliases advanced only by a new structural release.
47
+
48
+ Publish a structural release by creating a GitHub Release whose tag is
49
+ `sandbox-vX.Y.Z`. The tag version must match `VERSION`. Re-running ordinary or
50
+ scheduled builds never overwrites `X.Y.Z` or `latest`; it advances `rolling`.
51
+
52
+ The exact versions in each architecture are stored at
53
+ `/etc/taskflow-sandbox/manifest.json`, uploaded as workflow artifacts, and
54
+ printed in the workflow summary. For strict rollback or reproduction, use the
55
+ image digest or an immutable `build-*` tag.
56
+
57
+ ## Refresh and cache policy
58
+
59
+ The workflow supplies a unique `TOOLS_REFRESH` value for every run. Structural
60
+ layers remain cacheable, while signed APT repositories and npm stable tags are
61
+ resolved again for the volatile tools. No tool version edit or synthetic commit
62
+ is required: dispatch the workflow on `main` to refresh `rolling`.
63
+
64
+ A weekly Monday build refreshes the rolling image. Weekly, rather than daily,
65
+ limits two-architecture build minutes and immutable build-tag storage growth;
66
+ `workflow_dispatch` remains the fast path when an important agent release lands.
67
+
68
+ Claude Code is installed from Anthropic's signed stable APT channel. GitHub CLI
69
+ uses its signed stable APT repository. Codex, OpenCode, and Pi use the official
70
+ npm packages and their stable `latest` tags. A deliberate upstream regression
71
+ should be handled by temporarily pinning inside the affected agent installer,
72
+ then removing the pin after compatibility is restored.
73
+
74
+ ## Image architecture
75
+
76
+ The first release uses one universal image:
77
+
78
+ | Strategy | Decision |
79
+ | --- | --- |
80
+ | Universal image | Selected. It gives local, CI, VPS, and ephemeral environments the same warm toolset with no provisioning delay. |
81
+ | Base plus agent variants | Deferred until image size or an agent incompatibility is measured; combinations would otherwise multiply tags and builds. |
82
+ | Minimal base plus dynamic installs | Rejected as the default because startup would depend on registry availability and would be harder to audit and cache. |
83
+ | Runtime plugin layers | Kept as a possible future distribution mechanism if independent agent release/security policies become necessary. |
84
+
85
+ Agent installers are isolated files even though the delivered image is universal,
86
+ so a future variant can reuse the same scripts and BuildKit layers instead of
87
+ forking Dockerfiles.
88
+
89
+ ## Sandbox version policy
90
+
91
+ Tool-only refreshes do not change `VERSION`. Bump its patch component for a
92
+ backward-compatible image/configuration fix, its minor component for a new
93
+ sandbox capability or supported agent, and its major component for a breaking
94
+ runtime contract. A future reproducible-build mode can consume a saved manifest;
95
+ until then, the immutable image digest is the exact replay and rollback identity.
96
+
97
+ ## Adding an agent
98
+
99
+ Add one installer under `agents/`, call it from `scripts/install-tools.sh`, and
100
+ add its version command to `scripts/generate-manifest.mjs` and
101
+ `scripts/verify-image.sh`. Keep a single universal image until an agent has a
102
+ demonstrated incompatibility or size requirement that justifies a variant.
103
+
104
+ ## Registry setup
105
+
106
+ The publish job uses `GITHUB_TOKEN` and grants `packages: write` only to that
107
+ job. The organization must allow GitHub Actions to create packages. After the
108
+ first publication, make the `portta-sandbox` package public in the GHCR
109
+ package settings; package visibility cannot be changed by the build workflow.
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ key=/tmp/claude-code.asc
5
+ keyring=/etc/apt/keyrings/claude-code.asc
6
+ fingerprint=31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE
7
+
8
+ install -d -m 0755 /etc/apt/keyrings
9
+ curl -fsSL https://downloads.claude.ai/keys/claude-code.asc -o "$key"
10
+ actual_fingerprint="$(gpg --show-keys --with-colons "$key" | awk -F: '$1 == "fpr" { print $10; exit }')"
11
+ test "$actual_fingerprint" = "$fingerprint"
12
+ install -m 0644 "$key" "$keyring"
13
+ echo "deb [signed-by=$keyring] https://downloads.claude.ai/claude-code/apt/stable stable main" \
14
+ > /etc/apt/sources.list.d/claude-code.list
15
+ apt-get update
16
+ apt-get install -y --no-install-recommends claude-code
17
+ rm -f "$key"
18
+ rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ npm install --global --no-audit --no-fund @openai/codex@latest
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ npm install --global --allow-scripts=opencode-ai --no-audit --no-fund opencode-ai@latest
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ npm install --global --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@latest
@@ -0,0 +1,8 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ if [ -f "$PWD/package-lock.json" ]; then
5
+ npm ci 2>/dev/null || npm install
6
+ fi
7
+
8
+ exec "$@"
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync, spawnSync } from 'node:child_process'
4
+ import { readFileSync, writeFileSync } from 'node:fs'
5
+ import { arch } from 'node:os'
6
+
7
+ function output(command, args) {
8
+ const result = spawnSync(command, args, { encoding: 'utf8' })
9
+ if (result.status !== 0) {
10
+ throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr.trim()}`)
11
+ }
12
+ const value = `${result.stdout}\n${result.stderr}`
13
+ return value
14
+ .split('\n')
15
+ .map((line) => line.trim())
16
+ .find(Boolean)
17
+ }
18
+
19
+ function osRelease() {
20
+ return Object.fromEntries(
21
+ readFileSync('/etc/os-release', 'utf8')
22
+ .split('\n')
23
+ .filter((line) => line.includes('='))
24
+ .map((line) => {
25
+ const index = line.indexOf('=')
26
+ return [line.slice(0, index), line.slice(index + 1).replace(/^"|"$/g, '')]
27
+ }),
28
+ )
29
+ }
30
+
31
+ const chromium = execFileSync(
32
+ 'find',
33
+ [
34
+ process.env.PLAYWRIGHT_BROWSERS_PATH ?? '/ms-playwright',
35
+ '-type',
36
+ 'f',
37
+ '(',
38
+ '-name',
39
+ 'chrome-headless-shell',
40
+ '-o',
41
+ '-name',
42
+ 'headless_shell',
43
+ ')',
44
+ '-executable',
45
+ '-print',
46
+ '-quit',
47
+ ],
48
+ { encoding: 'utf8' },
49
+ ).trim()
50
+
51
+ if (!chromium) throw new Error('Playwright Chromium executable was not found')
52
+
53
+ const os = osRelease()
54
+ const manifest = {
55
+ schemaVersion: 1,
56
+ sandbox: {
57
+ version: process.env.SANDBOX_VERSION ?? 'unknown',
58
+ builtAt: process.env.BUILD_DATE ?? 'unknown',
59
+ buildId: process.env.BUILD_ID ?? 'local',
60
+ gitRevision: process.env.VCS_REF ?? 'unknown',
61
+ platform: `linux/${arch() === 'x64' ? 'amd64' : arch()}`,
62
+ base: `${os.PRETTY_NAME ?? os.ID ?? 'Linux'}`,
63
+ },
64
+ tools: {
65
+ node: output('node', ['--version']),
66
+ npm: output('npm', ['--version']),
67
+ git: output('git', ['--version']),
68
+ gh: output('gh', ['--version']),
69
+ aws: output('aws', ['--version']),
70
+ codex: output('codex', ['--version']),
71
+ claude: output('claude', ['--version']),
72
+ opencode: output('opencode', ['--version']),
73
+ pi: output('pi', ['--version']),
74
+ playwright: output('playwright', ['--version']),
75
+ chromium: output(chromium, ['--version', '--no-sandbox']),
76
+ mermaid: output('mmdc', ['--version']),
77
+ asciinema: output('asciinema', ['--version']),
78
+ },
79
+ }
80
+
81
+ writeFileSync(process.argv[2] ?? '/etc/taskflow-sandbox/manifest.json', `${JSON.stringify(manifest, null, 2)}\n`)
@@ -0,0 +1,46 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ install_github_cli() {
5
+ install -d -m 0755 /etc/apt/keyrings
6
+ curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
7
+ -o /etc/apt/keyrings/githubcli-archive-keyring.gpg
8
+ chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
9
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
10
+ > /etc/apt/sources.list.d/github-cli.list
11
+ apt-get update
12
+ apt-get install -y --no-install-recommends gh
13
+ rm -rf /var/lib/apt/lists/*
14
+ }
15
+
16
+ install_aws_cli() {
17
+ case "${TARGETARCH:?TARGETARCH is required}" in
18
+ amd64|arm64) ;;
19
+ *) echo "Unsupported AWS CLI architecture: ${TARGETARCH}" >&2; return 1 ;;
20
+ esac
21
+
22
+ curl -fsSL https://awscli.amazonaws.com/v2/install.sh -o /tmp/aws-cli-install.sh
23
+ chmod 0755 /tmp/aws-cli-install.sh
24
+ /tmp/aws-cli-install.sh --system
25
+ rm -f /tmp/aws-cli-install.sh
26
+ }
27
+
28
+ install_node_tools() {
29
+ PUPPETEER_SKIP_DOWNLOAD=true \
30
+ npm install --global --no-audit --no-fund @playwright/test@latest @mermaid-js/mermaid-cli@latest
31
+ playwright install chromium --with-deps --only-shell
32
+ npm cache clean --force
33
+
34
+ chromium="$(find "$PLAYWRIGHT_BROWSERS_PATH" -type f \( -name chrome-headless-shell -o -name headless_shell \) -executable | head -n 1)"
35
+ test -n "$chromium"
36
+ printf '{"args":["--no-sandbox","--disable-setuid-sandbox"],"executablePath":"%s"}\n' "$chromium" \
37
+ > /root/.puppeteerrc.json
38
+ }
39
+
40
+ install_github_cli
41
+ install_aws_cli
42
+ /usr/local/lib/taskflow-sandbox/agents/claude-code.sh
43
+ /usr/local/lib/taskflow-sandbox/agents/codex.sh
44
+ /usr/local/lib/taskflow-sandbox/agents/opencode.sh
45
+ /usr/local/lib/taskflow-sandbox/agents/pi.sh
46
+ install_node_tools
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ manifest="${PORTTA_FLOW_SANDBOX_MANIFEST:-/etc/taskflow-sandbox/manifest.json}"
5
+ test -s "$manifest"
6
+
7
+ for command in node npm git gh aws codex claude opencode pi playwright mmdc asciinema; do
8
+ command -v "$command" >/dev/null
9
+ done
10
+
11
+ jq -e '
12
+ . as $manifest |
13
+ $manifest.schemaVersion == 1 and
14
+ ($manifest.sandbox.version | length > 0) and
15
+ ($manifest.sandbox.platform | startswith("linux/")) and
16
+ (["node", "npm", "git", "gh", "aws", "codex", "claude", "opencode", "pi", "playwright", "chromium", "mermaid", "asciinema"] |
17
+ all(. as $tool | $manifest.tools[$tool] | type == "string" and length > 0))
18
+ ' "$manifest" >/dev/null
@@ -0,0 +1,38 @@
1
+ # ============================================================================
2
+ # Portta toolbox
3
+ # ============================================================================
4
+ # One small image holding the diagnostic and client tools the gateway needs, so
5
+ # a host only ever has to provide Docker, Git and a shell.
6
+ #
7
+ # Built on demand and cached locally; see `portta toolbox build`.
8
+ # ============================================================================
9
+ FROM alpine:3.24.1
10
+
11
+ ARG PORTTA_VERSION
12
+
13
+ # Pinned base, unpinned packages: Alpine's repositories only carry the current
14
+ # patch level for a release, so `apk add` inside 3.24.1 is already constrained.
15
+ RUN apk add --no-cache \
16
+ bash \
17
+ ca-certificates \
18
+ curl \
19
+ jq \
20
+ bind-tools \
21
+ openssl \
22
+ socat \
23
+ netcat-openbsd \
24
+ openssh-client \
25
+ postgresql-client \
26
+ mysql-client \
27
+ redis \
28
+ iputils \
29
+ && update-ca-certificates
30
+
31
+ LABEL org.opencontainers.image.title="portta-toolbox" \
32
+ org.opencontainers.image.description="Diagnostic and client tools for Portta" \
33
+ org.opencontainers.image.version="$PORTTA_VERSION" \
34
+ org.opencontainers.image.source="https://github.com/codions-labs/portta" \
35
+ org.opencontainers.image.licenses="MIT"
36
+
37
+ ENTRYPOINT []
38
+ CMD ["/bin/bash"]
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env bash
2
+ # The command the project runner container is created with. Fixed at creation;
3
+ # the panel supplies no argument. The request is { verb, project, flags } in
4
+ # state/runner/request.json, plus { workingDir, configFiles } for an `up` of a
5
+ # project that has no container left. See docs/development/adr/0030-the-panel-and-a-project-lifecycle.md.
6
+ set -euo pipefail
7
+
8
+ PORTTA_ROOT="${PORTTA_ROOT:?PORTTA_ROOT is required}"
9
+ REQUEST="$PORTTA_ROOT/state/runner/request.json"
10
+ HOST_ROOT=/host
11
+
12
+ die() { printf 'error: %s\n' "$1" >&2; exit 1; }
13
+
14
+ [ -f "$REQUEST" ] || die "no runner request at $REQUEST"
15
+
16
+ # Closed fields, closed values. A full JSON parser is not needed and would be a
17
+ # dependency this image does not have. The panel refuses a path carrying a
18
+ # comma, a quote or a backslash before it writes the request, which is what
19
+ # lets the list be read as text between the brackets.
20
+ verb=$(sed -n 's/.*"verb"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" | head -n1)
21
+ project=$(sed -n 's/.*"project"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" | head -n1)
22
+ nocache=$(grep -q '"no-cache"' "$REQUEST" && printf '1' || printf '0')
23
+ directory=$(grep -q '"directory"' "$REQUEST" && printf '1' || printf '0')
24
+ request_working_dir=$(sed -n 's/.*"workingDir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" | head -n1)
25
+ request_config_files=$(sed -n 's/.*"configFiles"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p' "$REQUEST" | head -n1 | tr -d '"')
26
+
27
+ case "$verb" in
28
+ up|stop|restart|build|down|down-volumes) ;;
29
+ *) die "unknown runner verb '$verb'" ;;
30
+ esac
31
+
32
+ [[ "$project" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || die "refusing project name '$project'"
33
+
34
+ # The bound a path from the request must satisfy, the same one
35
+ # packages/core/src/runner.ts checks before writing it: absolute, no walk-up,
36
+ # not `/`, not a top-level directory.
37
+ assert_request_path() {
38
+ local path="$1" what="$2"
39
+ [[ "$path" == /* ]] || die "$what is not absolute"
40
+ [[ "$path" != *..* ]] || die "refusing $what that walks up"
41
+ [[ "$path" != "/" ]] || die "refusing / as $what"
42
+ case "${path#/}" in
43
+ */*) ;;
44
+ *) die "refusing a top-level directory as $what" ;;
45
+ esac
46
+ }
47
+
48
+ # With no container left there is no managed label to protect Portta itself.
49
+ # Refuse both its root and descendants: a remembered request must never run a
50
+ # consumer Compose file from the gateway checkout.
51
+ assert_not_portta_path() {
52
+ local path="$1" what="$2"
53
+ case "$path" in
54
+ "$PORTTA_ROOT"|"$PORTTA_ROOT"/*) die "refusing Portta's own directory as $what" ;;
55
+ esac
56
+ }
57
+
58
+ id=$(docker ps -aq --filter "label=com.docker.compose.project=${project}" | head -n1)
59
+ if [ -n "$id" ]; then
60
+ # A container exists: its labels are the truth, whatever the request says.
61
+ managed=$(docker inspect "$id" --format '{{ index .Config.Labels "portta.managed" }}' 2>/dev/null || true)
62
+ [ "$managed" != "true" ] || die "refusing to operate Portta's own project"
63
+ working_dir=$(docker inspect "$id" --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}')
64
+ config_files=$(docker inspect "$id" --format '{{ index .Config.Labels "com.docker.compose.project.config_files" }}')
65
+ else
66
+ # Nothing left to read labels from: only `up` makes sense, and only with the
67
+ # paths the panel remembered. Portta's own project is refused by name and by
68
+ # directory, since no label can say so here.
69
+ [ "$verb" = "up" ] || die "no container on this host belongs to project '$project'"
70
+ [ -n "$request_working_dir" ] || die "no container on this host belongs to project '$project', and the request names no working directory"
71
+ own_name=$(sed -n 's/^PORTTA_PROJECT_NAME=//p' "$PORTTA_ROOT/.env" 2>/dev/null | head -n1)
72
+ own_name="${own_name%\"}"; own_name="${own_name#\"}"; own_name="${own_name%\'}"; own_name="${own_name#\'}"
73
+ own_name="${own_name:-portta}"
74
+ [ "$project" != "$own_name" ] || die "refusing to operate Portta's own project"
75
+ assert_request_path "$request_working_dir" "working directory"
76
+ assert_not_portta_path "$request_working_dir" "a working directory"
77
+ working_dir="$request_working_dir"
78
+ config_files="$request_config_files"
79
+ fi
80
+
81
+ [ -n "$working_dir" ] || die "project '$project' has no Compose working directory label"
82
+ [ -d "${HOST_ROOT}${working_dir}" ] || die "working directory ${working_dir} does not exist on this host"
83
+
84
+ # Compose sees the host filesystem at the host's own paths, so `include:` and
85
+ # every relative path in a Compose file resolve exactly as they do on the host.
86
+ # The first ancestor of a path that does not exist in this container becomes a
87
+ # symlink into /host; the repository root is already mounted at its own path.
88
+ link_host_path() {
89
+ local target="$1" partial="" rest segment
90
+ case "$target" in
91
+ "$PORTTA_ROOT"|"$PORTTA_ROOT"/*) return 0 ;;
92
+ esac
93
+ rest="${target#/}"
94
+ while [ -n "$rest" ]; do
95
+ segment="${rest%%/*}"
96
+ if [ "$segment" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi
97
+ partial="$partial/$segment"
98
+ if [ -L "$partial" ]; then
99
+ [ "$(realpath "$partial")" = "$(realpath "${HOST_ROOT}${partial}")" ] || die "${partial} in the runner does not point at the host's ${partial}; recreate the runner with portta up"
100
+ return 0
101
+ fi
102
+ if [ ! -e "$partial" ]; then
103
+ ln -s "${HOST_ROOT}${partial}" "$partial"
104
+ return 0
105
+ fi
106
+ done
107
+ [ "$(realpath "$target")" = "$(realpath "${HOST_ROOT}${target}")" ] || die "${target} exists inside the runner image and is not the host's ${target}"
108
+ }
109
+
110
+ link_host_path "$working_dir"
111
+
112
+ # Compose files as the daemon recorded them, or as the panel remembered them.
113
+ # Readability is checked through /host; the path handed to Compose is the
114
+ # host's, like --project-directory, so bind mounts resolve on the host.
115
+ files=()
116
+ if [ -n "$config_files" ]; then
117
+ IFS=',' read -r -a listed <<< "$config_files"
118
+ for file in "${listed[@]}"; do
119
+ file="${file#"${file%%[![:space:]]*}"}"
120
+ file="${file%"${file##*[![:space:]]}"}"
121
+ [ -n "$file" ] || continue
122
+ [ -z "$id" ] && assert_request_path "$file" "compose file"
123
+ [ -n "$id" ] || assert_not_portta_path "$file" "a compose file"
124
+ [ -f "${HOST_ROOT}${file}" ] || die "compose file ${file} is not readable"
125
+ case "$file" in
126
+ "$working_dir"/*) ;;
127
+ *) link_host_path "${file%/*}" ;;
128
+ esac
129
+ files+=(-f "$file")
130
+ done
131
+ fi
132
+ if [ ${#files[@]} -eq 0 ]; then
133
+ for name in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
134
+ if [ -f "${HOST_ROOT}${working_dir}/${name}" ]; then
135
+ files+=(-f "${working_dir}/${name}")
136
+ break
137
+ fi
138
+ done
139
+ fi
140
+ [ ${#files[@]} -gt 0 ] || die "no compose file in ${working_dir}"
141
+
142
+ compose() {
143
+ docker compose --project-name "$project" --project-directory "$working_dir" "${files[@]}" "$@"
144
+ }
145
+
146
+ # The working directory is Docker's label. Removal is the same bound as
147
+ # packages/core/src/paths.ts: absolute, no walk-up, not `/`, not a top-level
148
+ # directory. `realpath` is the last check so a symlink cannot leave /host.
149
+ remove_working_dir() {
150
+ local dir="$1"
151
+ [[ "$dir" == /* ]] || die "working directory is not absolute"
152
+ [[ "$dir" != *..* ]] || die "refusing working directory that walks up"
153
+ [[ "$dir" != "/" ]] || die "refusing to remove /"
154
+ case "${dir#/}" in
155
+ */*) ;;
156
+ *) die "refusing to remove a top-level directory" ;;
157
+ esac
158
+ local target="${HOST_ROOT}${dir}"
159
+ [ -d "$target" ] || die "working directory ${dir} does not exist on this host"
160
+ local real
161
+ real=$(realpath "$target")
162
+ case "$real" in
163
+ /host/*) ;;
164
+ *) die "resolved path is outside /host" ;;
165
+ esac
166
+ [ "$real" != "/host" ] && [ "$real" != "/" ] || die "refusing to remove the host root"
167
+ rm -rf -- "$real"
168
+ }
169
+
170
+ case "$verb" in
171
+ up) compose up -d --remove-orphans ;;
172
+ stop) compose stop ;;
173
+ restart) compose stop && compose up -d --remove-orphans ;;
174
+ build)
175
+ if [ "$nocache" = "1" ]; then
176
+ compose build --no-cache
177
+ else
178
+ compose build
179
+ fi
180
+ compose up -d --remove-orphans
181
+ ;;
182
+ down) compose down ;;
183
+ down-volumes)
184
+ compose down --volumes
185
+ if [ "$directory" = "1" ]; then
186
+ remove_working_dir "$working_dir"
187
+ fi
188
+ ;;
189
+ esac