Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"tsyringe": "^4.10.0",
"unleash-client": "^6.6.0",
"uuid": "^9.0.1",
"ws": "^7.5.10",
"zod": "3.*"
},
"devDependencies": {
Expand All @@ -127,6 +128,7 @@
"@types/semver": "^7.5.2",
"@types/tar": "^6.1.13",
"@types/uuid": "^8.3.1",
"@types/ws": "^8.5.4",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@vitest/coverage-v8": "^4.0.18",
"drizzle-kit": "^0.22.7",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { Err, Ok } from "ts-results";
import { container } from "tsyringe";
import { describe, expect, it } from "vitest";
import { mock } from "vitest-mock-extended";

import { AuthService } from "@src/auth/services/auth.service";
import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { DeploymentReaderService } from "@src/deployment/services/deployment-reader/deployment-reader.service";
import type { ShellExecService } from "@src/deployment/services/shell-exec/shell-exec.service";
import type { ProviderService } from "@src/provider/services/provider/provider.service";
import { ShellExecController } from "./shell-exec.controller";

import { createUser } from "@test/seeders/user.seeder";

describe(ShellExecController.name, () => {
it("throws 404 when deployment not found", async () => {
const { controller, deploymentReaderService } = setup();
deploymentReaderService.findByUserIdAndDseq.mockResolvedValue(undefined as never);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Avoid as never/as any casts in mocks — breaks the type safety contract.

The mockResolvedValue(... as never) pattern hides real shape mismatches. Prefer typed factory helpers (e.g., a createDeployment() seeder returning the correct GetDeploymentResponse["data"] shape) so a future change in DeploymentReaderService.findByUserIdAndDseq return type fails the test loudly. Same for walletReaderService.getWalletByUserId and providerService.getProvider.

As per coding guidelines: "Never use type any or cast to type any. Always define the proper TypeScript types."

Also applies to: 94-94, 175-179


const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(404);
expect(error.message).toBe("Deployment not found");
});

it("throws 404 when no lease matches the provided gseq and oseq", async () => {
const { controller } = setup();

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 99, oseq: 99, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(404);
expect(error.message).toBe("Lease not found");
});

it("throws 500 when lease provider address is an empty string", async () => {
const { controller } = setup({ provider: "" });

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(500);
expect(error.message).toBe("Lease provider address not found");
});

it("throws 400 when lease state is not active", async () => {
const { controller } = setup({ state: "closed" });

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(400);
expect(error.message).toBe("Lease is not active");
});

it("throws 502 when shell exec service returns an error result", async () => {
const { controller, shellExecService } = setup();
shellExecService.execute.mockResolvedValue(Err("Command timed out"));

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(502);
expect(error.message).toBe("Command execution timed out");
});

it("returns the shell exec result on successful execution", async () => {
const { controller, shellExecService } = setup();

const result = await controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 });

expect(result).toEqual({ stdout: "output", stderr: "", exitCode: 0, truncated: false });
expect(shellExecService.execute).toHaveBeenCalledWith({
providerBaseUrl: "https://provider.example.com",
providerAddress: "akash1provider",
dseq: "1234",
gseq: 1,
oseq: 1,
service: "web",
command: "ls",
timeout: 60,
jwtToken: "test-token"
});
});

it("throws 404 when provider info lookup returns null", async () => {
const { controller, providerService } = setup();
providerService.getProvider.mockResolvedValue(null as never);

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(404);
expect(error.message).toBe("Provider not found");
});

it("throws 404 when deployment has an empty leases array", async () => {
const { controller, deploymentReaderService, deployment } = setup();
deploymentReaderService.findByUserIdAndDseq.mockResolvedValue({ ...deployment, leases: [] } as never);

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(404);
expect(error.message).toBe("Lease not found");
});

it("finds the correct lease among multiple leases by gseq and oseq", async () => {
const { controller, deploymentReaderService, deployment, shellExecService } = setup();

const multiLeaseDeployment = {
...deployment,
leases: [
{ ...deployment.leases[0], id: { ...deployment.leases[0].id, gseq: 1, oseq: 1 }, state: "active" },
{ ...deployment.leases[0], id: { ...deployment.leases[0].id, gseq: 2, oseq: 1, provider: "akash1provider2" }, state: "active" },
{ ...deployment.leases[0], id: { ...deployment.leases[0].id, gseq: 1, oseq: 2, provider: "akash1provider3" }, state: "active" }
]
};
deploymentReaderService.findByUserIdAndDseq.mockResolvedValue(multiLeaseDeployment as never);

const result = await controller.exec({ dseq: "1234", gseq: 2, oseq: 1, command: "ls", service: "web", timeout: 60 });

expect(result).toEqual({ stdout: "output", stderr: "", exitCode: 0, truncated: false });
expect(shellExecService.execute).toHaveBeenCalledWith(expect.objectContaining({ providerAddress: "akash1provider2" }));
});

it("throws 400 when deployment state is closed", async () => {
const { controller, deploymentReaderService, deployment } = setup();
deploymentReaderService.findByUserIdAndDseq.mockResolvedValue({ ...deployment, deployment: { ...deployment.deployment, state: "closed" } } as never);

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(400);
expect(error.message).toBe("Deployment is not active");
});

it("throws 502 with stable message when WS connection fails", async () => {
const { controller, shellExecService } = setup();
shellExecService.execute.mockResolvedValue(Err("WebSocket connection failed: ECONNREFUSED"));

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(502);
expect(error.message).toBe("Failed to connect to provider");
});

it("throws 502 with stable message when provider returns error", async () => {
const { controller, shellExecService } = setup();
shellExecService.execute.mockResolvedValue(Err("Provider error: internal error"));

const error = await captureError(() => controller.exec({ dseq: "1234", gseq: 1, oseq: 1, command: "ls", service: "web", timeout: 60 }));

expect(error.status).toBe(502);
expect(error.message).toBe("Provider returned an error");
});

async function captureError(fn: () => Promise<any>): Promise<any> {
try {
await fn();
throw new Error("Expected function to throw");
} catch (error) {
return error;
}
}

function setup(overrides?: { provider?: string; state?: string }) {
const user = createUser();
const deploymentReaderService = mock<DeploymentReaderService>();
const providerService = mock<ProviderService>();
const shellExecService = mock<ShellExecService>();
const authService = mock<AuthService>({ currentUser: user });
const walletReaderService = mock<WalletReaderService>();

container.register(AuthService, { useValue: authService });

const controller = new ShellExecController(deploymentReaderService, providerService, shellExecService, authService, walletReaderService);

const provider = overrides?.provider ?? "akash1provider";
const state = overrides?.state ?? "active";

const deployment = {
deployment: {
id: { owner: "akash1owner", dseq: "1234" },
state: "active",
hash: "abc123",
created_at: "12345"
},
leases: [
{
id: { owner: "akash1owner", dseq: "1234", gseq: 1, oseq: 1, provider, bseq: 0 },
state,
price: { denom: "uakt", amount: "100" },
created_at: "12345",
closed_on: "0",
status: null
}
],
escrow_account: {
id: { scope: "deployment", xid: "1234" },
state: {
owner: "akash1owner",
state: "open",
transferred: [],
settled_at: "12345",
funds: [{ denom: "uakt", amount: "1000" }],
deposits: []
}
}
};

deploymentReaderService.findByUserIdAndDseq.mockResolvedValue(deployment as never);
walletReaderService.getWalletByUserId.mockResolvedValue({ id: 1, address: "akash1wallet" } as never);
providerService.toProviderAuth.mockResolvedValue({ type: "jwt" as const, token: "test-token" });
providerService.getProvider.mockResolvedValue({ hostUri: "https://provider.example.com" } as never);
shellExecService.execute.mockResolvedValue(new Ok({ stdout: "output", stderr: "", exitCode: 0, truncated: false }));

return { controller, deploymentReaderService, providerService, shellExecService, authService, walletReaderService, user, deployment };
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "http-assert";
import { singleton } from "tsyringe";

import { AuthService, Protected } from "@src/auth/services/auth.service";
import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import { ShellExecRequest, ShellExecResponse } from "@src/deployment/http-schemas/shell-exec.schema";
import { DeploymentReaderService } from "@src/deployment/services/deployment-reader/deployment-reader.service";
import { ShellExecService } from "@src/deployment/services/shell-exec/shell-exec.service";
import { ProviderService } from "@src/provider/services/provider/provider.service";

@singleton()
export class ShellExecController {
constructor(
private readonly deploymentReaderService: DeploymentReaderService,
private readonly providerService: ProviderService,
private readonly shellExecService: ShellExecService,
private readonly authService: AuthService,
private readonly walletReaderService: WalletReaderService
) {}

@Protected([{ action: "read", subject: "Lease" }])
async exec(input: ShellExecRequest & { dseq: string; gseq: number; oseq: number }): Promise<ShellExecResponse> {
const userId = this.authService.currentUser.id;

const deployment = await this.deploymentReaderService.findByUserIdAndDseq(userId, input.dseq);

assert(deployment, 404, "Deployment not found");
assert(deployment.deployment.state === "active", 400, "Deployment is not active");

const lease = deployment.leases.find(l => l.id.gseq === input.gseq && l.id.oseq === input.oseq);

assert(lease, 404, "Lease not found");
assert(lease.id.provider, 500, "Lease provider address not found");
assert(lease.state === "active", 400, "Lease is not active");

const providerAddress = lease.id.provider;

const wallet = await this.walletReaderService.getWalletByUserId(userId);

const auth = await this.providerService.toProviderAuth({ walletId: wallet.id, provider: providerAddress }, ["shell"]);

const providerInfo = await this.providerService.getProvider(providerAddress);

assert(providerInfo, 404, "Provider not found");
Comment on lines +30 to +44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing deployment-state check + wasted JWT minting before provider lookup.

  1. You only validate the lease state === "active". A deployment can be closed while its lease row is stale; also check deployment.state (matching how the issue restricts to live deployments). At minimum reject if deployment.state === "closed".
  2. toProviderAuth(...) signs a JWT before confirming the provider actually exists. If providerInfo ends up null, you've done unnecessary crypto work and potentially handed a shell-scoped JWT to a caller targeting a non-existent provider. Reorder: getProvider and assert(providerInfo, ...) first, then toProviderAuth.
Proposed diff
-    assert(lease, 404, "Lease not found");
-    assert(lease.id.provider, 500, "Lease provider address not found");
-    assert(lease.state === "active", 400, "Lease is not active");
-
-    const providerAddress = lease.id.provider;
-
-    const wallet = await this.walletReaderService.getWalletByUserId(userId);
-
-    const auth = await this.providerService.toProviderAuth({ walletId: wallet.id, provider: providerAddress }, ["shell"]);
-
-    const providerInfo = await this.providerService.getProvider(providerAddress);
-
-    assert(providerInfo, 404, "Provider not found");
+    assert(lease, 404, "Lease not found");
+    assert(lease.id.provider, 500, "Lease provider address not found");
+    assert(lease.state === "active", 400, "Lease is not active");
+
+    const providerAddress = lease.id.provider;
+    const providerInfo = await this.providerService.getProvider(providerAddress);
+    assert(providerInfo, 404, "Provider not found");
+
+    const wallet = await this.walletReaderService.getWalletByUserId(userId);
+    const auth = await this.providerService.toProviderAuth({ walletId: wallet.id, provider: providerAddress }, ["shell"]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/deployment/controllers/shell-exec/shell-exec.controller.ts`
around lines 29 - 43, The code only checks lease.state but not deployment.state
and calls toProviderAuth before confirming the provider exists; update the
handler to first assert deployment.state is not "closed" (e.g., check
deployment.state !== "closed" similar to lease.state check) and reorder logic so
you call providerService.getProvider(providerAddress) and assert(providerInfo,
404, "Provider not found") before calling providerService.toProviderAuth(...).
This prevents minting a JWT for a non-existent provider and ensures closed
deployments are rejected.


const result = await this.shellExecService.execute({
providerBaseUrl: providerInfo.hostUri,
providerAddress: providerAddress,
dseq: input.dseq,
gseq: input.gseq,
oseq: input.oseq,
service: input.service,
command: input.command,
timeout: input.timeout,
jwtToken: auth.token
});

if (!result.ok) {
const message = result.val.startsWith("Command timed out")
? "Command execution timed out"
: result.val.startsWith("WebSocket connection failed")
? "Failed to connect to provider"
: result.val.startsWith("Provider error")
? "Provider returned an error"
: "Shell execution failed";
assert(false, 502, message);
}
Comment on lines +58 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

assert(false, 502, result.val) can leak internal error strings to clients.

result.val is whatever string the service returns — today it's benign ("Command timed out", "Connection closed without exit code"), but any future change that stuffs a provider-proxy stacktrace, hostname, or Node error message into that string gets piped straight into the 502 response body. Map known failure modes to stable, user-facing messages and log the raw reason via LoggerService instead.

Proposed diff
-    if (!result.ok) {
-      assert(false, 502, result.val);
-    }
+    if (!result.ok) {
+      this.logger.warn({ event: "shell-exec-failed", reason: result.val, providerAddress, dseq: input.dseq });
+      const publicMessage = result.val === "Command timed out" ? "Command timed out" : "Shell execution failed";
+      assert(false, 502, publicMessage);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!result.ok) {
assert(false, 502, result.val);
}
if (!result.ok) {
this.logger.warn({ event: "shell-exec-failed", reason: result.val, providerAddress, dseq: input.dseq });
const publicMessage = result.val === "Command timed out" ? "Command timed out" : "Shell execution failed";
assert(false, 502, publicMessage);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/deployment/controllers/shell-exec/shell-exec.controller.ts`
around lines 57 - 59, Replace the direct assert(false, 502, result.val) call in
the shell-exec controller with a safe mapping: inspect the result (the variable
named result returned from the shell exec service), convert known failure
strings into stable user-facing messages (e.g., "Command timed out", "Connection
lost", "Execution failed") and use assert or throw with that sanitized message
and 502, while sending the raw result.val to the LoggerService (or this.logger)
at error level; ensure any unknown/unmapped result.val is logged but replaced
with a generic message like "Remote execution error" before returning to the
client.


return result.val;
}
}
24 changes: 24 additions & 0 deletions apps/api/src/deployment/http-schemas/shell-exec.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from "zod";

export const ShellExecParamsSchema = z.object({
dseq: z.string(),
gseq: z.coerce.number().int().nonnegative(),
oseq: z.coerce.number().int().nonnegative()
});

export const ShellExecRequestSchema = z.object({
command: z.string().min(1).max(4096),
service: z.string().min(1).max(253),
timeout: z.number().int().min(1).max(120).default(60)
});
Comment on lines +3 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In @hono/zod-openapi 0.18.x, are URL path parameters validated as strings by default, or does z.number() auto-coerce for path params?

💡 Result:

In @hono/zod-openapi 0.18.x, URL path parameters are validated as strings by default. Hono extracts path parameters as strings (automatically URL-decoded), and the official documentation and examples explicitly use z.string for path params like {id}. z.number does not auto-coerce for path params. Multiple GitHub issues confirm that using z.number directly on path params causes type inference issues or validation failures because the input is a string. Developers must use explicit coercion like z.coerce.number, z.string.pipe(z.coerce.number), z.preprocess(...), or similar transforms to convert the string input to a number. This behavior holds across versions, including 0.18.x, as path param extraction in Hono is consistently string-based.

Citations:


🏁 Script executed:

# Find where ShellExecParamsSchema is used
fd -t f "\.ts$" apps/api | xargs rg "ShellExecParamsSchema" -l

Repository: akash-network/console

Length of output: 185


🏁 Script executed:

# Search for route definitions using ShellExecParamsSchema
rg "ShellExecParamsSchema" -A 5 -B 5

Repository: akash-network/console

Length of output: 3782


🏁 Script executed:

# Find shell-exec related service code and buildShellUrl
rg "buildShellUrl|ShellExec" -t ts --max-count 20

Repository: akash-network/console

Length of output: 6628


🏁 Script executed:

# Check test files for shell-exec
fd -t f -i "shell.*exec.*test\|test.*shell.*exec" apps/

Repository: akash-network/console

Length of output: 47


🏁 Script executed:

# Look for schema patterns in the codebase for similar validation
rg "z\.string\(\)\.regex" apps/api/src --max-count 10

Repository: akash-network/console

Length of output: 399


🏁 Script executed:

# Read the shell-exec service test file to find references to dseq: "foo/bar/baz"
cat -n apps/api/src/deployment/services/shell-exec/shell-exec.service.spec.ts | head -150

Repository: akash-network/console

Length of output: 6273


🏁 Script executed:

# Read the buildShellUrl implementation in the service
cat -n apps/api/src/deployment/services/shell-exec/shell-exec.service.ts

Repository: akash-network/console

Length of output: 8112


🏁 Script executed:

# Check how dseq, gseq, oseq are used in the controller
cat -n apps/api/src/deployment/controllers/shell-exec/shell-exec.controller.ts

Repository: akash-network/console

Length of output: 2984


🏁 Script executed:

# Check if there's a test with dseq: "foo/bar/baz" (continuation of spec file)
tail -100 apps/api/src/deployment/services/shell-exec/shell-exec.service.spec.ts

Repository: akash-network/console

Length of output: 3490


🏁 Script executed:

# Look for the createShellExecInput helper function
rg "createShellExecInput" -A 10 apps/api/src/deployment/services/shell-exec/shell-exec.service.spec.ts

Repository: akash-network/console

Length of output: 23237


🏁 Script executed:

# Verify how the router passes validated params to controller
cat -n apps/api/src/deployment/routes/shell-exec/shell-exec.router.ts | grep -A 3 "routeShellExec"

Repository: akash-network/console

Length of output: 334


Path parameters must use z.coerce.number() and numeric fields need additional constraints.

  1. gseq and oseq are URL path parameters but defined as z.number()@hono/zod-openapi does not auto-coerce path params from strings. Every request will fail validation. Use z.coerce.number().int().nonnegative() instead.

  2. dseq has no validation; test evidence shows dseq: "foo/bar/baz" reaches buildShellUrl. Add z.string().regex(/^\d+$/) to reject malformed values at the boundary.

  3. gseq and oseq should be .nonnegative() — test cases explicitly document that negative values (e.g., -1, -5) pass through with note "(no validation)".

  4. command lacks a .max() limit — an API token holder can POST multi-MB payloads. Add .max(4096) or a realistic ceiling aligned with the 1 MB response cap.

  5. timeout should be .int() — fractional seconds (e.g., 60.5) are currently allowed but meaningless.

  6. service should be stricter to match typical k8s service naming (alphanumeric, hyphens, underscores, no spaces/slashes). Consider .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,251}[a-zA-Z0-9]?$/) or at minimum reject spaces.

Proposed diff
 export const ShellExecParamsSchema = z.object({
-  dseq: z.string(),
-  gseq: z.number(),
-  oseq: z.number()
+  dseq: z.string().regex(/^\d+$/, "dseq must be numeric"),
+  gseq: z.coerce.number().int().nonnegative(),
+  oseq: z.coerce.number().int().nonnegative()
 });

 export const ShellExecRequestSchema = z.object({
-  command: z.string().min(1),
+  command: z.string().min(1).max(4096),
-  service: z.string().min(1),
+  service: z.string().min(1).max(253),
-  timeout: z.number().min(1).max(120).default(60)
+  timeout: z.number().int().min(1).max(120).default(60)
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const ShellExecParamsSchema = z.object({
dseq: z.string(),
gseq: z.number(),
oseq: z.number()
});
export const ShellExecRequestSchema = z.object({
command: z.string().min(1),
service: z.string().min(1),
timeout: z.number().min(1).max(120).default(60)
});
export const ShellExecParamsSchema = z.object({
dseq: z.string().regex(/^\d+$/, "dseq must be numeric"),
gseq: z.coerce.number().int().nonnegative(),
oseq: z.coerce.number().int().nonnegative()
});
export const ShellExecRequestSchema = z.object({
command: z.string().min(1).max(4096),
service: z.string().min(1).max(253),
timeout: z.number().int().min(1).max(120).default(60)
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/deployment/http-schemas/shell-exec.schema.ts` around lines 3 -
13, Update ShellExecParamsSchema and ShellExecRequestSchema: change gseq and
oseq to z.coerce.number().int().nonnegative() so path params coerce from strings
and reject negatives; add dseq: z.string().regex(/^\d+$/) to only allow digit
sequences; tighten command to include .max(4096); make timeout an integer by
adding .int() (keep .min(1).max(120).default(60)); and tighten service with a
stricter regex (e.g., /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,251}[a-zA-Z0-9]?$/ or at
minimum reject spaces) on ShellExecRequestSchema to prevent invalid service
names.


export const ShellExecResponseSchema = z.object({
stdout: z.string(),
stderr: z.string(),
exitCode: z.number(),
truncated: z.boolean()
});

export type ShellExecParams = z.infer<typeof ShellExecParamsSchema>;
export type ShellExecRequest = z.infer<typeof ShellExecRequestSchema>;
export type ShellExecResponse = z.infer<typeof ShellExecResponseSchema>;
59 changes: 59 additions & 0 deletions apps/api/src/deployment/routes/shell-exec/shell-exec.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { container } from "tsyringe";

import { createRoute } from "@src/core/lib/create-route/create-route";
import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler";
import { SECURITY_BEARER_OR_API_KEY } from "@src/core/services/openapi-docs/openapi-security";
import { ShellExecController } from "@src/deployment/controllers/shell-exec/shell-exec.controller";
import { ShellExecParamsSchema, ShellExecRequestSchema, ShellExecResponseSchema } from "@src/deployment/http-schemas/shell-exec.schema";

export const shellExecRouter = new OpenApiHonoHandler();

const shellExecRoute = createRoute({
method: "post",
path: "/v1/deployments/{dseq}/leases/{gseq}/{oseq}/shell-exec",
summary: "Execute a shell command in a deployment container",
tags: ["Shell Exec"],
security: SECURITY_BEARER_OR_API_KEY,
request: {
params: ShellExecParamsSchema,
body: {
content: {
"application/json": {
schema: ShellExecRequestSchema
}
}
}
},
responses: {
200: {
description: "Command executed successfully",
content: {
"application/json": {
schema: ShellExecResponseSchema
}
}
},
400: {
description: "Invalid request (e.g., lease not active)"
},
401: {
description: "Unauthorized"
},
403: {
description: "Forbidden - user does not own this deployment"
},
404: {
description: "Deployment or lease not found"
},
502: {
description: "Provider proxy error"
}
}
});

shellExecRouter.openapi(shellExecRoute, async function routeShellExec(c) {
const params = c.req.valid("param");
const body = c.req.valid("json");
const result = await container.resolve(ShellExecController).exec({ ...params, ...body });
return c.json(result, 200);
});
Loading