Skip to content
18 changes: 16 additions & 2 deletions cmd/agent/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agent
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -50,6 +51,15 @@ func NewDaemonCmd(flags *flags.GlobalFlags) *cobra.Command {
}

func (cmd *DaemonCmd) Run(ctx context.Context) error {
// The agent daemon is a container/machine-side process; the host
// never runs `devsy agent daemon`. Reject host invocations explicitly
// to avoid silently scanning a non-existent legacy glob.
if agent.IsHostAgentInvocation(cmd.AgentDir) {
return fmt.Errorf(
"`devsy agent daemon` is only valid inside the workspace container or machine",
)
}

logFolder, err := agent.GetAgentDaemonLogFolder(cmd.AgentDir)
if err != nil {
return err
Expand Down Expand Up @@ -93,7 +103,9 @@ func (cmd *DaemonCmd) doOnce(ctx context.Context) {
var latestActivity *time.Time
var workspace *provider2.AgentWorkspaceInfo

// get base folder
// get base folder — only reachable from Run, which rejects host
// invocations, so FindAgentHomeFolder always resolves the legacy
// container/machine layout here.
baseFolder, err := agent.FindAgentHomeFolder(cmd.AgentDir)
if err != nil {
return
Expand Down Expand Up @@ -200,7 +212,9 @@ func (cmd *DaemonCmd) runShutdownCommand(
}

func (cmd *DaemonCmd) initialTouch() {
// get base folder
// get base folder — only reachable from Run, which rejects host
// invocations, so this always resolves the legacy container/machine
// layout.
baseFolder, err := agent.FindAgentHomeFolder(cmd.AgentDir)
if err != nil {
return
Expand Down
9 changes: 9 additions & 0 deletions cmd/agent/workspace/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ func NewLogsCmd(flags *flags.GlobalFlags) *cobra.Command {
}

func (cmd *LogsCmd) Run(ctx context.Context) error {
// `agent workspace logs` returns devcontainer logs from inside the
// workspace container/machine. Reject host invocations explicitly
// to avoid silently reading from the wrong place.
if agent.IsHostAgentInvocation(cmd.AgentDir) {
return fmt.Errorf(
"`devsy agent workspace logs` is only valid inside the workspace container or machine",
)
}

// get workspace info
shouldExit, workspaceInfo, err := agent.ReadAgentWorkspaceInfo(
cmd.AgentDir,
Expand Down
9 changes: 9 additions & 0 deletions cmd/agent/workspace/logs_daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ func NewLogsDaemonCmd(flags *flags.GlobalFlags) *cobra.Command {
}

func (cmd *LogsDaemonCmd) Run(ctx context.Context) error {
// `agent workspace logs-daemon` reads agent-daemon.log, which only
// exists inside the workspace container or machine. Reject host
// invocations explicitly to surface misconfigurations early.
if agent.IsHostAgentInvocation(cmd.AgentDir) {
return fmt.Errorf(
"`devsy agent workspace logs-daemon` is only valid inside the workspace container or machine",
)
}

// get workspace
shouldExit, _, err := agent.ReadAgentWorkspaceInfo(
cmd.AgentDir,
Expand Down
3 changes: 2 additions & 1 deletion cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ func (cmd *LogsCmd) Run(ctx context.Context, args []string) error {
defer func() { _ = session.Close() }()

agentCommand := fmt.Sprintf(
"'%s' agent workspace logs --context '%s' --id '%s'",
"%s%q agent workspace logs --context %q --id %q",
agent.ContainerAgentEnvPrefix,
client.AgentPath(),
client.Context(),
client.Workspace(),
Expand Down
6 changes: 4 additions & 2 deletions cmd/logs_daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/pkg/agent"
"github.com/devsy-org/devsy/pkg/client"
"github.com/devsy-org/devsy/pkg/config"
provider2 "github.com/devsy-org/devsy/pkg/provider"
Expand Down Expand Up @@ -65,13 +66,14 @@ func (cmd *LogsDaemonCmd) Run(ctx context.Context, args []string) error {
}

command := fmt.Sprintf(
"'%s' agent workspace logs-daemon --context '%s' --id '%s'",
"%s%q agent workspace logs-daemon --context %q --id %q",
agent.ContainerAgentEnvPrefix,
workspaceClient.AgentPath(),
workspaceClient.Context(),
workspaceClient.Workspace(),
)
if agentInfo.Agent.DataPath != "" {
command += fmt.Sprintf(" --agent-dir '%s'", agentInfo.Agent.DataPath)
command += fmt.Sprintf(" --agent-dir %q", agentInfo.Agent.DataPath)
}

// read daemon logs
Expand Down
3 changes: 2 additions & 1 deletion cmd/up/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ func (cmd *UpCmd) devsyUpMachineSSH(
}

agentCommand := fmt.Sprintf(
"'%s' agent workspace up --workspace-info '%s'",
"%s%q agent workspace up --workspace-info %q",
agent.ContainerAgentEnvPrefix,
client.AgentPath(),
workspaceInfo,
)
Expand Down
2 changes: 2 additions & 0 deletions cmd/up/up_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ func (cmd *UpCmd) prepareClient( //nolint:funlen

cmd.resolveSSHConfig(devsyConfig)

log.Debugf("up: resolving workspace with cmd.IDE=%q ide-launch=%q", cmd.IDE, cmd.IDELaunch)

client, err := workspace2.Resolve(
ctx,
devsyConfig,
Expand Down
86 changes: 67 additions & 19 deletions desktop/src/main/__tests__/log-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { mkdtempSync, rmSync } from "node:fs"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { LogStore } from "../log-store.js"

const CTX = "default"

describe("LogStore", () => {
let store: LogStore
let tempDir: string
Expand All @@ -17,14 +19,16 @@ describe("LogStore", () => {
rmSync(tempDir, { recursive: true, force: true })
})

it("creates a log file and returns its path", () => {
const logPath = store.createLogFile("ws-1")
expect(logPath).toContain("ws-1")
it("creates a log file under the per-workspace dir", () => {
const logPath = store.createLogFile(CTX, "ws-1")
expect(logPath).toContain(
join("contexts", CTX, "workspaces", "ws-1", "logs"),
)
expect(logPath).toMatch(/\.log$/)
})

it("appends lines to a log file", () => {
const logPath = store.createLogFile("ws-1")
const logPath = store.createLogFile(CTX, "ws-1")
store.appendLog(logPath, "line 1")
store.appendLog(logPath, "line 2")
const content = store.readLogByPath(logPath)
Expand All @@ -33,40 +37,84 @@ describe("LogStore", () => {
})

it("lists logs for a workspace, newest first", () => {
store.createLogFile("ws-1")
// Small delay to ensure different timestamps
const path2 = store.createLogFile("ws-1")
const entries = store.listLogs("ws-1")
store.createLogFile(CTX, "ws-1")
const path2 = store.createLogFile(CTX, "ws-1")
const entries = store.listLogs(CTX, "ws-1")
expect(entries).toHaveLength(2)
expect(entries[0].filename).toBe(
path2.split("/").pop()?.split("\\").pop() ?? "",
)
})

it("returns empty list for unknown workspace", () => {
expect(store.listLogs("nonexistent")).toEqual([])
expect(store.listLogs(CTX, "nonexistent")).toEqual([])
})

it("reads a log file by workspace and filename", () => {
const logPath = store.createLogFile("ws-1")
const logPath = store.createLogFile(CTX, "ws-1")
store.appendLog(logPath, "test content")
const entries = store.listLogs("ws-1")
const content = store.readLog("ws-1", entries[0].filename)
const entries = store.listLogs(CTX, "ws-1")
const content = store.readLog(CTX, "ws-1", entries[0].filename)
expect(content).toContain("test content")
})

it("deletes a log file", () => {
const logPath = store.createLogFile("ws-1")
const logPath = store.createLogFile(CTX, "ws-1")
store.appendLog(logPath, "data")
const entries = store.listLogs("ws-1")
store.deleteLog("ws-1", entries[0].filename)
expect(store.listLogs("ws-1")).toHaveLength(0)
const entries = store.listLogs(CTX, "ws-1")
store.deleteLog(CTX, "ws-1", entries[0].filename)
expect(store.listLogs(CTX, "ws-1")).toHaveLength(0)
})

it("prunes logs older than maxAge (keeps recent)", () => {
store.createLogFile("ws-1")
store.createLogFile(CTX, "ws-1")
const removed = store.prune(30)
expect(removed).toBe(0)
expect(store.listLogs("ws-1")).toHaveLength(1)
expect(store.listLogs(CTX, "ws-1")).toHaveLength(1)
})

it("isolates logs per workspace and per context", () => {
store.createLogFile(CTX, "ws-1")
store.createLogFile("other-ctx", "ws-1")
store.createLogFile(CTX, "ws-2")
expect(store.listLogs(CTX, "ws-1")).toHaveLength(1)
expect(store.listLogs("other-ctx", "ws-1")).toHaveLength(1)
expect(store.listLogs(CTX, "ws-2")).toHaveLength(1)
})

it("rejects non-.log basenames after stripping traversal", () => {
// basename("../../etc/passwd") = "passwd" — no .log extension, rejected.
expect(() => store.readLog(CTX, "ws-1", "../../etc/passwd")).toThrow(
/invalid log filename/,
)
expect(() => store.deleteLog(CTX, "ws-1", "../../etc/passwd")).toThrow(
/invalid log filename/,
)
expect(() => store.readLog(CTX, "ws-1", "notes.txt")).toThrow(
/invalid log filename/,
)
})

it("confines traversal-shaped .log filenames to the workspace dir", () => {
// Plant a sibling.log OUTSIDE the workspace logs dir.
const outside = join(tempDir, "sibling.log")
writeFileSync(outside, "outside-content")
// basename("../sibling.log") = "sibling.log" → looked up INSIDE the
// workspace logs dir, where nothing of that name exists. The planted
// file outside the dir must NOT be reachable.
expect(() => store.readLog(CTX, "ws-1", "../sibling.log")).toThrow(
/ENOENT/,
)
})

it("prune skips non-directory entries without aborting", () => {
// Create a normal log first.
store.createLogFile(CTX, "ws-1")
// Plant a regular file where prune would expect a workspace dir.
const contextsRoot = join(tempDir, "contexts", CTX, "workspaces")
const stray = join(contextsRoot, "stray-file")
writeFileSync(stray, "")
expect(() => store.prune(30)).not.toThrow()
expect(store.listLogs(CTX, "ws-1")).toHaveLength(1)
})
})
59 changes: 58 additions & 1 deletion desktop/src/main/__tests__/state.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"
import { describe, expect, it, vi } from "vitest"
import type { Context, Machine, Provider, Workspace } from "../state.js"
import { DaemonState } from "../state.js"

Expand Down Expand Up @@ -90,4 +90,61 @@ describe("DaemonState", () => {
expect(state.updateContexts(contexts, "default")).toBe(false)
expect(state.updateContexts(contexts, "staging")).toBe(true)
})

describe("workspaceContext", () => {
it("returns the workspace's context when known and set", () => {
const state = new DaemonState()
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
state.updateWorkspaces([
{ id: "ws1", lastUsed: "2024-01-01", context: "team-prod" },
])
expect(state.workspaceContext("ws1")).toBe("team-prod")
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})

it("falls back to active context and warns when workspace exists but context is missing", () => {
const state = new DaemonState()
state.updateContexts([{ name: "team-prod" }], "team-prod")
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
state.updateWorkspaces([{ id: "ws1", lastUsed: "2024-01-01" }])
expect(state.workspaceContext("ws1")).toBe("team-prod")
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0][0]).toContain("ws1")
expect(warn.mock.calls[0][0]).toContain("no context field")
warn.mockRestore()
})

it("falls back to active context and warns when workspace exists but context is empty string", () => {
const state = new DaemonState()
state.updateContexts([{ name: "team-prod" }], "team-prod")
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
state.updateWorkspaces([
{ id: "ws1", lastUsed: "2024-01-01", context: "" },
])
expect(state.workspaceContext("ws1")).toBe("team-prod")
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0][0]).toContain("ws1")
warn.mockRestore()
})

it("falls back to active context and warns when workspace is unknown", () => {
const state = new DaemonState()
state.updateContexts([{ name: "team-prod" }], "team-prod")
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
expect(state.workspaceContext("missing")).toBe("team-prod")
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0][0]).toContain("missing")
expect(warn.mock.calls[0][0]).toContain("not found")
warn.mockRestore()
})

it("falls back to 'default' when the watcher hasn't populated active context yet", () => {
const state = new DaemonState()
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
expect(state.workspaceContext("missing")).toBe("default")
expect(warn).toHaveBeenCalledTimes(1)
warn.mockRestore()
})
})
})
Loading
Loading