Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## [0.3.0] - 2026-06-08

### Added
- AI assistant: interactive sessions that investigate before answering, grounded in this installation's context, with inline tool-call approval, suggested actions and any OpenAI-compatible provider configured at runtime
- Server-side plans: preview and apply mutations as reviewable artifacts, with opt-in enforcement per deployment
- Optional app template for image and compose deployments: the user's image or compose content is kept while the template contributes container port, default bind mounts, pre-created directories and ownership
- Template-defined environment file generation: prefers the env example shipped inside the deployed image, falls back to template content, generates per-deployment secrets (e.g. Laravel `APP_KEY`) and fills in database credentials
- Interactive system terminal on a real PTY over websocket, with per-line protected-command enforcement and the global terminal disable honored
- Persisted file manager preference for showing hidden files (default on), exposed through the key-based config API
- Template mounts can declare a host path; single-file mounts (such as `.env`) are kept as files
- Per-service image pulling

### Changed
- Mount ownership is applied recursively so nested directories belong to the container user
- Built-in template copies on disk sync automatically when the agent build changes, so upgrades take effect without a manual refresh
- Laravel template: complete storage subdirectory tree, bootstrap cache mount, env file mounted as a file
- Prompts composed by the product (log analysis, operation diagnosis) are kept out of AI session transcripts while still reaching the model
- Nginx reloads when forwarded-proxy trust settings change

### Fixed
- Forwarded client IPs are only trusted when sent by configured proxies, and trusted-proxy entries are sanitized before injection into the nginx Lua layer
- e2e suite aborts on leftover root-owned state instead of failing unrelated tests

## [0.2.0] - 2026-05-25

### Added
Expand Down
21 changes: 15 additions & 6 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"log"
"math/big"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -4115,23 +4116,31 @@ func generateSecretValue(spec TemplateEnvSecret) (string, error) {
if n <= 0 {
n = 32
}
buf := make([]byte, n)
if _, err := cryptoRand.Read(buf); err != nil {
return "", err
}

var value string
switch spec.Encoding {
case "hex":
buf := make([]byte, n)
if _, err := cryptoRand.Read(buf); err != nil {
return "", err
}
value = hex.EncodeToString(buf)
case "alphanumeric":
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
out := make([]byte, n)
for i, b := range buf {
out[i] = charset[int(b)%len(charset)]
for i := range out {
idx, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", err
}
out[i] = charset[idx.Int64()]
}
value = string(out)
default:
buf := make([]byte, n)
if _, err := cryptoRand.Read(buf); err != nil {
return "", err
}
value = base64.StdEncoding.EncodeToString(buf)
}

Expand Down
8 changes: 5 additions & 3 deletions internal/docker/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,9 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn
}
}

if info, err := os.Stat(base); err == nil && !info.IsDir() {
if info, err := os.Lstat(base); err == nil && !info.IsDir() {
if m.User != "" {
if err := os.Chown(base, uid, gid); err != nil {
if err := os.Lchown(base, uid, gid); err != nil {
return fmt.Errorf("chown %s: %w", base, err)
}
}
Expand All @@ -426,7 +426,9 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn
if walkErr != nil {
return walkErr
}
return os.Chown(path, uid, gid)
// Runs as root over container-written content: following a
// planted symlink would chown an arbitrary host file.
return os.Lchown(path, uid, gid)
})
if err != nil {
return fmt.Errorf("chown %s: %w", base, err)
Expand Down
29 changes: 29 additions & 0 deletions internal/docker/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,35 @@ func TestApplyMountOwnership(t *testing.T) {
}
})

t.Run("does not follow symlinks while chowning", func(t *testing.T) {
// Chowning through the link to this root-owned target would fail
// as non-root; success proves the link itself was changed.
target := "/etc/hostname"
if _, err := os.Stat(target); err != nil {
t.Skipf("no stable root-owned target available: %v", err)
}

base := filepath.Join(deploymentPath, "linked")
if err := os.MkdirAll(base, 0755); err != nil {
t.Fatalf("Failed to create mount dir: %v", err)
}
if err := os.Symlink(target, filepath.Join(base, "escape")); err != nil {
t.Fatalf("Failed to create symlink: %v", err)
}

user := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())
mounts := []MountOwnership{
{
HostPath: "./linked",
User: user,
},
}

if err := d.ApplyMountOwnership(deploymentPath, mounts); err != nil {
t.Fatalf("ApplyMountOwnership failed on a mount containing a symlink: %v", err)
}
})

t.Run("chowns recursively including pre-existing content", func(t *testing.T) {
base := filepath.Join(deploymentPath, "data")
nested := filepath.Join(base, "deep", "dir")
Expand Down
Loading