Skip to content
Draft
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
7 changes: 7 additions & 0 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ When a workspace opens, DevSpace loads root-level instruction files:
Nested instruction files are returned as `availableAgentsFiles`. The model
should read the relevant nested file before working under that directory.

Set `DEVSPACE_CONTEXT_IGNORE_PATHS` to a comma-separated list of literal,
workspace-relative directory paths that should be excluded from nested
instruction discovery. For example, `external-resources,vendor/generated`
prunes those subtrees before DevSpace scans for project instructions. The
default is empty, and normal workspace file tools can still access ignored
content.

This keeps instructions explicit and inspectable instead of silently injecting
new context during later tool calls.

Expand Down
31 changes: 31 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com
| `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. |
| `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. |
| `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. |
| `DEVSPACE_CONTEXT_IGNORE_PATHS` | Optional comma-separated workspace-relative directories to exclude from nested instruction discovery. |

## Instruction Discovery

`open_workspace` discovers nested `AGENTS.md` and `CLAUDE.md` files. Use
`DEVSPACE_CONTEXT_IGNORE_PATHS` to prune mounted, external, or unusually large
resource trees from that discovery walk. The default is empty, so DevSpace does
not exclude any additional directories unless you configure them:

```bash
DEVSPACE_CONTEXT_IGNORE_PATHS="external-resources,vendor/generated" \
npx @waishnav/devspace serve
```

Entries are literal workspace-relative directory paths, not globs. Absolute
paths and parent traversal are rejected. Matching is exact: ignoring
`external-resources` does not ignore `external-resources-old` or
`nested/external-resources`. Because the environment variable is
comma-separated, use the JSON form for a directory name containing a comma. The
equivalent persisted `config.json` field is an array:

```json
{
"contextIgnorePaths": ["external-resources", "vendor/generated"]
}
```

Ignored subtrees remain available to normal workspace file and shell tools;
only nested instruction discovery skips them. This setting does not enforce
read-only access and is not a filesystem security boundary.

## OAuth

Expand Down Expand Up @@ -158,6 +188,7 @@ DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" \
DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \
DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \
DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \
DEVSPACE_CONTEXT_IGNORE_PATHS="external-resources,vendor/generated" \
DEVSPACE_TOOL_MODE="minimal" \
DEVSPACE_WIDGETS="full" \
npx @waishnav/devspace serve
Expand Down
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
port,
allowedRoots,
publicBaseUrl,
contextIgnorePaths: files.config.contextIgnorePaths,
subagents: resolveSubagentsFlag(files.config),
};
const auth = {
Expand Down Expand Up @@ -218,6 +219,7 @@ async function serve(): Promise<void> {
console.log(`public base url: ${config.publicBaseUrl}`);
console.log(`allowed roots: ${config.allowedRoots.join(", ")}`);
console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`);
console.log(`context ignore paths: ${config.contextIgnorePaths.join(", ") || "(none)"}`);
if (config.allowedHosts.includes("*")) {
console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*");
}
Expand Down Expand Up @@ -256,6 +258,7 @@ async function runDoctor(): Promise<void> {
console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`);
console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`);
console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`);
console.log(`Context ignore paths: ${config.contextIgnorePaths.join(", ") || "(none)"}`);
} catch (error) {
console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`);
}
Expand Down
61 changes: 61 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true);
assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills"));
assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents"));
assert.equal(loadConfig(baseEnv).subagents, false);
assert.deepEqual(loadConfig(baseEnv).contextIgnorePaths, []);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true);
assert.equal(
Expand All @@ -36,6 +37,14 @@ assert.equal(resolveSubagentsFlag({}, {}), undefined);
assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true);
assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false);
assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true);
assert.deepEqual(
loadConfig({
...baseEnv,
DEVSPACE_CONTEXT_IGNORE_PATHS:
"external-resources, vendor/generated, ./cache/data/,external-resources",
}).contextIgnorePaths,
["external-resources", "vendor/generated", "cache/data"],
);

const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-"));
const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir });
Expand All @@ -60,6 +69,33 @@ assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }),
/Invalid DEVSPACE_TOOL_MODE: invalid/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "../outside" }),
/Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "/absolute" }),
/Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "C:\\outside" }),
/Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "." }),
/Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/,
);
assert.throws(
() => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "ignored\0path" }),
/Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/,
);
assert.throws(
() => loadConfig({
...baseEnv,
DEVSPACE_CONTEXT_IGNORE_PATHS: ["external-resources", 42] as unknown as string,
}),
/must be a comma-separated string or a config array of strings/,
);

assert.deepEqual(loadConfig(baseEnv).logging, {
level: "info",
Expand Down Expand Up @@ -162,6 +198,12 @@ writeFileSync(
port: 8787,
allowedRoots: [process.cwd()],
publicBaseUrl: "https://devspace.example.com",
contextIgnorePaths: [
"external-resources",
" vendor/generated ",
"./cache/data/",
"external-resources",
],
subagents: true,
}),
);
Expand All @@ -177,6 +219,25 @@ assert.equal(fileConfig.port, 8787);
assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough");
assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com");
assert.equal(fileConfig.subagents, true);
assert.deepEqual(fileConfig.contextIgnorePaths, [
"external-resources",
"vendor/generated",
"cache/data",
]);
assert.deepEqual(
loadConfig({
DEVSPACE_CONFIG_DIR: configDir,
DEVSPACE_CONTEXT_IGNORE_PATHS: "environment/path",
}).contextIgnorePaths,
["environment/path"],
);
assert.deepEqual(
loadConfig({
DEVSPACE_CONFIG_DIR: configDir,
DEVSPACE_CONTEXT_IGNORE_PATHS: "",
}).contextIgnorePaths,
[],
);
assert.deepEqual(fileConfig.allowedHosts, [
"localhost",
"127.0.0.1",
Expand Down
47 changes: 47 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ServerConfig {
widgets: WidgetMode;
stateDir: string;
worktreeRoot: string;
contextIgnorePaths: string[];
skillsEnabled: boolean;
skillPaths: string[];
devspaceSkillsDir: string;
Expand Down Expand Up @@ -115,6 +116,49 @@ function parsePathList(value: string | undefined): string[] {
);
}

function parseContextIgnorePaths(value: unknown): string[] {
if (value === undefined) return [];
const entries = typeof value === "string" ? value.split(",") : value;
if (!Array.isArray(entries) || !entries.every((entry) => typeof entry === "string")) {
throw new Error(
"DEVSPACE_CONTEXT_IGNORE_PATHS must be a comma-separated string or a config array of strings.",
);
}

const normalized = entries
.map((entry) => entry.trim())
.filter(Boolean)
.map(normalizeContextIgnorePath);

return Array.from(new Set(normalized));
}

function normalizeContextIgnorePath(value: string): string {
const slashPath = value.replaceAll("\\", "/");
const segments = slashPath.split("/");
if (
slashPath.startsWith("/") ||
/^[A-Za-z]:/.test(slashPath) ||
slashPath.includes("\0") ||
segments.includes("..")
) {
throw new Error(
`Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry: ${value}. Use workspace-relative directory paths.`,
);
}

const normalized = segments
.filter((segment) => segment !== "" && segment !== ".")
.join("/");
if (!normalized) {
throw new Error(
`Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry: ${value}. Use workspace-relative directory paths.`,
);
}

return normalized;
}

function parseStringList(value: string | undefined, fallback: string[]): string[] {
const entries = value
?.split(",")
Expand Down Expand Up @@ -226,6 +270,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
widgets: parseWidgetMode(env.DEVSPACE_WIDGETS),
stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())),
worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())),
contextIgnorePaths: parseContextIgnorePaths(
env.DEVSPACE_CONTEXT_IGNORE_PATHS ?? files.config.contextIgnorePaths,
),
skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS),
skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS),
devspaceSkillsDir: devspaceSkillsDir(env),
Expand Down
1 change: 1 addition & 0 deletions src/user-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface DevspaceUserConfig {
allowedHosts?: string[];
stateDir?: string;
worktreeRoot?: string;
contextIgnorePaths?: string[];
agentDir?: string;
subagents?: boolean;
}
Expand Down
64 changes: 61 additions & 3 deletions src/workspaces.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, opendir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { platform, tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
Expand Down Expand Up @@ -41,17 +41,41 @@ try {
await mkdir(join(root, "nested"));
await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n");
await writeFile(join(root, "nested", "file.txt"), "hello\n");
await mkdir(join(root, "nested", "external-resources"));
await writeFile(
join(root, "nested", "external-resources", "AGENTS.md"),
"nested visible instructions\n",
);
await mkdir(join(root, "external-resources", "mounted-content"), { recursive: true });
await writeFile(
join(root, "external-resources", "mounted-content", "AGENTS.md"),
"mounted instructions\n",
);
await mkdir(join(root, "external-resources-old"));
await writeFile(
join(root, "external-resources-old", "AGENTS.md"),
"prefix visible instructions\n",
);
await mkdir(join(root, "node_modules"));
await writeFile(join(root, "node_modules", "AGENTS.md"), "built-in ignored instructions\n");

const config = loadConfig({
DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"),
DEVSPACE_ALLOWED_ROOTS: root,
DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"),
DEVSPACE_AGENT_DIR: agentDir,
DEVSPACE_CONTEXT_IGNORE_PATHS: "external-resources",
DEVSPACE_SUBAGENTS: "1",
DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
PORT: "1",
});
const registry = new WorkspaceRegistry(config);
const openedDirectories: string[] = [];
const registry = new WorkspaceRegistry(config, undefined, {
opendir: async (path, options) => {
openedDirectories.push(path.toString());
return await opendir(path, options);
},
});
const { workspace, agentsFiles, availableAgentsFiles } = await registry.openWorkspace(root);

assert.equal(workspace.mode, "checkout");
Expand All @@ -61,7 +85,41 @@ try {
);
assert.deepEqual(
availableAgentsFiles.map((file) => file.path),
[join(root, "nested", "AGENTS.md")],
[
join(root, "external-resources-old", "AGENTS.md"),
join(root, "nested", "AGENTS.md"),
join(root, "nested", "external-resources", "AGENTS.md"),
],
);
assert.equal(openedDirectories.includes(join(root, "external-resources")), false);
assert.equal(openedDirectories.includes(join(root, "node_modules")), false);
assert.equal(openedDirectories.includes(join(root, "nested", "external-resources")), true);
assert.equal(
registry.resolvePath(
workspace,
join("external-resources", "mounted-content", "AGENTS.md"),
),
join(root, "external-resources", "mounted-content", "AGENTS.md"),
);

const unignoredRoot = join(root, "unignored-workspace");
await mkdir(join(unignoredRoot, "external-resources"), { recursive: true });
await writeFile(
join(unignoredRoot, "external-resources", "AGENTS.md"),
"visible instructions\n",
);
const unignoredConfig = loadConfig({
DEVSPACE_CONFIG_DIR: join(root, ".devspace-unignored-home"),
DEVSPACE_ALLOWED_ROOTS: unignoredRoot,
DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unignored-worktrees"),
DEVSPACE_AGENT_DIR: agentDir,
DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
PORT: "1",
});
const unignoredWorkspace = await new WorkspaceRegistry(unignoredConfig).openWorkspace(unignoredRoot);
assert.deepEqual(
unignoredWorkspace.availableAgentsFiles.map((file) => file.path),
[join(unignoredRoot, "external-resources", "AGENTS.md")],
);
assert.deepEqual(
workspace.agentProfiles.map((profile) => ({
Expand Down
Loading