Skip to content

Commit 1f8eeae

Browse files
committed
feat: harden workspace runtime operations
1 parent 0b05524 commit 1f8eeae

54 files changed

Lines changed: 2888 additions & 137 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/server/src/__tests__/fs/file-io.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Tests for file-io operations.
33
*/
44

5-
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "fs/promises";
5+
import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "fs/promises";
66
import { tmpdir } from "os";
77
import { join, win32 } from "path";
88
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -82,6 +82,19 @@ describe("readFile", () => {
8282
await expect(readWorkspaceFile("ws-1", testDir, "nonexistent.txt")).rejects.toThrow();
8383
});
8484

85+
it("rejects symlinked files that resolve outside the workspace root", async () => {
86+
const outsideDir = await mkdtemp(join(tmpdir(), "fileio-outside-"));
87+
const outsideFile = join(outsideDir, "secret.txt");
88+
await writeFile(outsideFile, "secret");
89+
await symlink(outsideFile, join(testDir, "escape.txt"));
90+
91+
await expect(readWorkspaceFile("ws-1", testDir, "escape.txt")).rejects.toMatchObject({
92+
code: "path_escape",
93+
});
94+
95+
await rm(outsideDir, { recursive: true, force: true });
96+
});
97+
8598
it("should return an image descriptor with a signed-in asset URL for png files", async () => {
8699
// Minimal 1x1 PNG so we exercise the binary branch without depending on
87100
// fixture files; exact bytes don't matter since the endpoint just streams
@@ -169,6 +182,19 @@ describe("writeFile", () => {
169182
// Try to write with outdated baseHash
170183
await expect(writeWorkspaceFile(testDir, "test.txt", "Updated", "wronghash")).rejects.toThrow();
171184
});
185+
186+
it("rejects writing through symlinked files outside the workspace root", async () => {
187+
const outsideDir = await mkdtemp(join(tmpdir(), "fileio-outside-"));
188+
const outsideFile = join(outsideDir, "secret.txt");
189+
await writeFile(outsideFile, "secret");
190+
await symlink(outsideFile, join(testDir, "escape.txt"));
191+
192+
await expect(writeWorkspaceFile(testDir, "escape.txt", "updated")).rejects.toMatchObject({
193+
code: "path_escape",
194+
});
195+
196+
await rm(outsideDir, { recursive: true, force: true });
197+
});
172198
});
173199

174200
describe("createFile", () => {
@@ -283,4 +309,17 @@ describe("deleteEntry", () => {
283309
code: "path_escape",
284310
});
285311
});
312+
313+
it("rejects deleting symlinked files outside the workspace root", async () => {
314+
const outsideDir = await mkdtemp(join(tmpdir(), "fileio-outside-"));
315+
const outsideFile = join(outsideDir, "secret.txt");
316+
await writeFile(outsideFile, "secret");
317+
await symlink(outsideFile, join(testDir, "escape.txt"));
318+
319+
await expect(deleteEntry(testDir, "escape.txt")).rejects.toMatchObject({
320+
code: "path_escape",
321+
});
322+
323+
await rm(outsideDir, { recursive: true, force: true });
324+
});
286325
});

packages/server/src/__tests__/fs/tree.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Tests for file tree builder (lazy loading version).
33
*/
44

5-
import { mkdir, mkdir as mkdirAsync, rmdir, writeFile } from "fs/promises";
5+
import { mkdir, mkdir as mkdirAsync, rmdir, symlink, writeFile } from "fs/promises";
66
import { tmpdir } from "os";
77
import { join } from "path";
88
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -162,4 +162,53 @@ describe("readTree", () => {
162162
isGitIgnored: false,
163163
});
164164
});
165+
166+
it("shows symlinked files and directories using the target kind", async () => {
167+
await mkdirAsync(join(testDir, "real-dir"));
168+
await writeFile(join(testDir, "real-file.txt"), "content");
169+
await symlink(join(testDir, "real-dir"), join(testDir, "linked-dir"), "dir");
170+
await symlink(join(testDir, "real-file.txt"), join(testDir, "linked-file.txt"));
171+
172+
const result = await readTree(testDir);
173+
174+
expect(result.children).toEqual(
175+
expect.arrayContaining([
176+
expect.objectContaining({
177+
name: "linked-dir",
178+
path: "linked-dir",
179+
kind: "dir",
180+
isSymlink: true,
181+
}),
182+
expect.objectContaining({
183+
name: "linked-file.txt",
184+
path: "linked-file.txt",
185+
kind: "file",
186+
isSymlink: true,
187+
}),
188+
])
189+
);
190+
});
191+
192+
it("lists external symlink directories but rejects expanding them", async () => {
193+
const outsideDir = join(tmpdir(), `tree-test-outside-${Date.now()}`);
194+
await mkdirAsync(outsideDir);
195+
await symlink(outsideDir, join(testDir, "external-dir"), "dir");
196+
197+
const rootResult = await readTree(testDir);
198+
expect(rootResult.children).toEqual(
199+
expect.arrayContaining([
200+
expect.objectContaining({
201+
name: "external-dir",
202+
kind: "dir",
203+
isSymlink: true,
204+
}),
205+
])
206+
);
207+
208+
await expect(readTree(testDir, "external-dir")).rejects.toMatchObject({
209+
code: "path_escape",
210+
});
211+
212+
await rmdir(outsideDir);
213+
});
165214
});

packages/server/src/__tests__/fs/watcher.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ describe("WorkspaceWatcher", () => {
9393
expect(ignored?.(join(testDir, "src/index.ts"))).toBe(false);
9494
});
9595

96+
it("ignores managed target files used for agent instruction publishing", () => {
97+
new WorkspaceWatcher("test-workspace-id", testDir, broadcaster);
98+
99+
const options = watchSpy.mock.calls[0]?.[1];
100+
const ignored = options?.ignored;
101+
102+
expect(typeof ignored).toBe("function");
103+
expect(ignored?.(join(testDir, "AGENTS.md"))).toBe(true);
104+
expect(ignored?.(join(testDir, "GEMINI.md"))).toBe(true);
105+
expect(ignored?.(join(testDir, ".claude", "CLAUDE.md"))).toBe(true);
106+
expect(ignored?.(join(testDir, ".coder-studio", "agent.md"))).toBe(false);
107+
});
108+
96109
it("ignores transient git lock files and write-heavy git internals", () => {
97110
new WorkspaceWatcher("test-workspace-id", testDir, broadcaster);
98111

@@ -241,6 +254,20 @@ describe("WorkspaceWatcher", () => {
241254
);
242255
});
243256

257+
it("invokes the dirty callback after a single file event settles", async () => {
258+
vi.useFakeTimers();
259+
const onDirty = vi.fn();
260+
261+
new WorkspaceWatcher("test-workspace-id", testDir, broadcaster, undefined, onDirty);
262+
263+
watcherEvents.all?.("change", join(testDir, ".coder-studio", "agent.md"));
264+
await vi.advanceTimersByTimeAsync(199);
265+
expect(onDirty).not.toHaveBeenCalled();
266+
267+
await vi.advanceTimersByTimeAsync(1);
268+
expect(onDirty).toHaveBeenCalledWith("test-workspace-id", "fs_change");
269+
});
270+
244271
it("broadcasts fs.dirty after consecutive file events settle", async () => {
245272
vi.useFakeTimers();
246273
new WorkspaceWatcher("test-workspace-id", testDir, broadcaster);

packages/server/src/__tests__/git/status-parser.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("parseStatus", () => {
1616
expect(status.modified).toHaveLength(0);
1717
expect(status.untracked).toHaveLength(0);
1818
expect(status.deleted).toHaveLength(0);
19+
expect(status.conflicted).toHaveLength(0);
1920
});
2021

2122
it("should parse branch name", () => {
@@ -144,6 +145,21 @@ describe("parseStatus", () => {
144145
]);
145146
});
146147

148+
it("parses unmerged conflict entries as merge changes", () => {
149+
const porcelain = `# branch.head main
150+
u UU N... 100644 100644 100644 100644 base123 ours123 theirs123 conflicted.ts`;
151+
const status = parseStatus(porcelain);
152+
153+
expect(status.conflicted).toEqual([
154+
{
155+
path: "conflicted.ts",
156+
status: "conflicted",
157+
},
158+
]);
159+
expect(status.modified).toHaveLength(0);
160+
expect(status.staged).toHaveLength(0);
161+
});
162+
147163
it("should handle complex status", () => {
148164
const porcelain = `# branch.oid abc123
149165
# branch.head main

packages/server/src/__tests__/lsp-commands.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
77
import { EventBus } from "../bus/event-bus.js";
8+
import { SettingsRepo } from "../storage/repositories/settings-repo.js";
89
import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js";
910
import { WorkspaceManager } from "../workspace/manager.js";
1011
import type { CommandContext } from "../ws/dispatch.js";
@@ -118,6 +119,9 @@ describe("LSP commands", () => {
118119
beforeEach(() => {
119120
stateDir = mkdtempSync(join(tmpdir(), "lsp-command-state-"));
120121
const eventBus = new EventBus();
122+
const settingsRepo = new SettingsRepo({
123+
filePath: join(stateDir, "settings.json"),
124+
});
121125
const workspaceMgr = new WorkspaceManager({
122126
workspaceRepo: new WorkspaceRepo({
123127
filePath: join(stateDir, "workspaces.json"),
@@ -131,6 +135,7 @@ describe("LSP commands", () => {
131135
sessionMgr: {} as never,
132136
terminalMgr: {} as never,
133137
broadcaster: { broadcast: vi.fn(), sendToClient: vi.fn(), sendBinaryToClient: vi.fn() },
138+
settingsRepo,
134139
providerRegistry: [],
135140
autoFetch: {} as never,
136141
fencingMgr: {} as never,

packages/server/src/__tests__/session-commands.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { SessionManager } from "../session/manager.js";
1010
import type { SessionDatabase } from "../session/types.js";
1111
import { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js";
1212
import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js";
13+
import { SettingsRepo } from "../storage/repositories/settings-repo.js";
1314
import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js";
1415
import type { TerminalManager } from "../terminal/manager.js";
1516
import { WorkspaceManager } from "../workspace/manager.js";
@@ -40,6 +41,9 @@ describe("Session Commands", () => {
4041
eventBus = new EventBus();
4142
stateDir = mkdtempSync(join(tmpdir(), "session-command-state-"));
4243
const providerConfigRepo = createProviderConfigRepo(join(stateDir, "provider-configs.json"));
44+
const settingsRepo = new SettingsRepo({
45+
filePath: join(stateDir, "settings.json"),
46+
});
4347
workspaceRepo = new WorkspaceRepo({
4448
filePath: join(stateDir, "workspaces.json"),
4549
});
@@ -79,6 +83,7 @@ describe("Session Commands", () => {
7983
terminalMgr: {} as never,
8084
eventBus,
8185
broadcaster,
86+
settingsRepo,
8287
providerRegistry: [],
8388
fencingMgr: {} as never,
8489
supervisorMgr: {} as never,
@@ -163,6 +168,73 @@ describe("Session Commands", () => {
163168
}
164169
});
165170

171+
it("publishes agent instructions before session.create starts the agent", async () => {
172+
const testDir = join(tmpdir(), `coder-studio-session-publish-${Date.now()}`);
173+
mkdirSync(join(testDir, ".git"), { recursive: true });
174+
writeFileSync(join(testDir, ".git", "HEAD"), "ref: refs/heads/main\n");
175+
176+
const calls: string[] = [];
177+
ctx.providerRegistry = providerRegistry as ProviderDefinition[];
178+
ctx.providerRuntimeDeps = {
179+
commandExists: async (command: string) => command === "claude",
180+
};
181+
ctx.agentInstructionPublisher = {
182+
syncWorkspace: vi.fn(async () => {
183+
calls.push("publish");
184+
}),
185+
scheduleWorkspaceSync: vi.fn(),
186+
syncAllOpenWorkspaces: vi.fn(),
187+
} as never;
188+
189+
const createSpy = vi.spyOn(sessionMgr, "create").mockImplementation(async () => {
190+
calls.push("create");
191+
return {
192+
id: "sess-1",
193+
workspaceId: "ws-1",
194+
providerId: "claude",
195+
terminalId: "term-1",
196+
capability: "full",
197+
state: "starting",
198+
startedAt: Date.now(),
199+
lastActiveAt: Date.now(),
200+
};
201+
});
202+
203+
try {
204+
const openResult = await dispatch(
205+
{
206+
kind: "command",
207+
id: "workspace-publish-order",
208+
op: "workspace.open",
209+
args: { path: testDir },
210+
},
211+
ctx
212+
);
213+
214+
expect(openResult.ok).toBe(true);
215+
calls.length = 0;
216+
217+
const result = await dispatch(
218+
{
219+
kind: "command",
220+
id: "session-publish-order",
221+
op: "session.create",
222+
args: {
223+
workspaceId: openResult.data!.id,
224+
providerId: "claude",
225+
},
226+
},
227+
ctx
228+
);
229+
230+
expect(result.ok).toBe(true);
231+
expect(calls).toEqual(["publish", "create"]);
232+
} finally {
233+
createSpy.mockRestore();
234+
rmSync(testDir, { recursive: true, force: true });
235+
}
236+
});
237+
166238
it("launches a custom provider through the existing session.create flow", async () => {
167239
const testDir = join(tmpdir(), `coder-studio-custom-provider-session-${Date.now()}`);
168240
mkdirSync(join(testDir, ".git"), { recursive: true });
@@ -235,6 +307,61 @@ describe("Session Commands", () => {
235307
}
236308
});
237309

310+
it.each([
311+
{ providerId: "gemini", command: "gemini", expectedCapability: "full" },
312+
{ providerId: "cursor", command: "agent", expectedCapability: "full" },
313+
{ providerId: "opencode", command: "opencode", expectedCapability: "limited" },
314+
])("launches $providerId through the shared session.create flow", async ({
315+
providerId,
316+
command,
317+
expectedCapability,
318+
}) => {
319+
const testDir = join(tmpdir(), `coder-studio-${providerId}-session-${Date.now()}`);
320+
mkdirSync(join(testDir, ".git"), { recursive: true });
321+
writeFileSync(join(testDir, ".git", "HEAD"), "ref: refs/heads/main\n");
322+
323+
ctx.providerRegistry = providerRegistry as ProviderDefinition[];
324+
ctx.providerRuntimeDeps = {
325+
commandExists: async (candidate: string) => candidate === command,
326+
};
327+
328+
try {
329+
const openResult = await dispatch(
330+
{
331+
kind: "command",
332+
id: `workspace-${providerId}`,
333+
op: "workspace.open",
334+
args: { path: testDir },
335+
},
336+
ctx
337+
);
338+
339+
expect(openResult.ok).toBe(true);
340+
341+
const result = await dispatch(
342+
{
343+
kind: "command",
344+
id: `session-${providerId}`,
345+
op: "session.create",
346+
args: {
347+
workspaceId: openResult.data!.id,
348+
providerId,
349+
},
350+
},
351+
ctx
352+
);
353+
354+
expect(result.ok).toBe(true);
355+
expect(result.data).toMatchObject({
356+
providerId,
357+
capability: expectedCapability,
358+
state: "starting",
359+
});
360+
} finally {
361+
rmSync(testDir, { recursive: true, force: true });
362+
}
363+
});
364+
238365
it("captures session objective and git baseline metadata when available", async () => {
239366
const testDir = join(tmpdir(), `coder-studio-session-metadata-${Date.now()}`);
240367
mkdirSync(testDir, { recursive: true });

0 commit comments

Comments
 (0)