Skip to content
Closed
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
198 changes: 198 additions & 0 deletions shell/src/code/functions/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! `coder::context` — one-call workspace snapshot for seeding a coding
//! session's environment block: effective allowed roots, platform, bounded
//! git state, and repo instruction files (AGENTS.md / CLAUDE.md). Read-only.
//! Git state comes from running the `git` binary with the primary root as
//! cwd; a missing binary, a timeout, or a non-repo root yields `git: null`
//! — never an error, so callers can always render whatever came back.

use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::code::config::CoderConfig;
use crate::code::path::PathResolver;

/// Maximum porcelain status entries reported before truncation.
const STATUS_MAX_ENTRIES: usize = 50;
/// Number of recent commits reported (`git log --oneline`).
const RECENT_COMMITS: usize = 5;
/// Per-file byte cap for instruction-file content.
const INSTRUCTION_MAX_BYTES: usize = 16 * 1024;
/// Instruction files probed at the primary root, in report order.
const INSTRUCTION_FILE_NAMES: [&str; 2] = ["AGENTS.md", "CLAUDE.md"];
/// Wall-clock budget per git invocation.
const GIT_TIMEOUT: Duration = Duration::from_secs(2);

/// No caller-visible arguments — `coder::context` is a pure discovery call.
// examples are wire-contract; goldens pin them.
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(example = "example_context_input")]
pub struct ContextInput {
/// Internal harness filesystem scope; omitted from published schema.
#[serde(default)]
#[schemars(skip)]
pub fs_scope: Option<crate::fs::FsScope>,
}

// examples are wire-contract; goldens pin them.
fn example_context_input() -> serde_json::Value {
serde_json::json!({})
}

#[derive(Debug, Serialize, JsonSchema)]
pub struct ContextOutput {
/// The primary allowed root — relative paths in every `coder::*` call
/// resolve against this directory.
pub primary_root: String,
/// Canonical absolute paths of all allowed roots, primary first.
pub base_paths: Vec<String>,
pub platform: Platform,
/// Git state of the primary root; `null` when the root is not inside a
/// git work tree or the `git` binary is unavailable.
#[serde(skip_serializing_if = "Option::is_none")]
pub git: Option<GitContext>,
/// Repo instruction files (AGENTS.md, CLAUDE.md) found at the primary
/// root, in probe order. Content is capped per file; `truncated` marks
/// a capped entry.
pub instruction_files: Vec<InstructionFile>,
}

#[derive(Debug, Serialize, JsonSchema)]
pub struct Platform {
/// Operating system (`std::env::consts::OS`, e.g. "macos", "linux").
pub os: String,
/// CPU architecture (`std::env::consts::ARCH`, e.g. "aarch64").
pub arch: String,
}

#[derive(Debug, Serialize, JsonSchema)]
pub struct GitContext {
/// Current branch name; `"HEAD"` when detached.
pub branch: String,
/// `git status --porcelain` lines, capped at 50 entries.
pub status: Vec<String>,
/// True when the status list was capped.
pub status_truncated: bool,
/// Up to the last 5 commits, `git log --oneline` format.
pub recent_commits: Vec<String>,
}

#[derive(Debug, Serialize, JsonSchema)]
pub struct InstructionFile {
/// Root-relative file name (e.g. "AGENTS.md").
pub path: String,
/// File content, capped at 16 KiB (char-boundary safe).
pub content: String,
/// True when `content` was capped.
pub truncated: bool,
}

pub async fn handle(
resolver: Arc<PathResolver>,
_cfg: Arc<CoderConfig>,
_req: ContextInput,
) -> Result<ContextOutput, String> {
let primary = 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}"))?;

Ok(ContextOutput {
primary_root: primary.display().to_string(),
base_paths: resolver
.roots()
.iter()
.map(|p| p.display().to_string())
.collect(),
platform: Platform {
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
},
git,
instruction_files,
})
}

/// Run one bounded git command; `None` on spawn failure, timeout, or
/// non-zero exit (all equivalent to "no git state" for this snapshot).
async fn git_lines(root: &Path, args: &[&str]) -> Option<Vec<String>> {
let fut = tokio::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.stdin(std::process::Stdio::null())
.output();
let out = tokio::time::timeout(GIT_TIMEOUT, fut).await.ok()?.ok()?;
if !out.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::to_string)
.collect(),
)
}
Comment on lines +124 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does tokio::process::Command::output() kill the child process on timeout/drop by default, or does it require kill_on_drop(true)?

💡 Result:

By default, tokio::process::Command::output() does not kill the child process if the future is dropped before it completes [1][2][3]. To enable this behavior, you must explicitly call kill_on_drop(true) on the Command object before spawning or running the output command [4][2][3]. In the tokio library, dropping a Child process handle (or the future returned by output()) does not trigger cancellation by default, a design choice intended to mirror the behavior of the standard library's std::process::Command [1][2][3]. If kill_on_drop(true) is set, the destructor of the future returned by output() will attempt to kill the child process if it is still running when the future is dropped [2][3]. Because of the potential for zombie processes and the non-guaranteed nature of automatic reaping, it is generally recommended to explicitly manage the child process lifecycle using child.wait().await or child.kill().await when strict cleanup guarantees are required [2][5][6].

Citations:


Kill timed-out git children in shell/src/code/functions/context.rs:124-131 tokio::time::timeout drops the output() future, but without kill_on_drop(true) the spawned git process can keep running after the deadline. Add kill_on_drop(true) here so timed-out calls don't leave background processes behind.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/src/code/functions/context.rs` around lines 124 - 141, The git command
in git_lines can outlive the timeout because dropping the output() future does
not stop the spawned process; update the tokio::process::Command setup in
git_lines to enable kill_on_drop(true) so timed-out git invocations are
terminated instead of leaving background children running.


async fn git_context(root: &Path) -> Option<GitContext> {
// rev-parse doubles as the is-a-repo probe: any failure → not a repo
// (or no usable git), and the whole block is omitted.
let branch = git_lines(root, &["rev-parse", "--abbrev-ref", "HEAD"])
.await?
.into_iter()
.next()?;
let mut status = git_lines(root, &["status", "--porcelain"])
.await
.unwrap_or_default();
let status_truncated = status.len() > STATUS_MAX_ENTRIES;
status.truncate(STATUS_MAX_ENTRIES);
// Fails on a repo with no commits yet — an empty history, not an error.
let recent_commits = git_lines(root, &["log", "--oneline", &format!("-{RECENT_COMMITS}")])
.await
.unwrap_or_default();
Some(GitContext {
branch,
status,
status_truncated,
recent_commits,
})
}

fn read_instruction_files(resolver: &PathResolver) -> Vec<InstructionFile> {
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 {
continue;
};
if resolver.is_non_accessible(&abs) {
continue;
}
let Ok(bytes) = std::fs::read(&abs) else {
continue;
};
let full = String::from_utf8_lossy(&bytes);
let truncated = full.len() > INSTRUCTION_MAX_BYTES;
let content = if truncated {
let mut end = INSTRUCTION_MAX_BYTES;
while !full.is_char_boundary(end) {
end -= 1;
}
full[..end].to_string()
} else {
full.into_owned()
};
out.push(InstructionFile {
path: name.to_string(),
content,
truncated,
});
}
out
}
34 changes: 34 additions & 0 deletions shell/src/code/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! `tests/code_golden_schemas.rs` snapshots each entry so ANY change to the
//! agent-facing wire surface shows up as an explicit, reviewed diff.

pub mod context;
pub mod create_file;
pub mod delete_file;
pub mod info;
Expand Down Expand Up @@ -47,6 +48,15 @@ const INFO_DESC: &str = "Report the coder jail: canonical allowed roots (primary
path was rejected — paths outside every allowed root need the \
shell worker's shell::fs::* instead.";

const CONTEXT_ID: &str = "coder::context";
const CONTEXT_DESC: &str = "One-call workspace snapshot for seeding a coding session: the \
allowed roots (primary first), platform os/arch, bounded git state \
of the primary root (current branch, porcelain status capped at 50 \
entries, last 5 commits), and repo instruction files (AGENTS.md / \
CLAUDE.md at the primary root, capped at 16 KiB each). Read-only \
and never fails on a non-repo: `git` is null when the root is not \
a git work tree or the git binary is unavailable.";

const READ_FILE_ID: &str = "coder::read-file";
const READ_FILE_DESC: &str = "Read a file window-first: probe with stat: true (size/mtime/mode \
plus total_lines, no content), then fetch just the lines you need \
Expand Down Expand Up @@ -208,6 +218,7 @@ where
pub fn catalog() -> Vec<FunctionSpec> {
vec![
spec::<info::InfoInput, info::InfoOutput>(INFO_ID, INFO_DESC),
spec::<context::ContextInput, context::ContextOutput>(CONTEXT_ID, CONTEXT_DESC),
spec::<read_file::ReadFileInput, read_file::ReadFileOutput>(READ_FILE_ID, READ_FILE_DESC),
spec::<search::SearchInput, search::SearchOutput>(SEARCH_ID, SEARCH_DESC),
spec::<update_file::UpdateFileInput, update_file::UpdateFileOutput>(
Expand Down Expand Up @@ -240,6 +251,8 @@ pub fn register_all(iii: &IIIClient, cells: CodeCells) {
let mut registered: usize = 0;
register_info(iii, cells.clone());
registered += 1;
register_context(iii, cells.clone());
registered += 1;
register_read_file(iii, cells.clone());
registered += 1;
register_search(iii, cells.clone());
Expand Down Expand Up @@ -281,6 +294,27 @@ fn register_info(iii: &IIIClient, cells: CodeCells) {
);
}

fn register_context(iii: &IIIClient, cells: CodeCells) {
iii.register_function(
CONTEXT_ID,
RegisterFunction::new_async(move |req: context::ContextInput| {
let cells = cells.clone();
async move {
let resolver = cells.resolver.read().await.clone();
let resolver = resolver.session_scoped(
crate::fs::scope_root(req.fs_scope.as_ref()),
crate::fs::scope_grants(req.fs_scope.as_ref()),
);
let cfg = cells.config.read().await.clone();
context::handle(resolver, cfg, req)
.await
.map_err(Error::from)
}
})
.description(CONTEXT_DESC),
);
}

fn register_read_file(iii: &IIIClient, cells: CodeCells) {
iii.register_function(
READ_FILE_ID,
Expand Down
Loading
Loading