From b4399de27ce5b65953d7b8398904ecb22ebae2bd Mon Sep 17 00:00:00 2001 From: Dorian TETU Date: Tue, 7 Jul 2026 17:35:35 +0200 Subject: [PATCH 1/2] fix: service account + scm owner + docker required error + iris_doc delete error handling --- README.md | 156 ++++++++++++++++++ crates/iris-agentic-dev-core/src/tools/scm.rs | 15 +- 2 files changed, 162 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a207d166..48937222 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,162 @@ or invoke the Linux binary via `wsl.exe`: --- +## Connecting to IRIS + +### Native IRIS on Windows or Linux (no Docker) + +Add a `.iris-agentic-dev.toml` file to your project root: + +```toml +host = "localhost" +web_port = 80 # IIS default for IRIS 2024.1+; use 52773 for pre-2024.1 +namespace = "USER" +username = "_SYSTEM" +password = "SYS" +``` + +#### Port reference + +| IRIS version | Web server | Default port | +|---|---|---| +| 2024.1+ on Windows | IIS | 80 | +| 2024.1+ on Linux | Apache | 80 | +| Pre-2024.1 (any OS) | Private Web Server (PWS) | 52773 | + +#### Windows IIS: `/api` web application required + +This is the most common failure on Windows. IIS needs an explicit `/api` web application mapped to the IRIS Web Gateway module. Without it, `/api/atelier` returns 404 — even when the Management Portal loads correctly. + +**To fix:** + +1. Open **IIS Manager** → expand your server → **Sites** → **Default Web Site** +2. Right-click → **Add Application**. Set alias: `api`, physical path: `C:\InterSystems\IRIS\CSP\bin` (adjust to your install path) +3. Add a wildcard script handler mapping: executable = `CSPms.dll`, no verb restriction +4. Verify `CSP.ini` contains an `[APP_PATH:/api]` section + +See the [`iris-windows-iis-setup` skill](./light-skills/skills/iris-windows-iis-setup/SKILL.md) for full step-by-step instructions with verification commands. + +**`localhost` vs `127.0.0.1`**: On some older Web Gateway builds, using `localhost` causes a brief connection error before each request. If you see connection delays, change the config to `host = "127.0.0.1"`. + +### Docker (community image) + +Run `iris-agentic-dev init` in your project directory — it detects any running IRIS containers and writes `.iris-agentic-dev.toml` automatically: + +```bash +iris-agentic-dev init +``` + +Or configure manually: + +```toml +container = "myapp-iris" +namespace = "MYAPP" +``` + +### Docker (enterprise image) + +Enterprise IRIS images (`intersystems/iris`, `intersystems/irishealth`) ship without a built-in web server. Run the ISC Web Gateway container alongside IRIS: + +```yaml +services: + iris: + image: containers.intersystems.com/intersystems/iris:2026.1 + ports: ["4972:1972"] + webgateway: + image: containers.intersystems.com/intersystems/webgateway:2026.1 + ports: ["52773:80"] + entrypoint: ["/bin/sh", "/init.sh"] + volumes: ["./webgateway-init.sh:/init.sh:ro"] +``` + +See the [`iris-vscode-objectscript` skill](./light-skills/skills/iris-vscode-objectscript/SKILL.md) for a working `webgateway-init.sh`. + +### VS Code Server Manager (zero-config) + +If the [InterSystems Server Manager](https://marketplace.visualstudio.com/items?itemName=intersystems-community.servermanager) extension is installed, iris-agentic-dev reads your server list from VS Code's `settings.json` and resolves credentials from the OS keychain automatically — no `.iris-agentic-dev.toml` needed. + +**Single server configured:** auto-connects, no extra setup. + +**Multiple servers configured:** set `IRIS_SERVER_NAME` to the map key from `intersystems.servers`: + +```bash +export IRIS_SERVER_NAME=dev-local +``` + +Credentials are stored under keychain service `"intersystems-server-credentials"` — the auth provider ID used by Server Manager in all VS Code-compatible forks (Cursor, Windsurf, VS Code Insiders). If a credential is missing, iris-agentic-dev fails fast with a message directing you to reconnect in VS Code (right-click the server → **Reconnect**) rather than silently falling through to other discovery sources. + +Use `check_config` to see which servers were detected and whether credentials resolved: + +```json +{ + "server_manager": { + "available": true, + "servers": [ + { "name": "dev-local", "active": true, "credential_status": "resolved" } + ] + } +} +``` + +### Per-connection policy (fleet / operate mode) + +Add `[policy.]` blocks to `.iris-agentic-dev.toml` to restrict which tool categories are permitted on a given Server Manager server: + +```toml +[policy.prod] +allow = ["query", "search", "docs"] +``` + +Blocked calls return `error_code: "POLICY_GATE"` with the list of allowed categories. Omit the block entirely to permit everything. Available categories: `compile`, `execute`, `query`, `search`, `docs`, `source_control`, `debug`, `admin`, `skill`, `kb`. + +For multi-instance fleet workflows (`mode = "operate"`), see the [fleet roles spec](./specs/003-workspace-config/) for the full `[instance.*]` config format and role-gate behavior. + +### Connection discovery order + +iris-agentic-dev resolves the IRIS connection in this order — first match wins: + +1. CLI flags (`--host`, `--web-port`, `--scheme`) +2. `.iris-agentic-dev.toml` in the workspace root +3. Environment variables (`IRIS_HOST`, etc.) +4. VS Code `settings.json` (`objectscript.conn` / `intersystems.servers`) +5. VS Code Server Manager keychain (`intersystems.servers` + OS keychain credential) +6. Running Docker containers (scored by workspace name similarity) +7. Localhost port scan (52773, 41773, 51773, 8080) + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `IRIS_HOST` | `localhost` | IRIS web gateway hostname | +| `IRIS_WEB_PORT` | `52773` | Web gateway port | +| `IRIS_SCHEME` | `http` | `http` or `https` | +| `IRIS_WEB_PREFIX` | *(empty)* | URL path prefix for non-root gateway installs | +| `IRIS_USERNAME` | `_SYSTEM` | IRIS username | +| `IRIS_PASSWORD` | `SYS` | IRIS password | +| `IRIS_SERVICE_USERNAME` | *(empty)* | Restricted service account for arbitrary-execution tools (see below) | +| `IRIS_SERVICE_PASSWORD` | *(empty)* | Password for `IRIS_SERVICE_USERNAME` | +| `IRIS_NAMESPACE` | `USER` | Default namespace | +| `IRIS_CONTAINER` | *(empty)* | Docker container name — required for Docker-dependent tools | +| `IRIS_SERVER_NAME` | *(empty)* | Server Manager server name when multiple are configured | +| `OBJECTSCRIPT_WORKSPACE` | `$PWD` | Workspace root for `.iris-agentic-dev.toml` lookup | + +### Privilege separation for arbitrary execution + +`iris_execute`, `iris_execute_method`, `iris_query` (`mode="write"`), and `iris_global` +(`set`/`kill`) can run arbitrary ObjectScript/SQL. Under a `%All` account these can edit class +and routine code — even by indirection (`$classmethod`, `$method("%Sa"_"ve")`, `xecute`) — +bypassing the SCM lock and the `CODE_EDIT_BLOCKED` string filter, since no static text filter +can be exhaustive against a fully-privileged identity. + +Set `IRIS_SERVICE_USERNAME` / `IRIS_SERVICE_PASSWORD` to a **least-privilege** IRIS account +(no `%Development` resource, code database mounted read-only). Those four tools then authenticate +as that account, so code edits fail with `` at the IRIS privilege layer regardless of +indirection. Code-writing tools (`iris_document` put, `iris_source_control`, `iris_compile`) +deliberately keep using the primary `IRIS_USERNAME`, so SCM checkouts and audit stay attributed +to the real user. When unset, all tools use the primary connection (unchanged behaviour). + +--- + ## Skills — improve AI output for ObjectScript Skills are concise instruction files that teach your AI assistant ObjectScript patterns and diff --git a/crates/iris-agentic-dev-core/src/tools/scm.rs b/crates/iris-agentic-dev-core/src/tools/scm.rs index 0e500099..91f37def 100644 --- a/crates/iris-agentic-dev-core/src/tools/scm.rs +++ b/crates/iris-agentic-dev-core/src/tools/scm.rs @@ -552,13 +552,7 @@ fn parse_scm_status_line(line: &str) -> Option<(bool, bool, bool, bool, String)> let has_undo_checkout = flag(parts.next())?; let has_add_to_sc = flag(parts.next())?; let owner = parts.next().unwrap_or("").trim().to_string(); - Some(( - is_in_sc, - has_checkout, - has_undo_checkout, - has_add_to_sc, - owner, - )) + Some((is_in_sc, has_checkout, has_undo_checkout, has_add_to_sc, owner)) } /// Fallback owner detection from the SCM provider's native `checked out by user ''` notice. @@ -578,8 +572,11 @@ fn parse_checked_out_by(raw: &str) -> Option<(String, Option)> { static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| { // "checked out by user 'xxx'" — timestamp captured only if the line isn't truncated. - regex::Regex::new(r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?") - .expect("static SCM checked-out regex is valid") + + regex::Regex::new( + r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?", + ) + .expect("static SCM checked-out regex is valid") }); let caps = re.captures(raw)?; let owner = caps.get(1)?.as_str().trim().to_string(); From ce24440beaf3c3c4a269599451707ccaae911b8b Mon Sep 17 00:00:00 2001 From: Dorian TETU Date: Wed, 15 Jul 2026 13:51:20 +0200 Subject: [PATCH 2/2] feat: display diffs when doing surgical edits --- crates/iris-agentic-dev-core/src/tools/doc.rs | 142 ++++++++++++++++++ crates/iris-agentic-dev-core/src/tools/mod.rs | 2 +- crates/iris-agentic-dev-core/src/tools/scm.rs | 14 +- 3 files changed, 152 insertions(+), 6 deletions(-) diff --git a/crates/iris-agentic-dev-core/src/tools/doc.rs b/crates/iris-agentic-dev-core/src/tools/doc.rs index c2b9845e..41f3d1ae 100644 --- a/crates/iris-agentic-dev-core/src/tools/doc.rs +++ b/crates/iris-agentic-dev-core/src/tools/doc.rs @@ -804,6 +804,59 @@ pub fn apply_delete_lines(lines: &[String], start: i64, end: i64) -> (Vec String { + let ctx_start = del_start.saturating_sub(DIFF_CONTEXT); + // Leading context precedes the change on both sides identically. + let lead = &before[ctx_start..del_start]; + // Trailing context follows the change; take it from `before` after the removed span. + let after_change = del_start + del_len; + let trail_end = (after_change + DIFF_CONTEXT).min(before.len()); + let trail = &before[after_change..trail_end]; + + // 1-based hunk line numbers and spans (old side / new side). + let old_start = ctx_start + 1; + let old_count = lead.len() + del_len + trail.len(); + let new_start = old_start; // context before the change is identical, so same first line + let new_count = lead.len() + add.len() + trail.len(); + + let mut out = String::new(); + out.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + old_start, old_count, new_start, new_count + )); + for l in lead { + out.push_str(&format!(" {l}\n")); + } + for l in &before[del_start..after_change] { + out.push_str(&format!("-{l}\n")); + } + for l in add { + out.push_str(&format!("+{l}\n")); + } + for l in trail { + out.push_str(&format!(" {l}\n")); + } + // Drop the trailing newline so the fenced block has no blank last line. + if out.ends_with('\n') { + out.pop(); + } + out +} + /// Compare `expected` (multi-line) against `actual` lines, ignoring trailing /// whitespace on each line and a single trailing blank line on either side. /// Returns None if they match, or Some((line_offset, expected_line, actual_line)) @@ -939,6 +992,9 @@ async fn handle_insert( let block: Vec = block_src.lines().map(|s| s.to_string()).collect(); let (new_lines, actual_at) = apply_insert(&existing, at, &block); let new_content = new_lines.join("\n"); + // Build the diff before the write, while we still hold before/after in memory (no extra + // IRIS round-trip). An insert removes nothing at (actual_at - 1) and adds `block` there. + let diff = unified_diff(&existing, (actual_at - 1) as usize, 0, &block); let result = write_with_scm( iris, @@ -960,6 +1016,7 @@ async fn handle_insert( "edit": "insert", "inserted_at": actual_at, "lines_added": block.len(), + "diff": diff, }), ) .await) @@ -1038,6 +1095,14 @@ async fn handle_delete_lines( ); } let new_content = new_lines.join("\n"); + // Build the diff before the write, while we still hold before/after in memory (no extra + // IRIS round-trip). delete_lines removes [actual_start, actual_end] and adds nothing. + let diff = unified_diff( + &existing, + (actual_start - 1) as usize, + removed as usize, + &[], + ); let result = write_with_scm( iris, @@ -1060,6 +1125,7 @@ async fn handle_delete_lines( "deleted_start": actual_start, "deleted_end": actual_end, "lines_removed": removed, + "diff": diff, }), ) .await) @@ -2361,6 +2427,82 @@ mod tests { assert_eq!(removed, 3); } + // ── unified_diff (rendered markdown diff) ───────────────────────────────── + #[test] + fn test_unified_diff_insert_middle_has_context_and_plus_lines() { + // Insert "X" before line 3 (0-based index 2) of a-b-c-d-e. + let before = v(&["a", "b", "c", "d", "e"]); + let add = v(&["X"]); + let diff = unified_diff(&before, 2, 0, &add); + // Hunk header: 3 context before + 0 removed + 2 trailing = old span 5 from line 1; + // new span = 3 context + 1 added + 2 trailing... but leading context is capped at + // del_start (2 lines here: a,b). So old=4 (a,b + c,d trailing? no): verify structurally. + assert!( + diff.starts_with("@@ -1,"), + "hunk header starts at line 1: {diff}" + ); + assert!(diff.contains("+X"), "added line is prefixed with +: {diff}"); + // The inserted line is the only + line; everything else is context (space-prefixed). + assert_eq!( + diff.matches("\n+").count() + diff.starts_with('+') as usize, + 1 + ); + // No removed lines on a pure insert. + assert!( + !diff.contains("\n-"), + "insert must have no removed lines: {diff}" + ); + } + + #[test] + fn test_unified_diff_delete_has_minus_lines_and_no_plus() { + // Delete lines 2..3 (b,c) from a-b-c-d-e → 0-based del_start=1, del_len=2. + let before = v(&["a", "b", "c", "d", "e"]); + let diff = unified_diff(&before, 1, 2, &[]); + assert!(diff.contains("-b"), "removed line b: {diff}"); + assert!(diff.contains("-c"), "removed line c: {diff}"); + assert!( + !diff.contains("\n+"), + "delete must have no added lines: {diff}" + ); + // Context line a precedes the change. + assert!(diff.contains(" a"), "leading context present: {diff}"); + } + + #[test] + fn test_unified_diff_context_capped_at_document_bounds() { + // Change at the very start → no leading context available; header still starts at 1. + let before = v(&["a", "b", "c"]); + let diff = unified_diff(&before, 0, 1, &[]); + assert!(diff.starts_with("@@ -1,"), "header from line 1: {diff}"); + assert!(diff.contains("-a"), "first line removed: {diff}"); + // Trailing context (b,c) shown as space-prefixed, no panic on out-of-range. + assert!( + diff.contains(" b") && diff.contains(" c"), + "trailing context: {diff}" + ); + } + + #[test] + fn test_unified_diff_replace_shows_minus_then_plus() { + // Replace line 2 (b) with Y: del_start=1, del_len=1, add=[Y]. + let before = v(&["a", "b", "c"]); + let diff = unified_diff(&before, 1, 1, &v(&["Y"])); + let minus = diff.find("-b").expect("removed b"); + let plus = diff.find("+Y").expect("added Y"); + assert!(minus < plus, "removed line comes before added line: {diff}"); + } + + #[test] + fn test_unified_diff_no_trailing_newline() { + let before = v(&["a", "b", "c"]); + let diff = unified_diff(&before, 1, 0, &v(&["X"])); + assert!( + !diff.ends_with('\n'), + "diff must not end with a newline: {diff:?}" + ); + } + // ── diff_expected (stale-edit guard) ────────────────────────────────────── #[test] fn test_diff_expected_match() { diff --git a/crates/iris-agentic-dev-core/src/tools/mod.rs b/crates/iris-agentic-dev-core/src/tools/mod.rs index a922ee1c..c9973e0c 100644 --- a/crates/iris-agentic-dev-core/src/tools/mod.rs +++ b/crates/iris-agentic-dev-core/src/tools/mod.rs @@ -3477,7 +3477,7 @@ do ##class(%UnitTest.Manager).RunTest("{pattern}","{flags}","{token}")"#, } #[tool( - description = "Read/write/delete IRIS documents. mode: get (fetch source), put (write, auto SCM checkout), delete, head (existence), fragment (read lines start..end), compiled (read INT), list (glob `pattern`), insert (splice `content` before 1-based `line`; omit `line` to append), delete_lines (remove start..end). `name` is required for all single-document modes; `line`/`start`/`end` are integers. For insert with an explicit `line` and for delete_lines, pass `expected` (current text at the target lines) or the edit is refused with STALE_CONTENT. Edits return the re-numbered post-write `content` to chain from. Batch via `names`; SCM dialogs resume via elicitation_id/elicitation_answer." + description = "Read/write/delete IRIS documents. mode: get (fetch source), put (write, auto SCM checkout), delete, head (existence), fragment (read lines start..end), compiled (read INT), list (glob `pattern`), insert (splice `content` before 1-based `line`; omit `line` to append), delete_lines (remove start..end). `name` is required for all single-document modes; `line`/`start`/`end` are integers. For insert with an explicit `line` and for delete_lines, pass `expected` (current text at the target lines) or the edit is refused with STALE_CONTENT. Edits return the re-numbered post-write `content` to chain from, plus a `diff` field (git-style unified diff of the change) — render it to the user inside a ```diff fenced code block. Batch via `names`; SCM dialogs resume via elicitation_id/elicitation_answer." )] async fn iris_doc( &self, diff --git a/crates/iris-agentic-dev-core/src/tools/scm.rs b/crates/iris-agentic-dev-core/src/tools/scm.rs index 91f37def..3c0ed7a6 100644 --- a/crates/iris-agentic-dev-core/src/tools/scm.rs +++ b/crates/iris-agentic-dev-core/src/tools/scm.rs @@ -552,7 +552,13 @@ fn parse_scm_status_line(line: &str) -> Option<(bool, bool, bool, bool, String)> let has_undo_checkout = flag(parts.next())?; let has_add_to_sc = flag(parts.next())?; let owner = parts.next().unwrap_or("").trim().to_string(); - Some((is_in_sc, has_checkout, has_undo_checkout, has_add_to_sc, owner)) + Some(( + is_in_sc, + has_checkout, + has_undo_checkout, + has_add_to_sc, + owner, + )) } /// Fallback owner detection from the SCM provider's native `checked out by user ''` notice. @@ -573,10 +579,8 @@ fn parse_checked_out_by(raw: &str) -> Option<(String, Option)> { let re = RE.get_or_init(|| { // "checked out by user 'xxx'" — timestamp captured only if the line isn't truncated. - regex::Regex::new( - r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?", - ) - .expect("static SCM checked-out regex is valid") + regex::Regex::new(r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?") + .expect("static SCM checked-out regex is valid") }); let caps = re.captures(raw)?; let owner = caps.get(1)?.as_str().trim().to_string();