Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35eca40c3 | ||
|
|
6d10877e34 | ||
|
|
b51a3b564f | ||
|
|
a4fe178724 | ||
|
|
e0b5af2276 | ||
|
|
5c71edfac4 | ||
|
|
d4af41e7dc | ||
|
|
bcf5a82eba | ||
|
|
bfac99274d | ||
|
|
0bfa55a94a |
@@ -0,0 +1 @@
|
|||||||
|
[ 214ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "username"): (More info: https://goo.gl.qjz9zk/9p2vKq) %o @ https://git.westphal.fr/user/login?redirect_to=%2Fuser%2Fsettings%2Fkeys:0
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
- generic [ref=e1]:
|
||||||
|
- generic [ref=e2]:
|
||||||
|
- navigation "Navigation Bar" [ref=e3]:
|
||||||
|
- generic [ref=e4]:
|
||||||
|
- link "Home" [ref=e5] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e6]
|
||||||
|
- link "Explore" [ref=e7] [cursor=pointer]:
|
||||||
|
- /url: /explore/repos
|
||||||
|
- link "Help" [ref=e8] [cursor=pointer]:
|
||||||
|
- /url: https://docs.gitea.com
|
||||||
|
- link "Sign In" [ref=e10] [cursor=pointer]:
|
||||||
|
- /url: /user/login
|
||||||
|
- img [ref=e11]
|
||||||
|
- generic [ref=e13]: Sign In
|
||||||
|
- main "Sign In" [ref=e14]:
|
||||||
|
- generic [ref=e16]:
|
||||||
|
- generic [ref=e17]:
|
||||||
|
- heading "Sign In" [level=4] [ref=e18]
|
||||||
|
- generic [ref=e19]:
|
||||||
|
- generic [ref=e20]:
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- generic [ref=e22]: Username or Email Address *
|
||||||
|
- textbox "Username or Email Address *" [active] [ref=e23]
|
||||||
|
- generic [ref=e24]:
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- generic [ref=e26]: Password
|
||||||
|
- link "Forgot password?" [ref=e27] [cursor=pointer]:
|
||||||
|
- /url: /user/forgot_password
|
||||||
|
- textbox "Password" [ref=e28]
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- generic [ref=e31]: Remember This Device
|
||||||
|
- checkbox "Remember This Device" [ref=e32]
|
||||||
|
- button "Sign In" [ref=e34] [cursor=pointer]
|
||||||
|
- generic [ref=e35]: or
|
||||||
|
- link "Sign in with OpenID" [ref=e37] [cursor=pointer]:
|
||||||
|
- /url: /user/login/openid
|
||||||
|
- img [ref=e38]
|
||||||
|
- text: Sign in with OpenID
|
||||||
|
- generic [ref=e42] [cursor=pointer]: Sign in with a passkey
|
||||||
|
- group "Footer" [ref=e43]:
|
||||||
|
- contentinfo "About Software" [ref=e44]:
|
||||||
|
- link "Powered by Gitea" [ref=e45] [cursor=pointer]:
|
||||||
|
- /url: https://about.gitea.com
|
||||||
|
- generic [ref=e46]: "Version: 1.26.4"
|
||||||
|
- generic [ref=e47]:
|
||||||
|
- text: "Page:"
|
||||||
|
- strong [ref=e48]: 2ms
|
||||||
|
- text: "Template:"
|
||||||
|
- strong [ref=e49]: 0ms
|
||||||
|
- group "Links" [ref=e50]:
|
||||||
|
- menu [ref=e51] [cursor=pointer]:
|
||||||
|
- generic [ref=e53]:
|
||||||
|
- img [ref=e54]
|
||||||
|
- text: Auto
|
||||||
|
- menu [ref=e56] [cursor=pointer]:
|
||||||
|
- generic [ref=e57]:
|
||||||
|
- img [ref=e58]
|
||||||
|
- text: English
|
||||||
|
- link "Licenses" [ref=e60] [cursor=pointer]:
|
||||||
|
- /url: /assets/licenses.txt
|
||||||
|
- link "API" [ref=e61] [cursor=pointer]:
|
||||||
|
- /url: /api/swagger
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- generic [ref=e2]:
|
||||||
|
- navigation "Navigation Bar" [ref=e3]:
|
||||||
|
- generic [ref=e4]:
|
||||||
|
- link "Dashboard" [ref=e5] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e6]
|
||||||
|
- link "Issues" [ref=e7] [cursor=pointer]:
|
||||||
|
- /url: /issues
|
||||||
|
- link "Pull Requests" [ref=e8] [cursor=pointer]:
|
||||||
|
- /url: /pulls
|
||||||
|
- link "Milestones" [ref=e9] [cursor=pointer]:
|
||||||
|
- /url: /milestones
|
||||||
|
- link "Explore" [ref=e10] [cursor=pointer]:
|
||||||
|
- /url: /explore/repos
|
||||||
|
- generic [ref=e11]:
|
||||||
|
- link "Notifications" [ref=e12] [cursor=pointer]:
|
||||||
|
- /url: /notifications
|
||||||
|
- img [ref=e14]
|
||||||
|
- menu "Create…" [ref=e16] [cursor=pointer]:
|
||||||
|
- generic [ref=e17]:
|
||||||
|
- img [ref=e18]
|
||||||
|
- img [ref=e21]
|
||||||
|
- menu "Profile and Settings…" [ref=e23] [cursor=pointer]:
|
||||||
|
- generic [ref=e24]:
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- img "buenosair" [ref=e26]
|
||||||
|
- img [ref=e27]
|
||||||
|
- img [ref=e30]
|
||||||
|
- main "Applications" [ref=e32]:
|
||||||
|
- generic [ref=e33]:
|
||||||
|
- generic [ref=e35]:
|
||||||
|
- generic [ref=e36]: User Settings
|
||||||
|
- link "Profile" [ref=e37] [cursor=pointer]:
|
||||||
|
- /url: /user/settings
|
||||||
|
- link "Account" [ref=e38] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/account
|
||||||
|
- link "Appearance" [ref=e39] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/appearance
|
||||||
|
- link "Security" [ref=e40] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/security
|
||||||
|
- link "Blocked users" [ref=e41] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/blocked_users
|
||||||
|
- link "Applications" [ref=e42] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/applications
|
||||||
|
- link "SSH / GPG Keys" [ref=e43] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/keys
|
||||||
|
- group [ref=e44]:
|
||||||
|
- generic "Actions" [ref=e45] [cursor=pointer]
|
||||||
|
- link "Packages" [ref=e46] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/packages
|
||||||
|
- link "Webhooks" [ref=e47] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/hooks
|
||||||
|
- link "Organizations" [ref=e48] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/organization
|
||||||
|
- link "Repositories" [ref=e49] [cursor=pointer]:
|
||||||
|
- /url: /user/settings/repos
|
||||||
|
- generic [ref=e51]:
|
||||||
|
- heading "Manage Access Tokens" [level=4] [ref=e52]
|
||||||
|
- generic [ref=e54]:
|
||||||
|
- generic [ref=e55]: These tokens grant access to your account using the Gitea API.
|
||||||
|
- generic [ref=e56]:
|
||||||
|
- generic "This token has been used in the last 7 days" [ref=e58]:
|
||||||
|
- img [ref=e59]
|
||||||
|
- generic [ref=e61]:
|
||||||
|
- group [ref=e62]:
|
||||||
|
- generic "lvmh" [ref=e63] [cursor=pointer]:
|
||||||
|
- generic [ref=e64]: lvmh
|
||||||
|
- generic [ref=e66]:
|
||||||
|
- text: Added on 2026-08-18Aug 18, 2026 —
|
||||||
|
- img [ref=e67]
|
||||||
|
- text: Last used on
|
||||||
|
- generic [ref=e69]: 2026-08-20Aug 20, 2026
|
||||||
|
- button "Delete" [ref=e71] [cursor=pointer]:
|
||||||
|
- img [ref=e72]
|
||||||
|
- text: Delete
|
||||||
|
- generic [ref=e74]:
|
||||||
|
- img [ref=e77]
|
||||||
|
- generic [ref=e79]:
|
||||||
|
- group [ref=e80]:
|
||||||
|
- generic "hermes-pr-creator" [ref=e81] [cursor=pointer]:
|
||||||
|
- generic [ref=e82]: hermes-pr-creator
|
||||||
|
- generic [ref=e84]:
|
||||||
|
- text: Added on 2026-05-03May 3, 2026 —
|
||||||
|
- img [ref=e85]
|
||||||
|
- text: Last used on
|
||||||
|
- generic [ref=e87]: 2026-05-03May 3, 2026
|
||||||
|
- button "Delete" [ref=e89] [cursor=pointer]:
|
||||||
|
- img [ref=e90]
|
||||||
|
- text: Delete
|
||||||
|
- generic [ref=e92]:
|
||||||
|
- img [ref=e95]
|
||||||
|
- generic [ref=e97]:
|
||||||
|
- group [ref=e98]:
|
||||||
|
- generic "hermes-agent-pr" [ref=e99] [cursor=pointer]:
|
||||||
|
- generic [ref=e100]: hermes-agent-pr
|
||||||
|
- generic [ref=e102]:
|
||||||
|
- text: Added on 2026-05-03May 3, 2026 —
|
||||||
|
- img [ref=e103]
|
||||||
|
- text: No recent activity
|
||||||
|
- button "Delete" [ref=e106] [cursor=pointer]:
|
||||||
|
- img [ref=e107]
|
||||||
|
- text: Delete
|
||||||
|
- group [ref=e110]:
|
||||||
|
- generic "Generate New Token" [ref=e111] [cursor=pointer]:
|
||||||
|
- heading "Generate New Token" [level=4] [ref=e112]
|
||||||
|
- heading "Authorized OAuth2 Applications" [level=4] [ref=e113]
|
||||||
|
- generic [ref=e116]: You have granted access to your personal Gitea account to these third-party applications. Please revoke access for applications you no longer need.
|
||||||
|
- heading "Manage OAuth2 Applications" [level=4] [ref=e117]
|
||||||
|
- generic [ref=e120]: OAuth2 applications give your third-party application access to user accounts on this instance.
|
||||||
|
- group [ref=e122]:
|
||||||
|
- generic "Create a new OAuth2 Application" [ref=e123] [cursor=pointer]:
|
||||||
|
- heading "Create a new OAuth2 Application" [level=4] [ref=e124]
|
||||||
|
- group "Footer" [ref=e125]:
|
||||||
|
- contentinfo "About Software" [ref=e126]:
|
||||||
|
- link "Powered by Gitea" [ref=e127] [cursor=pointer]:
|
||||||
|
- /url: https://about.gitea.com
|
||||||
|
- generic [ref=e128]:
|
||||||
|
- text: "Version:"
|
||||||
|
- link "1.26.4" [ref=e129] [cursor=pointer]:
|
||||||
|
- /url: /-/admin/config
|
||||||
|
- generic [ref=e130]:
|
||||||
|
- text: "Page:"
|
||||||
|
- strong [ref=e131]: 13ms
|
||||||
|
- text: "Template:"
|
||||||
|
- strong [ref=e132]: 3ms
|
||||||
|
- group "Links" [ref=e133]:
|
||||||
|
- menu [ref=e134] [cursor=pointer]:
|
||||||
|
- generic [ref=e136]:
|
||||||
|
- img [ref=e137]
|
||||||
|
- text: Light
|
||||||
|
- menu [ref=e139] [cursor=pointer]:
|
||||||
|
- generic [ref=e140]:
|
||||||
|
- img [ref=e141]
|
||||||
|
- text: English
|
||||||
|
- link "Licenses" [ref=e143] [cursor=pointer]:
|
||||||
|
- /url: /assets/licenses.txt
|
||||||
|
- link "API" [ref=e144] [cursor=pointer]:
|
||||||
|
- /url: /api/swagger
|
||||||
+49
-3
@@ -23,8 +23,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
envToken string = "LVMH_TOKEN"
|
envToken string = "LVMH_TOKEN"
|
||||||
|
|
||||||
envModelsFile string = "LVMH_MODELS_FILE"
|
envSettingsFile string = "LVMH_SETTINGS_FILE"
|
||||||
defaultModelsFile string = "/app/build/docker/worker-models.json"
|
defaultSettingsFile string = "/app/build/docker/pi-agent/settings.json"
|
||||||
|
defaultBakedModelsFile string = "/app/build/docker/pi-agent/models.json"
|
||||||
|
envModelsFile string = "LVMH_MODELS_FILE"
|
||||||
|
defaultModelsFile string = "/app/build/docker/worker-models.json"
|
||||||
|
|
||||||
defaultEventsAfter int64 = 0
|
defaultEventsAfter int64 = 0
|
||||||
defaultEventsLimit int = 1000
|
defaultEventsLimit int = 1000
|
||||||
@@ -544,8 +547,38 @@ func parseModelCatalog(data []byte) []ModelCatalogItem {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseEnabledModels turns settings.json "enabledModels" entries
|
||||||
|
// ("provider/model-id") into catalog items, so built-in providers
|
||||||
|
// (anthropic, openai, google, ...) show up next to custom ones.
|
||||||
|
func parseEnabledModels(data []byte) []ModelCatalogItem {
|
||||||
|
var doc struct {
|
||||||
|
EnabledModels []string `json:"enabledModels"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &doc); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := []ModelCatalogItem{}
|
||||||
|
for _, entry := range doc.EnabledModels {
|
||||||
|
provider, id, ok := strings.Cut(entry, "/")
|
||||||
|
if !ok || provider == "" || id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ModelCatalogItem{Provider: provider, ID: id, Name: id})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := os.ReadFile(envOr(envModelsFile, defaultModelsFile))
|
// Prefer the baked dotfiles models.json (full provider set, synced by
|
||||||
|
// rsync-pi-agent.sh); fall back to the minimal frozen worker list.
|
||||||
|
path := envOr(envModelsFile, "")
|
||||||
|
if path == "" {
|
||||||
|
path = defaultBakedModelsFile
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
path = defaultModelsFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "model catalog unavailable: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "model catalog unavailable: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -555,6 +588,19 @@ func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "model catalog unreadable")
|
writeError(w, http.StatusInternalServerError, "model catalog unreadable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Merge in enabled built-in models from the baked pi settings; custom
|
||||||
|
// providers (models.json) win on duplicates.
|
||||||
|
if sdata, serr := os.ReadFile(envOr(envSettingsFile, defaultSettingsFile)); serr == nil {
|
||||||
|
seen := make(map[string]bool, len(catalog))
|
||||||
|
for _, m := range catalog {
|
||||||
|
seen[m.Provider+"/"+m.ID] = true
|
||||||
|
}
|
||||||
|
for _, m := range parseEnabledModels(sdata) {
|
||||||
|
if !seen[m.Provider+"/"+m.ID] {
|
||||||
|
catalog = append(catalog, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, catalog)
|
writeJSON(w, http.StatusOK, catalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -562,3 +562,16 @@ func TestAPIRepoImagesStoreFailure(t *testing.T) {
|
|||||||
t.Fatal("DELETE with broken store must 500")
|
t.Fatal("DELETE with broken store must 500")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseEnabledModels(t *testing.T) {
|
||||||
|
items := parseEnabledModels([]byte(`{"enabledModels":["anthropic/claude-opus-5","zai/glm","bad","/x","p/"]}`))
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("items = %+v, want 2 valid", items)
|
||||||
|
}
|
||||||
|
if items[0].Provider != "anthropic" || items[0].ID != "claude-opus-5" {
|
||||||
|
t.Fatalf("first = %+v", items[0])
|
||||||
|
}
|
||||||
|
if parseEnabledModels([]byte("not json")) != nil {
|
||||||
|
t.Fatal("invalid json must yield nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+60
-17
@@ -40,14 +40,19 @@ const (
|
|||||||
stateRunning string = "running"
|
stateRunning string = "running"
|
||||||
stateError string = "error"
|
stateError string = "error"
|
||||||
|
|
||||||
imageRefWorker string = "lvmh-worker:latest"
|
imageRefWorker string = "lvmh-worker:latest"
|
||||||
labelSession string = "lvmh.session"
|
labelSession string = "lvmh.session"
|
||||||
volumeRepoPrefix string = "lvmh-repo-"
|
volumeRepoPrefix string = "lvmh-repo-"
|
||||||
volumeSessions string = "lvmh-sessions"
|
volumeSessions string = "lvmh-sessions"
|
||||||
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
|
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
|
||||||
cacheMount string = "/root/.pi/agent/cache"
|
cacheMount string = "/root/.pi/agent/cache"
|
||||||
workspaceMount string = "/workspace"
|
authMountTarget string = "/root/.pi/agent/auth.json"
|
||||||
sessionsMount string = "/pi-sessions"
|
envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR"
|
||||||
|
envSecretsDir string = "LVMH_SECRETS_DIR"
|
||||||
|
sshMountTarget string = "/root/.ssh"
|
||||||
|
gitconfigMountTarget string = "/root/.gitconfig"
|
||||||
|
workspaceMount string = "/workspace"
|
||||||
|
sessionsMount string = "/pi-sessions"
|
||||||
|
|
||||||
envWorkerDockerfile string = "LVMH_WORKER_DOCKERFILE"
|
envWorkerDockerfile string = "LVMH_WORKER_DOCKERFILE"
|
||||||
defaultDockerfile string = "/app/build/docker/worker.Dockerfile"
|
defaultDockerfile string = "/app/build/docker/worker.Dockerfile"
|
||||||
@@ -348,6 +353,36 @@ func (s *Spawner) cloneOrUpdate(ctx context.Context, repo, branch, slug string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// giteaTokenEnv returns LVMH_GITEA_TOKEN=<pat> when a PAT is stored.
|
||||||
|
func giteaTokenEnv(store *Store) string {
|
||||||
|
if pat, ok, _ := store.GetSetting(settingGitLabToken); ok && pat != "" {
|
||||||
|
return "LVMH_GITEA_TOKEN=" + pat
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// workerEnv assembles the env for a spawned worker container.
|
||||||
|
func workerEnv(s *Spawner, repo, sessionID string) []string {
|
||||||
|
env := []string{
|
||||||
|
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
||||||
|
envToken + "=" + daemonToken,
|
||||||
|
"LVMH_URL=" + s.containerURL,
|
||||||
|
envLVMHSessionID + "=" + sessionID,
|
||||||
|
envLVMHAgent + "=1",
|
||||||
|
// other provider keys (empty ones are harmless)
|
||||||
|
"OPENAI_API_KEY=" + os.Getenv("OPENAI_API_KEY"),
|
||||||
|
"GEMINI_API_KEY=" + os.Getenv("GEMINI_API_KEY"),
|
||||||
|
"DEEPSEEK_KEY=" + os.Getenv("DEEPSEEK_KEY"),
|
||||||
|
"ANTHROPIC_API_KEY=" + os.Getenv("ANTHROPIC_API_KEY"),
|
||||||
|
envLVMHRepo + "=" + repo,
|
||||||
|
}
|
||||||
|
// Gitea token (write scope) so agents can push and open PRs.
|
||||||
|
if e := giteaTokenEnv(s.store); e != "" {
|
||||||
|
env = append(env, e)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
// cloneURL builds the clean https clone URL (never credential-bearing).
|
// cloneURL builds the clean https clone URL (never credential-bearing).
|
||||||
func (s *Spawner) cloneURL(repo string) (string, error) {
|
func (s *Spawner) cloneURL(repo string) (string, error) {
|
||||||
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
|
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
|
||||||
@@ -515,16 +550,24 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
|||||||
volumeSessions + ":" + sessionsMount,
|
volumeSessions + ":" + sessionsMount,
|
||||||
volumePiCache + ":" + cacheMount,
|
volumePiCache + ":" + cacheMount,
|
||||||
}
|
}
|
||||||
|
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
|
||||||
|
// spawned agents can use every model the catalog offers.
|
||||||
|
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
|
||||||
|
binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro")
|
||||||
|
}
|
||||||
|
// Deploy secrets (ssh key + known_hosts + config, gitconfig), read-only.
|
||||||
|
// Bind sources resolve on the HOST (docker.sock semantics), so this env
|
||||||
|
// must carry the host path of the secrets dir.
|
||||||
|
if sec := os.Getenv(envSecretsDir); sec != "" {
|
||||||
|
binds = append(
|
||||||
|
binds,
|
||||||
|
sec+":"+sshMountTarget+":ro",
|
||||||
|
sec+"/gitconfig:"+gitconfigMountTarget+":ro",
|
||||||
|
)
|
||||||
|
}
|
||||||
cfg := &container.Config{
|
cfg := &container.Config{
|
||||||
Image: image,
|
Image: image,
|
||||||
Env: []string{
|
Env: workerEnv(s, repo, sessionID),
|
||||||
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
|
||||||
envToken + "=" + daemonToken,
|
|
||||||
"LVMH_URL=" + s.containerURL,
|
|
||||||
envLVMHSessionID + "=" + sessionID,
|
|
||||||
envLVMHAgent + "=1",
|
|
||||||
envLVMHRepo + "=" + repo,
|
|
||||||
},
|
|
||||||
Labels: map[string]string{labelSession: sessionID},
|
Labels: map[string]string{labelSession: sessionID},
|
||||||
}
|
}
|
||||||
hostCfg := &container.HostConfig{
|
hostCfg := &container.HostConfig{
|
||||||
|
|||||||
@@ -487,3 +487,34 @@ func waitJobState(t *testing.T, sp *Spawner, sessionID, want string) SpawnJob {
|
|||||||
})
|
})
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSpawnerSecretsBinds(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeOK)
|
||||||
|
f := newFakeDocker()
|
||||||
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
sec := t.TempDir()
|
||||||
|
t.Setenv(envSecretsDir, sec)
|
||||||
|
|
||||||
|
res, err := sp.Start(context.Background(), "group/project", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||||
|
creates := f.createsByName("lvmh-agent-")
|
||||||
|
if len(creates) != 1 {
|
||||||
|
t.Fatalf("creates = %d", len(creates))
|
||||||
|
}
|
||||||
|
binds := creates[0].HostConfig.Binds
|
||||||
|
ssh, gc := false, false
|
||||||
|
for _, b := range binds {
|
||||||
|
if b == sec+":/root/.ssh:ro" {
|
||||||
|
ssh = true
|
||||||
|
}
|
||||||
|
if b == sec+"/gitconfig:/root/.gitconfig:ro" {
|
||||||
|
gc = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ssh || !gc {
|
||||||
|
t.Fatalf("secrets binds missing: %v", binds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-1
@@ -68,9 +68,18 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
|||||||
envLVMHAgent + "=1": true,
|
envLVMHAgent + "=1": true,
|
||||||
envLVMHRepo + "=group/project": true,
|
envLVMHRepo + "=group/project": true,
|
||||||
}
|
}
|
||||||
|
// provider key passthroughs are environment-dependent; ignore them here
|
||||||
|
passthrough := map[string]bool{
|
||||||
|
"OPENAI_API_KEY": true, "GEMINI_API_KEY": true,
|
||||||
|
"DEEPSEEK_KEY": true, "ANTHROPIC_API_KEY": true,
|
||||||
|
"LVMH_GITEA_TOKEN": true,
|
||||||
|
}
|
||||||
for _, e := range c.Env {
|
for _, e := range c.Env {
|
||||||
|
if name, _, ok := strings.Cut(e, "="); ok && passthrough[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !wantEnv[e] {
|
if !wantEnv[e] {
|
||||||
t.Fatalf("unexpected env %q in %v", e, c.Env)
|
t.Fatalf("unexpected env %q", e)
|
||||||
}
|
}
|
||||||
delete(wantEnv, e)
|
delete(wantEnv, e)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -23,8 +23,9 @@ fi
|
|||||||
bash "$(dirname "$0")/rsync-pi-agent.sh"
|
bash "$(dirname "$0")/rsync-pi-agent.sh"
|
||||||
|
|
||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
--exclude .git --exclude node_modules --exclude web/node_modules \
|
--exclude /.git --exclude /node_modules --exclude /web/node_modules \
|
||||||
--exclude .env --exclude .scratch --exclude .pi --exclude coverage \
|
--exclude /.env --exclude /.scratch --exclude /.pi --exclude /coverage \
|
||||||
|
--exclude '*.db' --exclude /daemon/lvmh-daemon --exclude /.playwright-mcp \
|
||||||
./ "$REMOTE:$REMOTE_DIR/"
|
./ "$REMOTE:$REMOTE_DIR/"
|
||||||
|
|
||||||
echo "building daemon image + worker image on $REMOTE..."
|
echo "building daemon image + worker image on $REMOTE..."
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ if [ ! -f "$SRC/settings.json" ]; then
|
|||||||
fi
|
fi
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude 'auth.json' \
|
--exclude 'auth.json' \
|
||||||
--exclude 'models.json' \
|
|
||||||
--exclude 'models-store.json' \
|
--exclude 'models-store.json' \
|
||||||
--exclude 'sessions/' \
|
--exclude 'sessions/' \
|
||||||
--exclude 'cache/' \
|
--exclude 'cache/' \
|
||||||
@@ -37,7 +36,7 @@ rsync -a --delete \
|
|||||||
# Keeps subagents/todo/async tooling present with no runtime network need.
|
# Keeps subagents/todo/async tooling present with no runtime network need.
|
||||||
# Requires network access to github.com at bake time.
|
# Requires network access to github.com at bake time.
|
||||||
python3 - "$DEST" <<'PY'
|
python3 - "$DEST" <<'PY'
|
||||||
import json, re, shutil, subprocess, sys
|
import json, os, re, shutil, subprocess, sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
dest = Path(sys.argv[1])
|
dest = Path(sys.argv[1])
|
||||||
@@ -59,13 +58,23 @@ for pkg in settings.get("packages") or []:
|
|||||||
subprocess.run(["git", "clone", "--quiet", url, str(target)], check=True)
|
subprocess.run(["git", "clone", "--quiet", url, str(target)], check=True)
|
||||||
if ref:
|
if ref:
|
||||||
subprocess.run(["git", "checkout", "--quiet", ref], cwd=target, check=True)
|
subprocess.run(["git", "checkout", "--quiet", ref], cwd=target, check=True)
|
||||||
shutil.rmtree(target / ".git", ignore_errors=True)
|
# keep .git: pi's package manager runs `git fetch` + `rev-parse` on
|
||||||
|
# existing installs; a stripped dir would fail resolution.
|
||||||
if (target / "package.json").exists():
|
if (target / "package.json").exists():
|
||||||
npm = "/usr/bin/npm" if Path("/usr/bin/npm").exists() else "npm"
|
npm = "/usr/bin/npm" if Path("/usr/bin/npm").exists() else "npm"
|
||||||
|
# --ignore-scripts: prepare scripts (husky) are devDependency-based
|
||||||
|
# and fail headless; extensions are plain TS needing runtime deps only.
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[npm, "install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
[npm, "install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||||
cwd=target, check=True, capture_output=True,
|
cwd=target, check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
# Append the lvmh deployment guide to the baked APPEND_SYSTEM.md so every
|
||||||
|
# spawned agent knows how to deploy hosts / push / open PRs.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
if [ -f "$SCRIPT_DIR/../docker/agent-append-system.md" ]; then
|
||||||
|
printf '\n%s\n' "$(cat "$SCRIPT_DIR/../docker/agent-append-system.md")" >> "$DEST/APPEND_SYSTEM.md"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "rsync-pi-agent: synced $(find "$DEST" -type f | wc -l) files from $SRC (git: packages preserved)"
|
echo "rsync-pi-agent: synced $(find "$DEST" -type f | wc -l) files from $SRC (git: packages preserved)"
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ services:
|
|||||||
# How spawned containers reach the daemon: alias on the shared network.
|
# How spawned containers reach the daemon: alias on the shared network.
|
||||||
LVMH_CONTAINER_LVMH_URL: ws://lvmh:8686/agent/ws
|
LVMH_CONTAINER_LVMH_URL: ws://lvmh:8686/agent/ws
|
||||||
GITLAB_BASE_URL: https://git.westphal.fr
|
GITLAB_BASE_URL: https://git.westphal.fr
|
||||||
|
# host path of the pi config, so spawned containers can bind auth.json
|
||||||
|
# docker.sock bind semantics: bind sources resolve on the HOST, so
|
||||||
|
# this must be the HOST path of the pi config (auth.json etc.).
|
||||||
|
LVMH_HOST_PI_AGENT_DIR: ${LVMH_PI_AGENT_DIR:-/home/alarm/.dotfiles/pi/agent}
|
||||||
|
LVMH_SECRETS_DIR: /zdata/root/lvmh-secrets
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
DEEPSEEK_KEY: ${DEEPSEEK_KEY:-}
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- lvmh-data:/data
|
- lvmh-data:/data
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# lvmh agent deployment guide
|
||||||
|
|
||||||
|
You are running inside an lvmh worker container with deployment capabilities.
|
||||||
|
|
||||||
|
## Deploy hosts (SSH, passwordless key at /root/.ssh/id_ed25519)
|
||||||
|
|
||||||
|
- `alarm` — the main server. Services live in `/zdata/root/dockerFiles/<name>`
|
||||||
|
(docker compose). lvmh itself: `/zdata/root/dockerFiles/lvmh`.
|
||||||
|
- `desk` — desktop machine.
|
||||||
|
- `blanc-nas` — NAS.
|
||||||
|
|
||||||
|
SSH works directly: `ssh alarm 'sudo docker compose -f /zdata/root/dockerFiles/empstream/docker-compose.yml up -d --build'`.
|
||||||
|
The key is also registered in Gitea (but Gitea has no SSH port — use HTTPS+token for git, see below).
|
||||||
|
|
||||||
|
## Git pushes and pull requests
|
||||||
|
|
||||||
|
Your Gitea token is in `$LVMH_GITEA_TOKEN` (scopes: write repository + user).
|
||||||
|
Gitea: `https://git.westphal.fr` (user `buenosair`).
|
||||||
|
|
||||||
|
Clone/push with the token — NEVER put it in a URL that gets written to disk:
|
||||||
|
|
||||||
|
```
|
||||||
|
git -c http.extraHeader="Authorization: token $LVMH_GITEA_TOKEN" clone https://git.westphal.fr/<owner>/<repo>.git
|
||||||
|
git -c http.extraHeader="Authorization: token $LVMH_GITEA_TOKEN" push origin <branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
(For pulls from an existing clone, the same `-c` flag works with `pull`/`fetch`/`push`.)
|
||||||
|
|
||||||
|
Create a pull request:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -s -X POST -H "Authorization: token $LVMH_GITEA_TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"title":"<title>","head":"<branch>","base":"<base>","body":"<description>"}' \
|
||||||
|
https://git.westphal.fr/api/v1/repos/<owner>/<repo>/pulls
|
||||||
|
```
|
||||||
|
|
||||||
|
Comment, list PRs, merge — same `/api/v1/repos/<owner>/<repo>/pulls` resource family.
|
||||||
|
|
||||||
|
## Known repo deploy targets
|
||||||
|
|
||||||
|
- `buenosair/ezrpc`, `buenosair/empstream`, `buenosair/mev` — docker-compose services on
|
||||||
|
`alarm` under `/zdata/root/dockerFiles/`. Typical flow: push branch/merge to main →
|
||||||
|
`ssh alarm` → `git -C /zdata/root/dockerFiles/<dir> pull` (or clone if missing) →
|
||||||
|
`sudo docker compose up -d --build`.
|
||||||
|
- Check what's running: `ssh alarm 'sudo docker ps'`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never print or commit `$LVMH_GITEA_TOKEN` or the contents of `/root/.ssh/id_ed25519`.
|
||||||
|
- Prefer PRs for non-trivial changes; ask in chat before force-pushing or touching main.
|
||||||
|
- On deploy hosts, `sudo` is available for this key where needed; use it only for
|
||||||
|
service management (docker/systemctl), not for arbitrary system changes.
|
||||||
@@ -13,14 +13,18 @@ RUN apt-get update \
|
|||||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
# Same npm shim as the worker image (see worker.Dockerfile).
|
# Same npm shim as the worker image (see worker.Dockerfile).
|
||||||
RUN mv /usr/local/bin/npm /usr/local/bin/npm-real
|
COPY docker/npm-real /usr/local/bin/npm-real
|
||||||
COPY docker/npm-shim /usr/local/bin/npm
|
COPY docker/npm-shim /usr/local/bin/npm-ignore-scripts
|
||||||
RUN chmod +x /usr/local/bin/npm
|
# rm first: /usr/local/bin/npm is a symlink into npm's lib dir, and COPY
|
||||||
|
# would follow it and clobber npm-cli.js itself.
|
||||||
|
RUN rm -f /usr/local/bin/npm \
|
||||||
|
&& mv /usr/local/bin/npm-ignore-scripts /usr/local/bin/npm \
|
||||||
|
&& chmod +x /usr/local/bin/npm-real /usr/local/bin/npm
|
||||||
|
|
||||||
# Same pi config as workers (dotfiles, filtered; git: packages stripped).
|
# Same pi config as workers (dotfiles, filtered; git: packages stripped).
|
||||||
COPY docker/pi-agent/ /root/.pi/agent/
|
COPY docker/pi-agent/ /root/.pi/agent/
|
||||||
COPY plugin/lvmh-agent.ts /root/.pi/agent/extensions/lvmh-agent.ts
|
COPY plugin/lvmh-agent.ts /root/.pi/agent/extensions/lvmh-agent.ts
|
||||||
COPY docker/worker-models.json /root/.pi/agent/models.json
|
COPY docker/worker-models.json /root/.pi/agent/models.json.fallback
|
||||||
|
|
||||||
# Ops instructions live in the workspace (AGENTS.md is auto-loaded from cwd).
|
# Ops instructions live in the workspace (AGENTS.md is auto-loaded from cwd).
|
||||||
COPY docker/ops-context/AGENTS.md /ops-seed/AGENTS.md
|
COPY docker/ops-context/AGENTS.md /ops-seed/AGENTS.md
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[user]
|
||||||
|
name = buenosair
|
||||||
|
email = x3nx@hotmail.fr
|
||||||
|
[commit]
|
||||||
|
gpgsign = false
|
||||||
|
[tag]
|
||||||
|
gpgsign = false
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Thin launcher for the real npm CLI (npm ships as JS, not a binary).
|
||||||
|
exec node /usr/local/lib/node_modules/npm/bin/npm-cli.js "$@"
|
||||||
+7
-8
@@ -1,16 +1,15 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# npm shim for headless pi containers: skip lifecycle scripts during package
|
# npm shim for headless pi containers: skip lifecycle scripts during package
|
||||||
# installs. pi installs git: packages by running `npm install --omit=dev` in
|
# installs. pi installs git: packages by running `npm install` in each clone;
|
||||||
# each clone; some upstream packages (e.g. husky in rpiv-mono) have
|
# some upstream packages (e.g. husky in rpiv-mono) have postinstall scripts
|
||||||
# postinstall scripts that assume a dev machine and fail/crash headless
|
# that assume a dev machine and fail headless installs. --ignore-scripts
|
||||||
# installs. --ignore-scripts makes installs safe; runtime code (pure TS/JS
|
# makes installs safe; runtime code (pure TS/JS extensions) does not need
|
||||||
# extensions) does not need lifecycle scripts. Everything else passes through
|
# installs. Everything else passes through to the real npm (docker/npm-real).
|
||||||
# to the real npm transparently.
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
install|i)
|
install|i)
|
||||||
exec /usr/bin/npm-real "$@" --ignore-scripts
|
exec /usr/local/bin/npm-real "$@" --ignore-scripts
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
exec /usr/bin/npm-real "$@"
|
exec /usr/local/bin/npm-real "$@"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Host alarm
|
||||||
|
HostName 100.64.84.69
|
||||||
|
User alarm
|
||||||
|
Host desk
|
||||||
|
HostName 100.73.57.3
|
||||||
|
User raph
|
||||||
|
Host blanc-nas
|
||||||
|
HostName 100.91.19.107
|
||||||
|
User raph
|
||||||
|
Host alarm desk blanc-nas
|
||||||
|
IdentityFile /root/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
StrictHostKeyChecking yes
|
||||||
|
UserKnownHostsFile /root/.ssh/known_hosts
|
||||||
|
ConnectTimeout 8
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 100.64.84.69:22 SSH-2.0-OpenSSH_10.4
|
||||||
|
100.64.84.69 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGIoBFYAfkN1Q8XsjwsJZ2mLIQi9XQ9M03zEENW8Injq
|
||||||
|
# 100.73.57.3:22 SSH-2.0-OpenSSH_10.4
|
||||||
|
100.73.57.3 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN5kwgNZ/IYLpAxVtezTxYLcy7MmP9lKnrKX0XeBiCIn
|
||||||
|
# 100.91.19.107:22 SSH-2.0-OpenSSH_10.4
|
||||||
|
100.91.19.107 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFB7p37Rxw6tW2rUpoECwtXLBnU179CxiwTNtwJQs7Sp
|
||||||
@@ -17,19 +17,24 @@ RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
|||||||
# scripts (husky etc.) crash headless installs. Route installs through
|
# scripts (husky etc.) crash headless installs. Route installs through
|
||||||
# --ignore-scripts so ALL dotfiles packages (pi-subagents, todo tooling,
|
# --ignore-scripts so ALL dotfiles packages (pi-subagents, todo tooling,
|
||||||
# async agents, every extension) survive the bake.
|
# async agents, every extension) survive the bake.
|
||||||
RUN mv /usr/local/bin/npm /usr/local/bin/npm-real
|
COPY docker/npm-real /usr/local/bin/npm-real
|
||||||
COPY docker/npm-shim /usr/local/bin/npm
|
COPY docker/npm-shim /usr/local/bin/npm-ignore-scripts
|
||||||
RUN chmod +x /usr/local/bin/npm
|
# rm first: /usr/local/bin/npm is a symlink into npm's lib dir, and COPY
|
||||||
|
# would follow it and clobber npm-cli.js itself.
|
||||||
|
RUN rm -f /usr/local/bin/npm \
|
||||||
|
&& mv /usr/local/bin/npm-ignore-scripts /usr/local/bin/npm \
|
||||||
|
&& chmod +x /usr/local/bin/npm-real /usr/local/bin/npm
|
||||||
|
|
||||||
|
COPY docker/worker-models.json /root/.pi/agent/models.json.fallback
|
||||||
# User's pi config from dotfiles (settings, skills, agents, extensions,
|
# User's pi config from dotfiles (settings, skills, agents, extensions,
|
||||||
# APPEND_SYSTEM.md) — synced by deploy.sh from ~/.dotfiles/pi/agent (filtered:
|
# APPEND_SYSTEM.md) — synced by deploy.sh from ~/.dotfiles/pi/agent (filtered:
|
||||||
# no auth.json/sessions/cache/npm). If docker/pi-agent/ is absent this layer
|
# no auth.json/sessions/cache/npm). If docker/pi-agent/ is absent this layer
|
||||||
# is skipped (mkdir keeps later COPY targets valid).
|
# is skipped (mkdir keeps later COPY targets valid).
|
||||||
COPY docker/pi-agent/ /root/.pi/agent/
|
COPY docker/pi-agent/ /root/.pi/agent/
|
||||||
|
|
||||||
# lvmh overlays: dial-home plugin + env-ref models.json win over dotfiles copies.
|
# lvmh overlays: dial-home plugin; models.json comes from dotfiles (baked
|
||||||
|
# by rsync-pi-agent.sh; the .fallback above covers a dotfiles-less bake).
|
||||||
COPY plugin/lvmh-agent.ts /root/.pi/agent/extensions/lvmh-agent.ts
|
COPY plugin/lvmh-agent.ts /root/.pi/agent/extensions/lvmh-agent.ts
|
||||||
COPY docker/worker-models.json /root/.pi/agent/models.json
|
|
||||||
COPY docker/bridge /bridge
|
COPY docker/bridge /bridge
|
||||||
|
|
||||||
# Per-session pi sessions persist here (volume lvmh-sessions); package cache
|
# Per-session pi sessions persist here (volume lvmh-sessions); package cache
|
||||||
|
|||||||
Reference in New Issue
Block a user