Skip to content

Commit aebad4a

Browse files
feat(cli): Server Edition server-mode — gateway login/workspace commands, remote transport, headless entry hardening (#81)
### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [x] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? This PR prepares the new CLI (`packages/cli`, `dacode`) and the headless server to act as a client/data-plane for DeepAgent Server Edition (design: server-v1 §11/§13/§20). The CLI now connects to a gateway with JWT auth and routes workspace traffic through the `/w/:workspaceId` transparent proxy instead of a local daemon. **CLI changes:** - `dacode login [gateway] [--email --password]` / `logout` – authenticate via `POST /control/v1/auth/login`, store state in `~/.deepagent/code/state/server-mode.json` (0600, atomic temp+rename). Flags optional; fallback to interactive prompts. Refresh token read from response body, with `Set-Cookie` as fallback. - `dacode workspace list` / `dacode workspace use <id>` – list gateway workspaces and pin a selection; remote base URL becomes `{gateway}/w/{workspaceId}`. - The `Connection` service switches to remote transport when server-mode is active; otherwise uses the existing local daemon (unchanged behavior). - Remote transport is a fetch wrapper that injects `Authorization: Bearer` and, on 401, performs a single-flight token refresh with one retry. Since the TUI already accepts a custom `fetch`, all TUI/SDK traffic (including SSE) gets auth + reconnect without any changes to `packages/tui`. - `DEEPAGENT_GATEWAY_URL` pins the gateway (server-v1 §20.3 auto-switch); a mismatch with stored login produces a clear error suggesting `dacode login <url>`. **Server changes (`packages/deepagent-code`):** - Add explicit `"./server"` subpath export so `import { listen, openapi } from "deepagent-code/server"` resolves as documented for the workspace-agent. - `/global/capabilities` gains an optional `commit` field from `DEEPAGENT_CODE_COMMIT` (injected by CI for version-checking, §13.3). Omitted in local builds; optional in schema for backward compatibility. - `DEEPAGENT_SERVER_MODE=true` makes `Auth.set`/`Auth.remove` fail with a clear error – in gateway-managed containers, provider keys come via env and must not persist to volume (§20.4). Reads (including `DEEPAGENT_CODE_AUTH_CONTENT`) are unaffected. > **Note:** The gateway itself (`deepagent-code-server` repo) is not implemented yet; the refresh-cookie contract may need a follow-up once the real gateway exists. ### How did you verify your code works? - `bun typecheck` passes for `packages/cli`, `packages/core`, `packages/deepagent-code`. - End-to-end against a mock gateway (`Bun.serve` implementing auth endpoints, workspace listing, and proxy paths): covered login via args/env, state file permissions, workspace list/use with expired token refresh, remote transport URL and auth injection, logout, and gateway mismatch errors. - Server tests: started the legacy server via the new subpath export, confirmed `/global/capabilities` returns the injected commit and `openapi()` works; `Auth.set`/`Auth.remove` are blocked under `DEEPAGENT_SERVER_MODE=true`; without it `auth.json` is written with mode 600. - Existing related suites pass: `test/auth/auth.test.ts`, `test/server/httpapi-global.test.ts`, `test/server/httpapi-public-openapi.test.ts`. (`test/server/httpapi-listen.test.ts` has PTY failures that reproduce identically on the base commit in this environment.) ### Screenshots / recordings _No UI changes._ ### Checklist - [x] I have tested my changes locally - [x] I have not included unrelated changes in this PR --------- Co-authored-by: deepagent-ai <jamessmithm539@gmail.com>
1 parent f171a9a commit aebad4a

23 files changed

Lines changed: 1269 additions & 28 deletions

File tree

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"scripts": {
1515
"build": "bun run script/build.ts",
1616
"dev": "bun run src/index.ts",
17-
"typecheck": "tsgo --noEmit"
17+
"typecheck": "tsgo --noEmit",
18+
"test": "bun test --timeout 30000",
19+
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
1820
},
1921
"dependencies": {
2022
"@effect/platform-node": "catalog:",

packages/cli/src/commands/commands.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ export const Commands = Spec.make(
1212
description: "Debugging and troubleshooting tools",
1313
commands: [Spec.make("agents", { description: "List all agents" })],
1414
}),
15+
Spec.make("login", {
16+
description: "Log in to a DeepAgent Server gateway (server mode)",
17+
params: {
18+
gateway: Argument.string("gateway").pipe(Argument.optional),
19+
email: Flag.string("email").pipe(Flag.optional),
20+
password: Flag.string("password").pipe(Flag.optional),
21+
},
22+
}),
23+
Spec.make("logout", { description: "Log out of the DeepAgent Server gateway" }),
24+
Spec.make("workspace", {
25+
description: "Manage DeepAgent Server workspaces (server mode)",
26+
commands: [
27+
Spec.make("list", { description: "List workspaces on the gateway" }),
28+
Spec.make("use", {
29+
description: "Select the workspace to connect to",
30+
params: { id: Argument.string("id") },
31+
}),
32+
],
33+
}),
1534
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
1635
Spec.make("service", {
1736
description: "Manage the background server",

packages/cli/src/commands/handlers/default.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Commands } from "../commands"
22
import { Runtime } from "../../framework/runtime"
33
import { Effect } from "effect"
4-
import { Daemon } from "../../services/daemon"
4+
import { Connection } from "../../services/connection"
55

66
export default Runtime.handler(Commands, () =>
77
Effect.gen(function* () {
8-
const daemon = yield* Daemon.Service
9-
const transport = yield* daemon.transport()
8+
const connection = yield* Connection.Service
9+
const transport = yield* connection.transport()
1010
const { runTui } = yield* Effect.promise(() => import("../../tui"))
1111
yield* runTui(transport)
1212
}),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import * as NodeServices from "@effect/platform-node/NodeServices"
4+
import { Option, Redacted } from "effect"
5+
import { Prompt } from "effect/unstable/cli"
6+
import { Commands } from "../commands"
7+
import { Runtime } from "../../framework/runtime"
8+
import { ServerMode } from "../../services/server-mode"
9+
10+
export default Runtime.handler(
11+
Commands.commands.login,
12+
Effect.fn("cli.login")(function* (input) {
13+
const serverMode = yield* ServerMode.Service
14+
const gateway = Option.getOrUndefined(input.gateway) ?? process.env.DEEPAGENT_GATEWAY_URL
15+
if (gateway === undefined)
16+
return yield* Effect.fail(
17+
new Error("Gateway URL required. Pass it as an argument or set DEEPAGENT_GATEWAY_URL."),
18+
)
19+
const url = gateway.replace(/\/+$/, "")
20+
const email = yield* Option.match(input.email, {
21+
onNone: () =>
22+
Prompt.run(Prompt.text({ message: "Email" })).pipe(
23+
Effect.provide(NodeServices.layer),
24+
Effect.mapError(() => new Error("Login cancelled")),
25+
),
26+
onSome: Effect.succeed,
27+
})
28+
const password = yield* Option.match(input.password, {
29+
onNone: () =>
30+
Prompt.run(Prompt.password({ message: "Password" })).pipe(
31+
Effect.provide(NodeServices.layer),
32+
Effect.map(Redacted.value),
33+
Effect.mapError(() => new Error("Login cancelled")),
34+
),
35+
onSome: Effect.succeed,
36+
})
37+
const state = yield* serverMode.login(url, email, password)
38+
process.stdout.write(
39+
`Logged in to ${state.gatewayUrl} as ${email}.${state.workspaceId ? "" : " Run `dacode workspace list` to pick a workspace."}` +
40+
EOL,
41+
)
42+
}),
43+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import { Commands } from "../commands"
4+
import { Runtime } from "../../framework/runtime"
5+
import { ServerMode } from "../../services/server-mode"
6+
7+
export default Runtime.handler(
8+
Commands.commands.logout,
9+
Effect.fn("cli.logout")(function* () {
10+
yield* (yield* ServerMode.Service).logout()
11+
process.stdout.write("Logged out." + EOL)
12+
}),
13+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import { Option } from "effect"
4+
import { Commands } from "../../commands"
5+
import { Runtime } from "../../../framework/runtime"
6+
import { ServerMode } from "../../../services/server-mode"
7+
8+
export default Runtime.handler(
9+
Commands.commands.workspace.commands.list,
10+
Effect.fn("cli.workspace.list")(function* () {
11+
const serverMode = yield* ServerMode.Service
12+
const list = yield* serverMode.workspaces()
13+
const current = yield* serverMode
14+
.status()
15+
.pipe(Effect.map((state) => (Option.isSome(state) ? state.value.workspaceId : undefined)))
16+
list.forEach((workspace) => {
17+
const selected = current === workspace.id ? "*" : " "
18+
process.stdout.write(
19+
`${selected} ${workspace.id} ${workspace.name ?? "-"} ${workspace.status ?? "-"}` + EOL,
20+
)
21+
})
22+
}),
23+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { EOL } from "os"
2+
import * as Effect from "effect/Effect"
3+
import { Commands } from "../../commands"
4+
import { Runtime } from "../../../framework/runtime"
5+
import { ServerMode } from "../../../services/server-mode"
6+
7+
export default Runtime.handler(
8+
Commands.commands.workspace.commands.use,
9+
Effect.fn("cli.workspace.use")(function* (input) {
10+
const workspace = yield* (yield* ServerMode.Service).useWorkspace(input.id)
11+
process.stdout.write(`Using workspace ${workspace.id}${workspace.name ? ` (${workspace.name})` : ""}.` + EOL)
12+
}),
13+
)

packages/cli/src/framework/runtime.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import * as Effect from "effect/Effect"
22
import * as Command from "effect/unstable/cli/Command"
33
import { Spec } from "./spec"
44
import { Daemon } from "../services/daemon"
5+
import { ServerMode } from "../services/server-mode"
6+
import { Connection } from "../services/connection"
57

68
export type Input<Value> =
79
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -10,11 +12,13 @@ export type Input<Value> =
1012
? Input
1113
: never
1214

13-
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
15+
export type Services = Daemon.Service | ServerMode.Service | Connection.Service
16+
17+
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Services>
1418
type Loader<Node extends Spec.Any> = () => Promise<{
15-
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
19+
default: (input: Input<Node>) => Effect.Effect<void, any, Services>
1620
}>
17-
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
21+
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Services>
1822

1923
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
2024
? Loader<Node>

packages/cli/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@ import * as NodeServices from "@effect/platform-node/NodeServices"
55
import * as Effect from "effect/Effect"
66
import { Commands } from "./commands/commands"
77
import { Runtime } from "./framework/runtime"
8+
import { Connection } from "./services/connection"
89
import { Daemon } from "./services/daemon"
10+
import { ServerMode } from "./services/server-mode"
911

1012
const Handlers = Runtime.handlers(Commands, {
1113
$: () => import("./commands/handlers/default"),
1214
debug: {
1315
agents: () => import("./commands/handlers/debug/agents"),
1416
},
17+
login: () => import("./commands/handlers/login"),
18+
logout: () => import("./commands/handlers/logout"),
19+
workspace: {
20+
list: () => import("./commands/handlers/workspace/list"),
21+
use: () => import("./commands/handlers/workspace/use"),
22+
},
1523
migrate: () => import("./commands/handlers/migrate"),
1624
service: {
1725
start: () => import("./commands/handlers/service/start"),
@@ -24,6 +32,8 @@ const Handlers = Runtime.handlers(Commands, {
2432
})
2533

2634
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
35+
Effect.provide(Connection.defaultLayer),
36+
Effect.provide(ServerMode.defaultLayer),
2737
Effect.provide(Daemon.defaultLayer),
2838
Effect.provide(NodeServices.layer),
2939
Effect.scoped,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Context, Effect, Layer, Option } from "effect"
2+
import { Daemon } from "./daemon"
3+
import { ServerMode } from "./server-mode"
4+
5+
// The local daemon has no custom fetch, so Connection widens the server-mode
6+
// transport shape instead of redefining it.
7+
export interface Transport extends Omit<ServerMode.Transport, "fetch"> {
8+
readonly fetch?: ServerMode.Transport["fetch"]
9+
}
10+
11+
export interface Interface {
12+
readonly transport: () => Effect.Effect<Transport, unknown>
13+
}
14+
15+
export class Service extends Context.Service<Service, Interface>()("@deepagent-code/cli/Connection") {}
16+
17+
// Server mode (DeepAgent Server Edition gateway) takes precedence when active:
18+
// either DEEPAGENT_GATEWAY_URL is pinned or a server-mode login exists. Otherwise
19+
// fall back to the local background daemon.
20+
export const layer = Layer.effect(
21+
Service,
22+
Effect.gen(function* () {
23+
const daemon = yield* Daemon.Service
24+
const serverMode = yield* ServerMode.Service
25+
26+
const transport = Effect.fn("cli.connection.transport")(function* () {
27+
const remote = yield* serverMode.transport()
28+
if (Option.isSome(remote)) return remote.value
29+
return yield* daemon.transport()
30+
})
31+
32+
return Service.of({ transport })
33+
}),
34+
)
35+
36+
export const defaultLayer = layer
37+
38+
export * as Connection from "./connection"

0 commit comments

Comments
 (0)