@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.
- package/dist/assets/skills/portta-workflows/SKILL.md +280 -0
- package/dist/assets/workflows/bake-off.workflow.js +171 -0
- package/dist/assets/workflows/code-review.workflow.js +179 -0
- package/dist/assets/workflows/deep-research.workflow.js +222 -0
- package/dist/assets/workflows/multi-provider-review.workflow.js +168 -0
- package/dist/assets/workflows/provider-debate.workflow.js +140 -0
- package/dist/assets/workflows/second-opinion.workflow.js +175 -0
- package/dist/cli.js +129793 -0
- package/dist/documentation.json +1 -0
- package/dist/host.js +73741 -0
- package/dist/runtime/.env.example +460 -0
- package/dist/runtime/VERSION +1 -0
- package/dist/runtime/config/traefik/dynamic/auth.example.yaml.disabled +24 -0
- package/dist/runtime/config/traefik/dynamic/middlewares.yaml +35 -0
- package/dist/runtime/config/traefik/dynamic/tcp.yaml +23 -0
- package/dist/runtime/docker/compose/attach/host.yaml +24 -0
- package/dist/runtime/docker/compose/attach/tailscale.yaml +70 -0
- package/dist/runtime/docker/compose/compose.yaml +214 -0
- package/dist/runtime/docker/compose/features/auth-build.yaml +19 -0
- package/dist/runtime/docker/compose/features/auth-dev.yaml +20 -0
- package/dist/runtime/docker/compose/features/cloudflare-tunnel.yaml +69 -0
- package/dist/runtime/docker/compose/features/dashboard-tailscale.yaml +21 -0
- package/dist/runtime/docker/compose/features/dashboard.yaml +21 -0
- package/dist/runtime/docker/compose/features/db.yaml +63 -0
- package/dist/runtime/docker/compose/features/panel-domain.yaml +42 -0
- package/dist/runtime/docker/compose/features/panel-public.yaml +47 -0
- package/dist/runtime/docker/compose/features/panel-webhook.yaml +46 -0
- package/dist/runtime/docker/compose/features/taskflow.yaml +38 -0
- package/dist/runtime/docker/compose/features/tcp-tailscale.yaml +30 -0
- package/dist/runtime/docker/compose/features/tcp.yaml +40 -0
- package/dist/runtime/docker/compose/features/web-bind.yaml +19 -0
- package/dist/runtime/docker/compose/features/web-build.yaml +21 -0
- package/dist/runtime/docker/compose/features/web-dev.yaml +54 -0
- package/dist/runtime/docker/compose/features/web-vpn.yaml +24 -0
- package/dist/runtime/docker/compose/features/web.yaml +283 -0
- package/dist/runtime/docker/compose/profiles/local-tls.yaml +23 -0
- package/dist/runtime/docker/compose/profiles/local.yaml +15 -0
- package/dist/runtime/docker/compose/profiles/public.yaml +19 -0
- package/dist/runtime/docker/compose/profiles/remote-tls-dns.yaml +26 -0
- package/dist/runtime/docker/compose/profiles/remote-tls-http.yaml +32 -0
- package/dist/runtime/docker/compose/profiles/remote-tls.yaml +31 -0
- package/dist/runtime/docker/compose/profiles/remote.yaml +23 -0
- package/dist/runtime/docker/images/README.md +12 -0
- package/dist/runtime/docker/images/apply/Dockerfile +45 -0
- package/dist/runtime/docker/images/sandbox/Dockerfile +79 -0
- package/dist/runtime/docker/images/sandbox/README.md +109 -0
- package/dist/runtime/docker/images/sandbox/VERSION +1 -0
- package/dist/runtime/docker/images/sandbox/agents/claude-code.sh +18 -0
- package/dist/runtime/docker/images/sandbox/agents/codex.sh +4 -0
- package/dist/runtime/docker/images/sandbox/agents/opencode.sh +4 -0
- package/dist/runtime/docker/images/sandbox/agents/pi.sh +4 -0
- package/dist/runtime/docker/images/sandbox/scripts/entrypoint.sh +8 -0
- package/dist/runtime/docker/images/sandbox/scripts/generate-manifest.mjs +81 -0
- package/dist/runtime/docker/images/sandbox/scripts/install-tools.sh +46 -0
- package/dist/runtime/docker/images/sandbox/scripts/verify-image.sh +18 -0
- package/dist/runtime/docker/images/toolbox/Dockerfile +38 -0
- package/dist/runtime/scripts/lib/runner-exec.sh +189 -0
- package/dist/runtime/templates/README.md +43 -0
- package/dist/runtime/templates/overlays/01-single-web.yaml +23 -0
- package/dist/runtime/templates/overlays/02-web-api.yaml +30 -0
- package/dist/runtime/templates/overlays/03-web-api-postgres.yaml +33 -0
- package/dist/runtime/templates/overlays/04-web-api-postgres-redis.yaml +30 -0
- package/dist/runtime/templates/overlays/05-multiple-apis.yaml +37 -0
- package/dist/runtime/templates/overlays/06-monorepo.yaml +63 -0
- package/dist/runtime/templates/overlays/07-worktree.env +47 -0
- package/dist/runtime/templates/overlays/08-nonstandard-port.yaml +41 -0
- package/dist/runtime/templates/overlays/09-tcp-routing.yaml +70 -0
- package/dist/runtime/templates/overlays/10-mailpit.yaml +24 -0
- package/dist/runtime/templates/overlays/11-rustfs.yaml +25 -0
- package/dist/runtime/templates/project/PORTTA.md +107 -0
- package/dist/supervisor.js +23928 -0
- 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,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
|