Skip to content
Merged
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
3 changes: 1 addition & 2 deletions apps/decodex/src/agent/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,7 @@ impl AppServerCapabilityPreflightFailure {

pub(crate) fn terminal_next_action(&self, recovery_gate: &str) -> String {
format!(
"inspect Codex app-server preflight blocker `{}`, repair the local Codex config/model/provider/skills/plugin/MCP state, restart `decodex serve`, {recovery_gate}",
self.blocker_summary()
"inspect the Codex app-server preflight status, repair the local Codex runtime configuration, restart `decodex serve`, {recovery_gate}"
)
}

Expand Down
2 changes: 1 addition & 1 deletion apps/decodex/src/agent/json_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ impl AppServerHomePreflightFailure {

pub(crate) fn terminal_next_action(&self, recovery_gate: &str) -> String {
format!(
"inspect the local `decodex serve` HOME and app-server Codex home resolution, keep CODEX_HOME/CODEX_SQLITE_HOME shared instead of per-account, restart `decodex serve`, {recovery_gate}"
"inspect the local Decodex and Codex home sharing, restart `decodex serve`, {recovery_gate}"
)
}
}
Expand Down
79 changes: 11 additions & 68 deletions apps/decodex/src/agent/tracker_tool_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{
env,
error::Error,
fmt::{Display, Formatter},
path::{Component, PathBuf},
path::{Path, PathBuf},
process::Command,
};

Expand All @@ -20,7 +20,7 @@ use crate::{
github,
prelude::eyre,
state::StateStore,
tracker::{IssueTracker, TrackerIssue},
tracker::{IssueTracker, TrackerIssue, public_text},
workflow::WorkflowDocument,
};

Expand Down Expand Up @@ -68,17 +68,14 @@ pub(crate) trait DynamicToolHandler {
pub(crate) trait PullRequestInspector {
fn inspect_pull_request(
&self,
cwd: &std::path::Path,
cwd: &Path,
pr_url: &str,
github_token: &str,
) -> std::result::Result<PullRequestDetails, String>;
}

pub(crate) trait LocalRepoInspector {
fn inspect_local_repo(
&self,
cwd: &std::path::Path,
) -> std::result::Result<LocalRepoDetails, String>;
fn inspect_local_repo(&self, cwd: &Path) -> std::result::Result<LocalRepoDetails, String>;
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
Expand Down Expand Up @@ -509,7 +506,7 @@ struct GhPullRequestInspector;
impl PullRequestInspector for GhPullRequestInspector {
fn inspect_pull_request(
&self,
cwd: &std::path::Path,
cwd: &Path,
pr_url: &str,
github_token: &str,
) -> std::result::Result<PullRequestDetails, String> {
Expand Down Expand Up @@ -561,10 +558,7 @@ impl PullRequestInspector for GhPullRequestInspector {

struct LocalGitRepoInspector;
impl LocalRepoInspector for LocalGitRepoInspector {
fn inspect_local_repo(
&self,
cwd: &std::path::Path,
) -> std::result::Result<LocalRepoDetails, String> {
fn inspect_local_repo(&self, cwd: &Path) -> std::result::Result<LocalRepoDetails, String> {
let head_oid =
run_command_for_stdout("git", &["rev-parse", "HEAD"], cwd, "inspect lane HEAD")?;
let default_branch = resolve_lane_default_branch(cwd)?;
Expand Down Expand Up @@ -910,7 +904,7 @@ fn resolve_review_handoff_github_token(
fn run_command_for_stdout(
command: &str,
args: &[&str],
cwd: &std::path::Path,
cwd: &Path,
purpose: &str,
) -> std::result::Result<String, String> {
let output = Command::new(command)
Expand Down Expand Up @@ -941,7 +935,7 @@ fn run_command_for_stdout(
Ok(value.to_owned())
}

fn resolve_lane_default_branch(cwd: &std::path::Path) -> std::result::Result<String, String> {
fn resolve_lane_default_branch(cwd: &Path) -> std::result::Result<String, String> {
if let Some(default_branch) = resolve_lane_default_branch_from_local_cache(cwd)? {
return Ok(default_branch);
}
Expand All @@ -962,7 +956,7 @@ fn resolve_lane_default_branch(cwd: &std::path::Path) -> std::result::Result<Str
}

fn resolve_lane_default_branch_from_remote(
cwd: &std::path::Path,
cwd: &Path,
) -> std::result::Result<Option<String>, String> {
let remote_probe = Command::new("git")
.args(["ls-remote", "--symref", "origin", "HEAD"])
Expand All @@ -986,7 +980,7 @@ fn resolve_lane_default_branch_from_remote(
}

fn resolve_lane_default_branch_from_local_cache(
cwd: &std::path::Path,
cwd: &Path,
) -> std::result::Result<Option<String>, String> {
let symbolic_ref = Command::new("git")
.args(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])
Expand Down Expand Up @@ -1062,58 +1056,7 @@ fn parse_github_remote_with_authority(remote_url: &str) -> std::result::Result<&
}

fn validate_public_comment_body(body: &str) -> Result<(), String> {
for line in body.lines() {
let Some((field_name, value)) = extract_structured_field(line) else {
continue;
};

if field_name == "worktree_path" {
validate_repo_relative_path(value, field_name)?;

continue;
}
if field_name.ends_with("_path") {
return Err(format!(
"Unsupported structured field `{field_name}` in public issue comments."
));
}
}

Ok(())
}

fn extract_structured_field(line: &str) -> Option<(&str, &str)> {
let trimmed = line.trim();
let trimmed = trimmed.strip_prefix("- ").unwrap_or(trimmed);
let (key, value) = trimmed.split_once(':')?;

Some((key.trim(), value.trim().trim_matches('`')))
}

fn validate_repo_relative_path(path: &str, field_name: &str) -> Result<(), String> {
if path.is_empty() {
return Err(format!("`{field_name}` must not be empty."));
}
if path.starts_with('/') || path.starts_with("~/") || has_drive_root_prefix(path) {
return Err(format!("`{field_name}` must be repository-relative, not `{path}`."));
}

let components = std::path::Path::new(path).components();

if components.into_iter().any(|component| matches!(component, Component::ParentDir)) {
return Err(format!("`{field_name}` must stay within the repository, not `{path}`."));
}

Ok(())
}

fn has_drive_root_prefix(path: &str) -> bool {
let bytes = path.as_bytes();

bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
public_text::validate_public_comment_body(body)
}

fn normalize_summary(summary: &str) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,19 @@ fn rejects_public_comments_with_sensitive_or_unknown_paths() {
),
(
"decodex run failed and will retry\n\n- worktree_path: `C:/absolute/path/to/repo/.worktrees/DEC-1`",
"`worktree_path` must be repository-relative, not `C:/absolute/path/to/repo/.worktrees/DEC-1`.",
"`body` must be public/team-visible text; host-local paths are not allowed.",
),
(
"decodex run failed and will retry\n\nMissing credential GITHUB_PAT_Y.",
"`body` must be public/team-visible text; credential-like names are not allowed.",
),
(
"decodex run failed and will retry\n\nSelected account user@example.com.",
"`body` must be public/team-visible text; private identity details are not allowed.",
),
(
"decodex run failed and will retry\n\ncodex.github-identity resolved to a private route.",
"`body` must be public/team-visible text; local identity or account-routing details are not allowed.",
),
] {
let tracker = FakeTracker::new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,42 @@ fn progress_checkpoint_retries_do_not_duplicate_same_ledger_event() {
assert!(second.success);
assert_eq!(tracker.comments.borrow().len(), 1);
}

#[test]
fn progress_checkpoint_rejects_private_public_text_before_write() {
let tracker = FakeTracker::new();
let issue = sample_issue();
let workflow = sample_workflow();
let temp_dir = TempDir::new().expect("tempdir should create");
let pull_request_inspector = FakePullRequestInspector::new(Vec::new());
let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]);
let bridge = TrackerToolBridge::with_review_handoff_for_test(
&tracker,
&issue,
&workflow,
sample_review_context_in(temp_dir.path()),
&pull_request_inspector,
&local_repo_inspector,
);
let response = DynamicToolHandler::handle_call(
&bridge,
ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME,
serde_json::json!({
"phase": "implementing",
"focus": "Inspected /Users/example/code/private checkout.",
"next_action": "Continue implementation.",
"blockers": [],
"evidence": ["Missing GITHUB_PAT_Y was observed."],
"head_sha": sample_local_repo().head_oid
}),
);

assert!(!response.success);
assert!(
response
.content_items
.iter()
.any(|item| matches!(item, DynamicToolContentItem::InputText { text } if text.contains("public/team-visible")))
);
assert!(tracker.comments.borrow().is_empty());
}
3 changes: 2 additions & 1 deletion apps/decodex/src/manual.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1255,7 +1255,8 @@ fn apply_closeout(
branch_name,
landed_change_record,
)? {
prepared.tracker.create_comment(
tracker::create_public_comment(
&prepared.tracker,
prepared.issue.id.as_str(),
format!(
"decodex land completed\n\n- pr_url: `{pr_url}`\n- merge_commit: `{merge_commit}`\n- branch: `{branch_name}`\n- landed_change: `{landed_change_record}`"
Expand Down
3 changes: 2 additions & 1 deletion apps/decodex/src/orchestrator/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,8 @@ where
"Run failed and remains retryable."
);

tracker.create_comment(
tracker::create_public_comment(
tracker,
&issue_run.issue.id,
&format_retry_comment(RetryComment {
run_id: &issue_run.run_id,
Expand Down
4 changes: 4 additions & 0 deletions apps/decodex/src/orchestrator/prompting.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
pub(crate) const TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION: &str =
"Tracker public text boundary\n- Linear tracker text is public/team-visible. Do not include local host paths, routed identity details, account details, credential-like names, private config paths, tokens, or secrets in issue comments, progress checkpoints, review summaries, closeout summaries, blockers, evidence, verification, failed commands, or raw errors.\n- Use public collaboration identifiers when needed: PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths.";

const PROMPT_ONLY_INTERNAL_REVIEW_INSTRUCTION: &str =
"Review your work repeatedly and fix any logic bugs until no new issues are found.";

Expand Down Expand Up @@ -63,6 +66,7 @@ where
sections.push(String::from(
"Commit contract\n- When you create a local commit for this lane, use a single-line `decodex/commit/1` JSON commit message.\n- Required fields: `schema`, `summary`, and `authority`.\n- `authority` must be the authoritative Linear issue identifier for this lane.\n- Optional fields: `related` and `breaking`.\n- Do not encode landing mode, CI status, closeout state, or other process-state fields in the commit message.",
));
sections.push(String::from(TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION));

if let Some(recovery_context) = build_retry_recovery_context(issue_run.dispatch_mode) {
sections.push(recovery_context);
Expand Down
7 changes: 2 additions & 5 deletions apps/decodex/src/orchestrator/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,11 @@ fn terminal_failure_comment_details(
"inspect the worktree and app-server activity for the stalled lane, resolve the blocker manually, {recovery_gate}"
),
)
} else if let Some(credentials_failure) =
error.downcast_ref::<AgentGitCredentialsUnavailable>()
{
} else if error.downcast_ref::<AgentGitCredentialsUnavailable>().is_some() {
(
"github_credentials_unavailable",
format!(
"configure `{}` for the routed GitHub identity, verify noninteractive Git credentials, {recovery_gate}",
credentials_failure.token_env_var
"repair GitHub authentication for this lane, verify noninteractive Git access, {recovery_gate}"
),
)
} else if let Some(app_server_failure) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ fn developer_instructions_trim_workflow_body_and_preserve_required_guidance() {
assert!(instructions.contains("Keep pre-edit discovery bounded"));
assert!(instructions.contains("Do not browse upstream references"));
assert!(instructions.contains("Tracker tool contract"));
assert!(instructions.contains("Linear tracker text is public/team-visible"));
assert!(instructions.contains("You own issue-scoped tracker writes for `PUB-101`."));
assert!(instructions.contains("Decodex already records the run-start Linear ledger"));
assert!(!instructions.contains("started work on run"));
Expand Down
5 changes: 5 additions & 0 deletions apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use orchestrator::TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION;

#[test]
fn daemon_workflow_reload_keeps_last_known_good_on_same_path_failure() {
let (_temp_dir, config, workflow) = temp_project_layout();
Expand Down Expand Up @@ -171,6 +173,9 @@ fn expected_developer_instructions(
sections.push(String::from(
"Commit contract\n- When you create a local commit for this lane, use a single-line `decodex/commit/1` JSON commit message.\n- Required fields: `schema`, `summary`, and `authority`.\n- `authority` must be the authoritative Linear issue identifier for this lane.\n- Optional fields: `related` and `breaking`.\n- Do not encode landing mode, CI status, closeout state, or other process-state fields in the commit message.",
));
sections.push(String::from(
TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION,
));

sections.push(format!(
"Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}`.\n- At the start of execution, call `{transition_tool}` to move the issue to `{in_progress}`. Decodex already records the run-start Linear ledger, so do not add a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- Follow the repo-native bounded review method from `WORKFLOW.md`: review the actual current diff and branch state, run both the requirements pass and the adversarial reviewer pass, fix only the smallest coherent owned batch, rerun verification, and re-read `HEAD` before deciding the next normalized review status.\n- Every time the repo-native bounded review method produces a result for the current head, call `{review_checkpoint_tool}` with that normalized status, the exact current `HEAD` SHA, and any concise evidence items.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- When the implementation is ready, commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n- Call `{review_handoff_tool}` only after the latest `{review_checkpoint_tool}` for this handoff phase and current `HEAD` is `clean`. Then call `{terminal_finalize_tool}` with path `review_handoff`.\n- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, explain the exact observed blocker in a comment, including the failed command and raw error when available, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`. `decodex` will complete the success writeback only after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,14 +289,14 @@ fn app_server_failures_skip_retry_and_require_attention() {
"skills/list returned no enabled skills.",
)),
"app_server_runtime_preflight_failed",
"repair the local Codex config/model/provider/skills/plugin/MCP state",
"repair the local Codex runtime configuration",
);
assert_app_server_failure_requires_attention(
Report::new(AppServerHomePreflightFailure::resolution_failed(String::from(
"app_server_preflight_failed: HOME is not set, so Decodex cannot resolve the shared Codex home for app-server dispatch.",
))),
"app_server_codex_home_preflight_failed",
"keep CODEX_HOME/CODEX_SQLITE_HOME shared instead of per-account",
"inspect the local Decodex and Codex home sharing",
);
assert_app_server_failure_requires_attention(
Report::new(AppServerHomePreflightFailure::initialize_mismatch(
Expand Down
8 changes: 4 additions & 4 deletions apps/decodex/src/orchestrator/tests/runtime/failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,14 +441,14 @@ fn app_server_terminal_failures_preserve_specific_error_classes() {
"configured model was not present in model/list.",
)),
"app_server_runtime_preflight_failed",
"repair the local Codex config/model/provider/skills/plugin/MCP state",
"repair the local Codex runtime configuration",
),
(
Report::new(AppServerHomePreflightFailure::resolution_failed(String::from(
"app_server_preflight_failed: HOME is not set, so Decodex cannot resolve the shared Codex home for app-server dispatch.",
))),
"app_server_codex_home_preflight_failed",
"keep CODEX_HOME/CODEX_SQLITE_HOME shared instead of per-account",
"inspect the local Decodex and Codex home sharing",
),
(
Report::new(AppServerHomePreflightFailure::initialize_mismatch(
Expand Down Expand Up @@ -739,8 +739,8 @@ fn missing_agent_git_credentials_stop_without_retry() {

assert_eq!(credentials_error.token_env_var, missing_env_var);
assert_eq!(error_class, "github_credentials_unavailable");
assert!(next_action.contains("configure"));
assert!(next_action.contains(&missing_env_var));
assert!(next_action.contains("repair GitHub authentication"));
assert!(!next_action.contains(&missing_env_var));
}

#[test]
Expand Down
12 changes: 12 additions & 0 deletions apps/decodex/src/tracker.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub(crate) mod linear;
pub(crate) mod public_text;
pub(crate) mod records;

use std::slice;
Expand Down Expand Up @@ -229,6 +230,7 @@ where
T: IssueTracker + ?Sized,
{
records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?;
public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?;

let comments = tracker.list_comments(issue_id)?;

Expand All @@ -248,6 +250,15 @@ where
Ok(true)
}

pub(crate) fn create_public_comment<T>(tracker: &T, issue_id: &str, body: &str) -> Result<()>
where
T: IssueTracker + ?Sized,
{
public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?;

tracker.create_comment(issue_id, body)
}

pub(crate) fn create_linear_execution_event_comment_without_remote_scan<T>(
tracker: &T,
issue_id: &str,
Expand All @@ -258,6 +269,7 @@ where
T: IssueTracker + ?Sized,
{
records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?;
public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?;

let comment_body = records::append_structured_comment_record(body, record)?;

Expand Down
Loading