diff --git a/console/web/src/lib/backend/harness-send.ts b/console/web/src/lib/backend/harness-send.ts index 2ad3b3aaf..3270593aa 100644 --- a/console/web/src/lib/backend/harness-send.ts +++ b/console/web/src/lib/backend/harness-send.ts @@ -37,8 +37,12 @@ export type HarnessOutputContract = | { type: 'text' } | { type: 'json'; schema?: unknown } -/** Operating mode — the harness prepends a short paragraph before the identity prompt. */ -export type HarnessSendMode = 'plan' | 'ask' | 'agent' +/** + * Operating mode — the harness prepends a short paragraph before the identity + * prompt. `code` instead swaps in the coding identity and, when no explicit + * `functions` policy is sent, defaults to the native coding toolset. + */ +export type HarnessSendMode = 'plan' | 'ask' | 'agent' | 'code' /** Per-send options frozen onto the turn record. */ export interface HarnessSendOptions { diff --git a/console/web/src/lib/backend/real.ts b/console/web/src/lib/backend/real.ts index a5d21e6ea..0deb2b70c 100644 --- a/console/web/src/lib/backend/real.ts +++ b/console/web/src/lib/backend/real.ts @@ -211,7 +211,9 @@ async function* realStream( session: { metadata: { surface: 'console' } }, options: { mode, - functions: functionPolicy, + // Code mode omits `functions` so the harness applies its curated + // native coding policy; other modes keep the gate-configured surface. + ...(mode === 'code' ? {} : { functions: functionPolicy }), ...(thinkingLevel ? { thinking_level: thinkingLevel } : {}), metadata: buildTurnMetadata(sessionId, messageId, opts?.workingDir), }, diff --git a/console/web/src/types/chat.ts b/console/web/src/types/chat.ts index 03427174b..1f44b644e 100644 --- a/console/web/src/types/chat.ts +++ b/console/web/src/types/chat.ts @@ -1,4 +1,4 @@ -export type Mode = 'plan' | 'ask' | 'agent' +export type Mode = 'plan' | 'ask' | 'agent' | 'code' /** Composite `provider::` (matches harness models-catalog). */ export const CATALOG_MODEL_KEY_SEP = '::' as const @@ -15,6 +15,7 @@ export const MODES: { id: Mode; label: string }[] = [ { id: 'plan', label: 'plan' }, { id: 'ask', label: 'ask' }, { id: 'agent', label: 'agent' }, + { id: 'code', label: 'code' }, ] export const DEFAULT_MODE: Mode = 'agent' diff --git a/harness/prompts/code.txt b/harness/prompts/code.txt index 152f4f9d3..e0bb3f99a 100644 --- a/harness/prompts/code.txt +++ b/harness/prompts/code.txt @@ -4,7 +4,7 @@ Your tools are attached natively to this conversation: file operations (the code # Workspace -An block in this prompt describes your workspace: the working directory (all relative paths resolve there), the platform, the git state, and any repo instruction files (AGENTS.md / CLAUDE.md). Treat repo instruction files as authoritative conventions for this codebase — follow them even when they conflict with your defaults. Paths outside the workspace jail are rejected; stay inside it. +An block in this prompt describes your workspace: the working directory (all relative paths resolve there), the platform, the git state, and any repo instruction files (AGENTS.md / CLAUDE.md). It is refreshed at the start of every turn — do not call coder::context to re-fetch what it already tells you; call it only when you have changed the git state mid-turn and need a fresh snapshot. Treat repo instruction files as authoritative conventions for this codebase — follow them even when they conflict with your defaults. Paths outside the workspace jail are rejected; stay inside it. # Working on code diff --git a/shell/src/code/functions/context.rs b/shell/src/code/functions/context.rs index 5fbe74cae..1474fe28c 100644 --- a/shell/src/code/functions/context.rs +++ b/shell/src/code/functions/context.rs @@ -44,8 +44,10 @@ fn example_context_input() -> serde_json::Value { #[derive(Debug, Serialize, JsonSchema)] pub struct ContextOutput { - /// The primary allowed root — relative paths in every `coder::*` call - /// resolve against this directory. + /// The effective root for this call — the session's working directory + /// when the call carries a filesystem scope, else the primary allowed + /// root. Relative paths in `coder::*` calls resolve against it, and the + /// git state and instruction files below describe it. pub primary_root: String, /// Canonical absolute paths of all allowed roots, primary first. pub base_paths: Vec, @@ -93,15 +95,25 @@ pub struct InstructionFile { pub async fn handle( resolver: Arc, _cfg: Arc, - _req: ContextInput, + req: ContextInput, ) -> Result { - let primary = resolver.base_root().to_path_buf(); + // The effective root is the call's fs_scope root when the harness + // stamped one (the turn's working directory) — the configured primary + // only when the call is unscoped. session_root canonicalizes and + // confines it to the allowed roots, exactly like resolve_in. + let scope_root = crate::fs::scope_root(req.fs_scope.as_ref()).map(str::to_string); + let primary = scope_root + .as_deref() + .and_then(|r| resolver.session_root(r)) + .unwrap_or_else(|| resolver.base_root().to_path_buf()); let git = git_context(&primary).await; let resolver_for_files = resolver.clone(); - let instruction_files = - tokio::task::spawn_blocking(move || read_instruction_files(&resolver_for_files)) - .await - .map_err(|e| format!("context task join failed: {e}"))?; + let files_scope = scope_root.clone(); + let instruction_files = tokio::task::spawn_blocking(move || { + read_instruction_files(&resolver_for_files, files_scope.as_deref()) + }) + .await + .map_err(|e| format!("context task join failed: {e}"))?; Ok(ContextOutput { primary_root: primary.display().to_string(), @@ -164,11 +176,15 @@ async fn git_context(root: &Path) -> Option { }) } -fn read_instruction_files(resolver: &PathResolver) -> Vec { +fn read_instruction_files( + resolver: &PathResolver, + scope_root: Option<&str>, +) -> Vec { let mut out = Vec::new(); for name in INSTRUCTION_FILE_NAMES { - // Resolve through the jail so non-accessible globs keep applying. - let Ok(abs) = resolver.resolve(name) else { + // Resolve through the jail (relative to the effective root) so + // non-accessible globs keep applying. + let Ok(abs) = resolver.resolve_opt(scope_root, name) else { continue; }; if resolver.is_non_accessible(&abs) { diff --git a/shell/tests/code_context.rs b/shell/tests/code_context.rs index a21062c10..79eee2f18 100644 --- a/shell/tests/code_context.rs +++ b/shell/tests/code_context.rs @@ -87,3 +87,45 @@ async fn context_caps_oversized_instruction_file() { assert!(out.instruction_files[0].truncated); assert_eq!(out.instruction_files[0].content.len(), 16 * 1024); } + +#[tokio::test] +async fn context_honors_fs_scope_root() { + let tmp = tempdir().unwrap(); + let jail = tmp.path(); + let proj = jail.join("proj"); + std::fs::create_dir(&proj).unwrap(); + git(&proj, &["init", "-b", "work"]); + std::fs::write(proj.join("AGENTS.md"), "scoped conventions\n").unwrap(); + git(&proj, &["add", "."]); + git(&proj, &["commit", "-m", "scoped seed"]); + + let (r, c) = make(jail.to_path_buf()); + // Mirror the registration path: the harness stamps fs_scope and the + // wrapper widens the resolver with session_scoped before handling. + let scope = proj.to_string_lossy().to_string(); + let scoped = r.session_scoped(Some(&scope), None); + let out = context_handle( + scoped, + c, + ContextInput { + fs_scope: Some(shell::fs::FsScope { + root: scope, + grants: vec![], + }), + }, + ) + .await + .unwrap(); + + assert!( + out.primary_root.ends_with("/proj"), + "effective root is the fs_scope root, got {}", + out.primary_root + ); + let git_ctx = out.git.expect("scoped root is a git repo"); + assert_eq!(git_ctx.branch, "work"); + assert_eq!(out.instruction_files.len(), 1); + assert!(out.instruction_files[0] + .content + .contains("scoped conventions")); +} diff --git a/shell/tests/golden/schemas/coder.context.json b/shell/tests/golden/schemas/coder.context.json index b258e2b1e..2b11f102e 100644 --- a/shell/tests/golden/schemas/coder.context.json +++ b/shell/tests/golden/schemas/coder.context.json @@ -116,7 +116,7 @@ "$ref": "#/definitions/Platform" }, "primary_root": { - "description": "The primary allowed root — relative paths in every `coder::*` call resolve against this directory.", + "description": "The effective root for this call — the session's working directory when the call carries a filesystem scope, else the primary allowed root. Relative paths in `coder::*` calls resolve against it, and the git state and instruction files below describe it.", "type": "string" } },