diff --git a/.vscodeignore b/.vscodeignore index 0a76297..7bc3fc3 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -67,3 +67,8 @@ docs/assets/** # The activity bar glyph. Referenced by `contributes.viewsContainers`, so leaving it out # ships an extension whose sidebar icon is a blank square. !docs/assets/activity-bar.svg + +# The marketing images and the HTML they are rendered from. README references them by +# absolute raw.githubusercontent URL, exactly as it does the icon, so the Marketplace +# listing renders them without a megabyte of PNG riding along in every install. +docs/images/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 02319ed..5e9ba66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,114 @@ All notable changes to HirayaCoder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.1] — unreleased + +A macOS run of the same React TODO spec on the 0.6.0 build, `ornith:9b`, high thinking +capacity, step sessions on. Eight steps, four of them refused, and the run stopped at +item one of six reporting *"nothing was written"* — for a step whose two commands had +both exited 0 with the scaffolded project sitting on disk. Four separate faults stacked +up to produce that, and none of them were the model being small. + +### Added — the agent is told what machine it is on + +Three of the four refusals were the model reaching for shell syntax it had no way to know +was unavailable: `mkdir -p todo-glass-app && cd todo-glass-app && npm create vite@latest .`, +then `mkdir -p todo-glass-app`, then `cd todo-glass-app && npm install`. `mkdir -p` is +POSIX; the same model on Windows proposes `md`. Both are wrong here for the same reason — +there is no shell, and `mkdir` is not on the allow-list — but nothing in the prompt said +which OS this was, and nothing said the shell was absent either. The model was guessing at +the platform *and* at the execution model, and a guess wrong on both is refused three +times before the step dies. + +`core/environmentProfile` detects the machine — OS, release, architecture, Node version — +and states it in the system prompt as fact, alongside the sentence that matters more: +there is no shell on any platform, `cwd` is how you run somewhere else, and `create_folder` +/ `list_files` / `read_file` / `write_file` are what replace the shell utilities. Detected +per session from `os.platform()`, never read back from disk, so a workspace synced between +two machines never reports the other one's OS. + +- Carried on the tool-using routes only. Every line of it is about performing an action, + and Ask mode has no tools; a Plan prompt gets the platform facts without the lines + naming tools Plan mode structurally does not have. +- Positioned by a `{environment}` placeholder in both prompt files, and appended when the + placeholder is absent — a customised prompt file cannot leave the model guessing at the + platform by not knowing about it. +- Written to `.hirayacoder/environment.json` for the user to read. Nothing in the prompt + path reads it back; it is there so a session's records say which machine produced them. + Every benchmark folder in this repo had its OS reconstructed from the shape of the + commands the model tried. + +### Added — `.hirayacoder/` is ignored by git from the first session + +`.hirayacoder/` holds the audit log, the outcome ledger, full session transcripts, and +per-session memory. It has never been added to a workspace's `.gitignore`, and in every +benchmark workspace it sits untracked beside a scaffolded project whose `.gitignore` +lists `node_modules` and nothing else. A user who stages everything commits their whole +conversation history. + +`core/workspaceBootstrap` now runs at activation: an existing `.gitignore` is **appended +to**, never rewritten; nothing is appended if any line already covers `.hirayacoder` in +any form, including a `!.hirayacoder` negation, which is a decision to respect rather than +overrule; a missing one is created holding that single entry and nothing else, since +guessing at `node_modules` and `dist` for a project whose language nobody has looked at is +how a helpful default becomes a wrong one. Idempotent, and every failure is logged and +swallowed — a session must still start on a read-only checkout. + +### Fixed — a scaffolder was refused for the absence of the project it creates + +`npm create vite@latest todo-glass-app -- --template react` was refused with +`NO_PACKAGE_JSON`: *"there is no package.json in the workspace root or any folder above +it."* That guard is right about `npm install`, which climbs out of the workspace and +installs into whatever manifest it finds above — that is how a dependency once landed in +this extension's own `package.json`. It is exactly wrong about `npm create`, `npm init`, +and `npm exec`, where the missing manifest is the point. + +This was the first failure of the run and it cascaded: step one was "scaffold the +project", the only command that does it was refused, and the model spent its remaining +attempts on `mkdir -p` and `cd … &&` trying to satisfy a precondition that was never +real. `npm create` / `init` / `exec` / `x`, `yarn create` / `init` / `dlx`, and `pnpm +create` / `init` / `dlx` are now exempt. Everything that acts on an existing project is +guarded exactly as before. + +### Fixed — `cd folder && command` is now taken, not refused for a fourth time + +0.6.0 added `cwd`, documented it in both system prompts, and made the refusal name it +outright: *"If you were chaining `cd folder && …`, drop the `cd` and pass the folder as +'cwd' instead."* The model sent `cd todo-glass-app && npm install`, was told exactly +that, and sent the identical line again. Two of the eight steps in the run went on it. + +That is the third wording of the same instruction, and a small model reaching for `cd x +&& y` is not failing to understand the rule — it is producing the only phrasing it has +for "run this over there". The folder and the command are both in the string, +unambiguously, and the extension was throwing them away to ask for them back. `run_script` +now rewrites that one shape into the two arguments it already takes. + +Nothing is relaxed. Exactly one shape is accepted — a single leading `cd` into one +relative folder, one `&&`, and a remainder with no operator left in it — and a second +`&&`, a pipe, a redirect, `cd ..`, an absolute path, or a quoted path is left untouched +and refused as before. The rewritten command goes through pre-flight, the permission +gate, the allow-list, and the tokenizer; the folder goes through `pathGuard` like every +other `cwd`; the confirmation prompt and the audit log show what will actually run. The +model is told about the rewrite in the observation every time, against its own input, +which teaches better than the sentence in the system prompt did. + +### Fixed — a step that ran a command was scored as having done nothing + +The step that ended the run had already succeeded. `npx create-vite` exit 0, `npm +install` exit 0, twenty seconds of it, eleven files and a `node_modules` on disk. The +guard failed it anyway, stopped the run, and skipped the five remaining items, because a +scaffolder's output never passes through `write_file`: `ChangeSet.recordCommand` appends +to `commands`, `ChangeSet.since` reads `files`, and the two never met. A step whose entire +job is "scaffold the project" could not pass. + +`ChangeSet` now stamps commands with the same revision counter it stamps file changes +with, `commandsSince` is its counterpart to `since`, and `stepGuard.verify` accepts a +command that succeeded as evidence the step did work — reported as such, so a later step +knows the agent wrote nothing itself before it assumes a file is there. The principle is +unchanged: judged from evidence the extension holds, never from the model's account. A +process it spawned and whose exit code it read is exactly that; it was being read from +the wrong half of the change set. + ## [0.6.0] — unreleased Machine B, a full model round on the 0.5.3 build: every model was given the same diff --git a/README.md b/README.md index 137cafe..6f9cc61 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ HirayaCoder icon

+

+ HirayaCoder v0.6.1 — your AI pair programmer, fully offline. A VS Code chat panel showing the agent reading two files, writing two files, and asking for approval before running npm run build. +

+ *A local Filipino-inspired AI coder that brings imagination and speed to your VS Code workflow.* **HirayaCoder is a free AI coding assistant that runs entirely on your own computer.** @@ -129,6 +133,10 @@ There is a longer, friendlier walkthrough in ## Using it day to day +

+ What HirayaCoder does: nothing leaves your machine, you approve every change, agentic on every model, big requests become a checklist, it knows your machine, and it learns your project. Three modes — Agent, Plan, and Ask. +

+ ### The three modes There is a row of buttons at the top of the chat. You can ignore them at first — diff --git a/app/agent/agentSession.js b/app/agent/agentSession.js index 41dafd7..52e7a49 100644 --- a/app/agent/agentSession.js +++ b/app/agent/agentSession.js @@ -31,6 +31,7 @@ const promptRouter = require('../core/promptRouter'); const intentRouter = require('../core/intentRouter'); const toolRegistry = require('./toolRegistry'); const contextBuilder = require('../core/contextBuilder'); +const environmentProfile = require('../core/environmentProfile'); const projectOverview = require('../core/projectOverview'); const reactLoop = require('./reactLoop'); const nativeToolLoop = require('./nativeToolLoop'); @@ -70,7 +71,7 @@ class ChangeSet { constructor() { /** @type {Map} */ this.files = new Map(); - /** @type {Array<{command: string, exitCode: number | null, ok: boolean}>} */ + /** @type {Array<{command: string, exitCode: number | null, ok: boolean, revision?: number}>} */ this.commands = []; /** * How many records this set has taken, ever. @@ -118,7 +119,23 @@ class ChangeSet { */ recordCommand(entry) { this.revision += 1; - this.commands.push(entry); + // Stamped like a file change, and for the same question: `stepGuard` asks what *this + // step* did, and until 0.6.1 the commands half of the set could not answer it. + this.commands.push({ ...entry, revision: this.revision }); + } + + /** + * The commands run since a given revision. + * + * The counterpart to `since`, which reads the files half only. A step that scaffolds a + * project writes no files through the agent at all, so this is the only record that it + * did anything — see `stepGuard.ranSomething`. + * + * @param {number} revision + * @returns {Array<{command: string, ok: boolean}>} + */ + commandsSince(revision) { + return this.commands.filter((entry) => (entry.revision || 0) > revision); } /** @returns {FileChange[]} */ @@ -459,6 +476,18 @@ class AgentSession { */ this.stepSessions = options.stepSessions === true; + /** + * The machine this session is running on, detected once. + * + * Held on the session rather than detected inside `promptRouter` per call: a + * six-item TODO run in step mode routes once per step, and the answer cannot change + * between them. Injected in tests to assert the Windows and POSIX prompts without + * needing the matching machine. + * + * @type {import('../core/environmentProfile').EnvironmentProfile} + */ + this.environment = options.environment || environmentProfile.detect(); + /** * Earlier turns of this chat, set per `run` call. * @@ -519,6 +548,7 @@ class AgentSession { thinkingCapacity: this.thinkingCapacity, memory: await this._renderMemory(), earnedHints: await this._earnedHints(mode), + environment: this.environment, intent: intent.intent, // "Read the README" should not be able to reach `run_script`, whatever the // planner decides in between. See `intentRouter.isReadOnlyRequest`. @@ -857,6 +887,9 @@ class AgentSession { item: item.text, stopReason: outcome.stopReason, changed: changeSet.since(itemRevisionBefore), + // A scaffold step's whole output is a command's side effects, which never pass + // through `write_file` and so never appear above. + commands: changeSet.commandsSince(itemRevisionBefore), steps: outcome.steps, }); @@ -969,6 +1002,7 @@ class AgentSession { thinkingCapacity: this.thinkingCapacity, memory: await this._renderMemory({ about }), earnedHints: await this._earnedHints(activeRoute.mode), + environment: this.environment, intent: 'task', }); } catch (err) { diff --git a/app/agent/scriptPreflight.js b/app/agent/scriptPreflight.js index 4de2df5..a33e1de 100644 --- a/app/agent/scriptPreflight.js +++ b/app/agent/scriptPreflight.js @@ -35,6 +35,46 @@ const NODE_RUNNERS = new Set(['npm', 'yarn', 'pnpm']); */ const IMPLICIT_SCRIPTS = new Set(['test', 'start', 'install', 'stop', 'restart']); +/** + * Subcommands that exist to *create* a project rather than to act on one. + * + * The manifest check below exists because `npm install` with no `package.json` climbs + * out of the workspace and installs into whatever it finds above. These do not: `npm + * create vite@latest todo-glass-app` downloads `create-vite` and runs it against a + * folder it makes itself, `npm init` writes a manifest into the current directory, and + * `npm exec` / `npm x` are `npx` under another name — which was never guarded at all, + * since `npx` is not a package manager by `packageManagerOf`. + * + * Refusing them was the first failure of the macOS 0.6.0 benchmark and it cascaded + * through the whole run: step one was "scaffold the project", the only command that + * does it was refused for the absence of the very thing it was about to produce, and + * the model spent its remaining attempts on `mkdir -p` and `cd … &&` trying to satisfy + * a precondition that was never real. + * + * The rule this keeps: refuse only when being wrong is impossible. "npm cannot install + * into a project that does not exist" is certain. "npm cannot create a project where + * there is no project" is the opposite of certain — it is the one case where the + * missing manifest is the point. + */ +const PROJECT_CREATING_SUBCOMMANDS = new Map([ + ['npm', new Set(['create', 'init', 'exec', 'x'])], + ['yarn', new Set(['create', 'init', 'dlx'])], + ['pnpm', new Set(['create', 'init', 'dlx'])], +]); + +/** + * Is this command about to create a project rather than act on an existing one? + * + * @param {string} runner + * @param {string} command + * @returns {boolean} + */ +function createsProject(runner, command) { + const subcommand = (String(command || '').trim().split(/\s+/)[1] || '').toLowerCase(); + const known = PROJECT_CREATING_SUBCOMMANDS.get(runner); + return Boolean(known && known.has(subcommand)); +} + /** * @typedef {object} PreflightRefusal * @property {string} code Machine-readable, matching the runner's refusal codes. @@ -146,6 +186,10 @@ function preflight({ command, cwd, workspaceRoot }) { const runner = packageManagerOf(command); if (!runner || !workspaceRoot) return null; + // A scaffolder makes the project this module would otherwise insist already exists. + // Nothing below has anything useful to say about one, so it is left to run. + if (createsProject(runner, command)) return null; + /** @type {string} */ let directory; try { @@ -219,6 +263,8 @@ module.exports = { parseRun, packageManagerOf, manifestDirectoryWithin, + createsProject, NODE_RUNNERS, IMPLICIT_SCRIPTS, + PROJECT_CREATING_SUBCOMMANDS, }; diff --git a/app/agent/stepGuard.js b/app/agent/stepGuard.js index 5101cdf..761fe82 100644 --- a/app/agent/stepGuard.js +++ b/app/agent/stepGuard.js @@ -46,8 +46,41 @@ const { namedFiles } = require('./stepBrief'); * @property {Array<{kind: string, path: string}>} changed * Files this step created, edited, or deleted — this step's, not the session's. * @property {import('./agentSession').AgentStep[]} steps + * @property {Array<{command: string, ok: boolean}>} [commands] + * Commands this step ran. See `ranSomething` for why a step that only ran commands is + * not a step that did nothing. */ +/** + * Did this step do its work through a command rather than through `write_file`? + * + * ## The step this was missing + * + * A scaffold step writes no files *through the agent*. `npx create-vite todo-glass-app` + * writes eleven of them and `npm install` writes several thousand, and the extension + * sees none of it: `ChangeSet.recordCommand` appends to `commands`, `ChangeSet.since` + * reads `files`, and the two never meet. So a step whose entire job is "scaffold the + * project" arrived here with an empty `changed` array and was judged to have produced + * "no work at all". + * + * That is what ended the macOS 0.6.0 benchmark run. Both commands the step needed had + * already succeeded — `npx create-vite` exit 0, `npm install` exit 0, twenty seconds of + * it, the project sitting on disk — and the run stopped with *"nothing was written"* + * and skipped the five remaining items. The user's report of the session was a wall of + * text about a scaffold that had in fact worked. + * + * The guard's principle is unchanged: judge from evidence the extension holds itself, + * never from the model's account. A process the extension spawned, whose exit code it + * read, is exactly that kind of evidence — it was simply being read from the wrong half + * of the change set. + * + * @param {StepEvidence} evidence + * @returns {boolean} + */ +function ranSomething(evidence) { + return (evidence.commands || []).some((entry) => entry && entry.ok); +} + /** * @typedef {object} Verdict * @property {boolean} ok @@ -115,6 +148,19 @@ function verify(evidence) { const failedSteps = (evidence.steps || []).filter((step) => step.result && step.result.ok === false); if (changed.length === 0) { + // A command that succeeded is work, and a scaffolder's output never passes through + // `write_file`. Reported rather than passed silently: the step is not failed, but + // "the agent wrote nothing itself" is the one thing a later step needs to know + // before it assumes a file is there. + if (ranSomething(evidence)) { + const ran = evidence.commands.filter((entry) => entry.ok).map((entry) => `\`${entry.command}\``); + return { + ok: true, + reason: 'ran', + detail: `no files were written directly, but ${ran.join(' and ')} ran and succeeded`, + }; + } + // The distinction the user sees: a step that tried and was refused reads differently // from one that never attempted anything, and only the first is worth naming a cause // for. diff --git a/app/agent/tools/runScript.js b/app/agent/tools/runScript.js index c97e31f..ac2bbb0 100644 --- a/app/agent/tools/runScript.js +++ b/app/agent/tools/runScript.js @@ -207,6 +207,71 @@ function nextStepAfterRefusal(code, command) { } } +/** + * Rewrite `cd folder && command` into the `cwd` this tool already takes. + * + * ## Why this is a rewrite and not another refusal message + * + * 0.6.0 added `cwd`, documented it in both system prompts, and made the refusal name it + * explicitly: *"If you were chaining `cd folder && …`, drop the `cd` and pass the folder + * as 'cwd' instead."* On the macOS benchmark run the model sent `cd todo-glass-app && + * npm install` anyway, was told that, and sent `cd todo-glass-app && npm install` again. + * Two of the eight steps in the run went on it, and the step that was scaffolding the + * project died there. + * + * That is the third iteration of the same instruction, and the conclusion is that a + * small model reaching for `cd x && y` is not failing to understand the rule — it is + * producing the only phrasing it has for "run this over there", under a decoding + * distribution that a sentence in the system prompt does not move. The information the + * refusal wanted is *already in the command*: the folder and the command are both right + * there, unambiguously, and the extension is throwing them away to ask for them again. + * + * ## What this deliberately does not do + * + * It is not a shell, and it is not the beginning of one. Exactly one shape is accepted — + * a single leading `cd` into one relative folder, joined by one `&&`, followed by a + * remainder with no operator of any kind left in it. Anything else (a second `&&`, a + * pipe, a redirect, `cd ..`, an absolute path, a quoted path with a space) is left + * untouched and refused downstream exactly as before. + * + * Nothing here relaxes a guard. The rewritten command goes through `preflight`, the + * permission gate, the binary allow-list, and `tokenize` — which would still refuse it + * if any operator survived — and the folder goes through `pathGuard` like every other + * `cwd`. The user's confirmation prompt shows the rewritten form, which is what will + * actually run. + * + * @param {string} command + * @param {string} cwd The `cwd` already supplied, if any. + * @returns {{command: string, cwd: string} | null} null when the shape does not match. + */ +function unchainCd(command, cwd) { + // One `cd`, one relative folder, one `&&`, and a remainder. The folder is restricted + // to the characters a project directory actually uses, which is also what keeps a + // quoted path, a variable, or a second command out of it. A leading `./` is the one + // decoration allowed, because models write it constantly. + const match = /^\s*cd\s+(?:\.\/)?([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)\/?\s*&&\s*(.+)$/.exec(String(command || '')); + if (!match) return null; + + const folder = match[1]; + const rest = match[2].trim(); + + // `cd ..`, `cd .`, and anything climbing through a parent is refused rather than + // rewritten: `pathGuard` would reject the resulting `cwd` anyway, and a refusal that + // names the real problem beats a rewrite that produces one. + if (!rest || folder.split('/').some((segment) => segment === '.' || segment === '..')) return null; + // A remainder that still contains an operator was never the simple case. Chaining two + // commands after one `cd` is a genuine ambiguity — which of them the user is approving + // is not answerable — so it keeps the refusal it has always had. + if (/[;&|<>`$(){}\n\r]/.test(rest)) return null; + + return { + command: rest, + // A `cwd` the model also supplied is the folder the `cd` is relative to, which is + // how `cd` behaves and how the two would compose in a real shell. + cwd: cwd ? `${cwd.replace(/\/+$/, '')}/${folder}` : folder, + }; +} + /** * Turn a completed run into an observation. * @@ -287,12 +352,35 @@ function describeServerProbe(command, result, budget, cwd) { * @returns {Promise} */ module.exports = async function runScript(args, context) { - const command = String(args.command || '').trim(); + let command = String(args.command || '').trim(); if (!command) { return { ok: false, observation: 'run_script needs a "command" to run.' }; } - const cwd = String(args.cwd || '').trim(); + let cwd = String(args.cwd || '').trim(); + + // `cd folder && command` says the same thing this tool's two arguments say. Taken + // rather than refused — see `unchainCd` for why a fourth wording of the refusal was + // not the answer. + const unchained = unchainCd(command, cwd); + /** + * Told to the model on whatever the outcome turns out to be. + * + * Stated every time rather than silently absorbed: the command that ran is not the + * command the model sent, and a tool that quietly substitutes one for the other makes + * a failure impossible to read. It doubles as the correction — the shape the model + * should have sent, shown against its own input, which teaches better than the same + * sentence sitting in the system prompt did. + */ + let rewriteNote = ''; + if (unchained) { + const original = command; + ({ command, cwd } = unchained); + logger.info(`run_script rewrote "${original}" as "${command}" in "${cwd}".`); + rewriteNote = + `\n(You sent \`${original}\`. There is no shell, so \`cd\` and \`&&\` cannot work — ` + + `it was run as {"command": "${command}", "cwd": "${cwd}"}. Send that shape yourself next time.)`; + } // Before the user is asked anything: a command that provably cannot work is answered // from the filesystem instead of costing a click, a subprocess, and a page of npm @@ -302,7 +390,7 @@ module.exports = async function runScript(args, context) { logger.info(`run_script pre-flight refused "${command}": ${blocked.code}`); return { ok: false, - observation: `\`${command}\` was not run: ${blocked.reason}`, + observation: `\`${command}\` was not run: ${blocked.reason}${rewriteNote}`, error: blocked.code, detail: { command, cwd: cwd || undefined, reason: blocked.code, attempts: 0 }, }; @@ -322,7 +410,9 @@ module.exports = async function runScript(args, context) { if (!decision.allowed) { return { ok: false, - observation: `\`${command}\` was not run: ${decision.reason}${nextStepAfterRefusal(decision.code, command)}`, + observation: + `\`${command}\` was not run: ${decision.reason}` + + `${nextStepAfterRefusal(decision.code, command)}${rewriteNote}`, error: decision.code, }; } @@ -352,7 +442,9 @@ module.exports = async function runScript(args, context) { logger.warn(`run_script could not start "${command}": ${code || 'unknown'}`); return { ok: false, - observation: `\`${command}\` could not be started: ${/** @type {Error} */ (err).message}${nextStepAfterRefusal(code, command)}`, + observation: + `\`${command}\` could not be started: ${/** @type {Error} */ (err).message}` + + `${nextStepAfterRefusal(code, command)}${rewriteNote}`, error: code, }; } @@ -392,9 +484,10 @@ module.exports = async function runScript(args, context) { // just how often — "this model hits MISSING_DEPENDENCY eleven times a week" is a // fixable observation, "37 failed steps" is not. error: startedCleanly || result.ok || !diagnosis ? undefined : diagnosis.reason, - observation: startedCleanly - ? describeServerProbe(command, result, budget, cwd) - : describeRun(command, result, budget, cwd, { diagnosis, attempts }), + observation: + (startedCleanly + ? describeServerProbe(command, result, budget, cwd) + : describeRun(command, result, budget, cwd, { diagnosis, attempts })) + rewriteNote, detail: { command, cwd: cwd || undefined, @@ -417,4 +510,5 @@ module.exports.looksLikeStartupFailure = looksLikeStartupFailure; module.exports.PROBE_MS = PROBE_MS; module.exports.nextStepAfterRefusal = nextStepAfterRefusal; module.exports.toolForRefusedCommand = toolForRefusedCommand; +module.exports.unchainCd = unchainCd; module.exports.TOOL_INSTEAD_OF = TOOL_INSTEAD_OF; diff --git a/app/core/environmentProfile.js b/app/core/environmentProfile.js new file mode 100644 index 0000000..7010c51 --- /dev/null +++ b/app/core/environmentProfile.js @@ -0,0 +1,212 @@ +'use strict'; + +/* eslint-disable security/detect-non-literal-fs-filename -- + * Every fs call here targets a path built from the VS Code workspace root and a fixed + * filename. No component derives from model output. */ + +/** + * What machine this is, told to the model instead of left for it to guess. + * + * ## The failure this exists for + * + * A live macOS run of the React TODO benchmark on 0.6.0, `ornith:9b`, eight steps, four + * of them refused. Three of the four were the model reaching for shell syntax it had no + * way to know was unavailable: + * + * mkdir -p todo-glass-app && cd todo-glass-app && npm create vite@latest . + * mkdir -p todo-glass-app + * cd todo-glass-app && npm install + * + * `mkdir -p` is POSIX; the same model on Windows proposes `mkdir` with no flag, or `md`. + * Both are wrong here for the same reason — there is no shell and `mkdir` is not on the + * allow-list — but the model had nothing in its prompt saying so, and nothing saying + * which OS it was on either. It was guessing at the platform *and* at the execution + * model, and a guess that is wrong on both is refused three times before the step dies. + * + * The fix is not a better refusal message. It is that the model should never have been + * guessing: the extension knows the platform exactly, at no cost, before the first token + * is generated. So it says so, once, in the system prompt. + * + * ## Why this is also written to disk + * + * `.hirayacoder/environment.json` is not read back by the prompt path — the profile is + * recomputed each session, because a workspace opened on a different machine must not + * inherit the last machine's answer. The file is there so the *user* can see what the + * agent was told, the same way `audit.log` shows what it did, and so a benchmark run + * carries its platform with it. Every one of the eleven benchmark folders in this repo + * had to have its OS reconstructed from the shape of the commands the model tried. + * + * @module core/environmentProfile + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const logger = require('../utils/logger'); +const { platformName } = require('../utils/platform'); + +/** Where the profile is persisted, relative to the workspace root. */ +const PROFILE_PATH = '.hirayacoder/environment.json'; + +/** + * @typedef {object} EnvironmentProfile + * @property {'win32' | 'darwin' | 'linux' | 'other'} platform + * @property {string} osName Human-readable, e.g. 'macOS'. + * @property {string} osVersion `os.release()`, as reported. + * @property {string} arch + * @property {'cmd' | 'sh'} commandStyle + * Which family a *user* would type these commands in. Not what HirayaCoder spawns — + * it spawns no shell at all — but the right answer to "what would this command look + * like here", which is what a model needs when it tells the user to run something. + * @property {string} pathSeparator + * @property {string} nodeVersion + * @property {string} generatedAt ISO timestamp of this detection. + */ + +/** + * The display name for a platform. + * + * @param {ReturnType} name + * @returns {string} + */ +function osNameFor(name) { + if (name === 'win32') return 'Windows'; + if (name === 'darwin') return 'macOS'; + if (name === 'linux') return 'Linux'; + return 'an unrecognised Unix-like system'; +} + +/** + * Detect the machine this session is running on. + * + * Pure inspection of `os` and `process` — no spawning, no filesystem probing, nothing + * that could fail or take a measurable amount of time. This runs on every session start + * and must stay free. + * + * @param {object} [overrides] For tests. + * @param {string} [overrides.platform] + * @param {string} [overrides.release] + * @param {string} [overrides.arch] + * @returns {EnvironmentProfile} + */ +function detect(overrides = {}) { + const name = platformName(overrides.platform || os.platform()); + + return { + platform: name, + osName: osNameFor(name), + osVersion: overrides.release || os.release(), + arch: overrides.arch || os.arch(), + commandStyle: name === 'win32' ? 'cmd' : 'sh', + pathSeparator: name === 'win32' ? '\\' : '/', + nodeVersion: process.versions.node, + generatedAt: new Date().toISOString(), + }; +} + +/** + * The platform-specific half of the prompt block. + * + * Kept to the differences that actually changed a model's behaviour in the benchmark + * runs. A longer list of trivia costs Tier B budget it does not have — at Medium + * capacity a 1B model has roughly 1800 tokens for everything. + * + * @param {EnvironmentProfile} profile + * @returns {string[]} + */ +function platformNotes(profile) { + if (profile.platform === 'win32') { + return [ + 'This is Windows: paths a user types use backslashes, and there is no `mkdir -p`, ' + + '`rm -rf`, `ls`, `cat`, `touch`, or `&&` available to you.', + 'When you tell the user a command to run themselves, write it for cmd/PowerShell, not bash.', + ]; + } + + return [ + `This is ${profile.osName}: a user's own shell is POSIX, but you are not running in one — ` + + '`mkdir -p`, `rm -rf`, `ls`, `cat`, `touch`, and `&&` are all unavailable to you here.', + 'When you tell the user a command to run themselves, write it for bash/zsh.', + ]; +} + +/** + * The block that goes into the system prompt. + * + * Two things at once, and both halves are load-bearing. The platform facts stop the + * model guessing at the OS. The execution-model sentence stops it assuming that knowing + * the OS means it has that OS's shell — which is the more expensive mistake, because a + * model told "this is macOS" and nothing else proposes `mkdir -p` with *more* + * confidence, not less. + * + * Written as statements of fact rather than rules. This is not a policy the model is + * being asked to follow; it is the machine it is standing on. + * + * ## Why the mutating half is optional + * + * Plan mode does not have `write_file`, `create_folder`, or `run_script` — they are + * absent from the schema, not merely refused, and that structural guarantee is what + * Plan mode *is*. A block naming them would be the one place in a Plan prompt that + * tells the model it can run commands, which is exactly the confusion the mode exists + * to prevent. So the platform facts are carried everywhere the block is used, and the + * lines about acting are carried only where acting is possible. + * + * @param {EnvironmentProfile} profile + * @param {object} [opts] + * @param {boolean} [opts.mutating] Does this route actually offer the write tools? + * Defaults to true; `promptRouter` passes the route's own answer. + * @returns {string} + */ +function render(profile, opts = {}) { + const mutating = opts.mutating !== false; + + const lines = [ + 'Environment — this is the machine you are running on right now. It is detected, not ' + + 'guessed, so never speculate about the operating system or ask the user what it is:', + `- Operating system: ${profile.osName} (${profile.platform} ${profile.osVersion}, ${profile.arch})`, + `- Node.js: v${profile.nodeVersion}`, + ...platformNotes(profile).map((note) => `- ${note}`), + ]; + + if (mutating) { + lines.push( + '- run_script does not use a shell on any platform. One plain command per call, no ' + + 'operators, no chaining, no redirects. To run inside a subfolder pass it as "cwd" ' + + '(`{"command": "npm install", "cwd": "todo-glass-app"}`) — never `cd`.', + '- To make a folder use the create_folder tool, to list one use list_files, to read a ' + + 'file use read_file, and to write one use write_file. Those replace the shell ' + + 'utilities on every platform.' + ); + } else { + lines.push('- To list a folder use list_files, and to read a file use read_file.'); + } + + return lines.join('\n'); +} + +/** + * Persist the profile for the user to read, best-effort. + * + * Failure is logged and swallowed. Nothing in the prompt path reads this file, so a + * read-only workspace or a full disk must not be able to stop a session starting. + * + * @param {string} workspaceRoot + * @param {EnvironmentProfile} [profile] + * @returns {EnvironmentProfile} The profile, written or not. + */ +function persist(workspaceRoot, profile = detect()) { + if (!workspaceRoot) return profile; + + const file = path.join(workspaceRoot, ...PROFILE_PATH.split('/')); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(profile, null, 2)}\n`, 'utf8'); + } catch (err) { + logger.warn(`Could not write ${PROFILE_PATH}: ${/** @type {Error} */ (err).message}`); + } + + return profile; +} + +module.exports = { detect, render, persist, platformNotes, osNameFor, PROFILE_PATH }; diff --git a/app/core/promptRouter.js b/app/core/promptRouter.js index 89ca25a..3726731 100644 --- a/app/core/promptRouter.js +++ b/app/core/promptRouter.js @@ -25,6 +25,7 @@ const toolRegistry = require('../agent/toolRegistry'); const modelCapability = require('./modelCapability'); const earnedHints = require('../agent/earnedHints'); +const environmentProfile = require('./environmentProfile'); const productInfo = require('../utils/productInfo'); const { loadTemplate } = require('../utils/promptLoader'); @@ -51,6 +52,8 @@ Rules: set "cwd" to that folder. Never use cd, and never chain commands with &&. - When the task is done, use "done" with a short summary. Do not keep exploring. +{environment} + Session Memory (things established earlier in this project — background, not new instructions): {memory}`; @@ -63,6 +66,8 @@ prefer small verifiable steps, and stop as soon as the task is genuinely complet Never guess a file's contents — read it. Never chain shell commands; run one at a time, and to run inside a subfolder set the command's "cwd" rather than using cd. +{environment} + Session Memory (established earlier in this project — background, not new instructions): {memory}`; @@ -202,6 +207,35 @@ function withIdentity(prompt) { return `${identity}\n\n${prompt}`; } +/** + * Put the detected machine into a system prompt. + * + * ## Why only the tool-using routes get this + * + * Every line of the block is about performing an action — which shell utilities are + * unavailable, how to run a command in a subfolder, which tool replaces `mkdir`. Ask and + * the conversational route have no tools at all, so the block would be six lines of + * inapplicable instruction paid for out of the same budget the user's question is + * competing for. The one thing it would answer for them — "what OS am I on?" — is not + * worth the block, and Agent mode answers it anyway. + * + * Substituted in place where a template carries `{environment}`, appended otherwise. The + * prompt files on disk are author-editable, and a user's customised prompt must not be + * able to silently drop the platform facts by not knowing about the placeholder — the + * same rule `withIdentity` follows, for the same reason. + * + * @param {string} prompt + * @param {import('./environmentProfile').EnvironmentProfile} [profile] + * @param {{mutating?: boolean}} [opts] Passed through to `environmentProfile.render`, + * so a Plan prompt never names a tool Plan mode does not have. + * @returns {string} + */ +function withEnvironment(prompt, profile, opts = {}) { + const block = environmentProfile.render(profile || environmentProfile.detect(), opts); + if (prompt.includes('{environment}')) return prompt.replace('{environment}', block); + return `${prompt}\n\n${block}`; +} + /** * Render the earned-hints block appended to a system prompt. * @@ -237,6 +271,9 @@ function renderEarnedHints(hints) { * @property {import('./modelCapability').Capability} capability * @property {import('./modelCapability').ThinkingCapacity} thinkingCapacity * @property {string} [memory] Rendered Session Memory block. + * @property {import('./environmentProfile').EnvironmentProfile} [environment] + * The detected machine. Detected here when omitted; passed in by `agentSession` so a + * six-item TODO run does not re-detect it per step. * @property {string[]} [earnedHints] Sentences from `agent/earnedHints`, already selected. * @property {'chat' | 'task'} [intent] From `core/intentRouter`. Only consulted in Agent mode. * @property {boolean} [readOnlyTurn] From `intentRouter.isReadOnlyRequest`. Drops the @@ -333,7 +370,9 @@ function route(request) { .replace('{session_memory}', memory); } - systemPrompt = withIdentity(systemPrompt); + systemPrompt = withIdentity( + withEnvironment(systemPrompt, request.environment, { mutating: toolMode !== 'plan' }) + ); if (mode === 'plan') systemPrompt += PLAN_SUFFIX; if (readOnlyTurn) systemPrompt += READ_ONLY_SUFFIX; @@ -378,6 +417,7 @@ module.exports = { canMutate, renderEarnedHints, withIdentity, + withEnvironment, READ_ONLY_SUFFIX, ASK_SYSTEM, CHAT_SYSTEM, diff --git a/app/core/workspaceBootstrap.js b/app/core/workspaceBootstrap.js new file mode 100644 index 0000000..6b5fc47 --- /dev/null +++ b/app/core/workspaceBootstrap.js @@ -0,0 +1,139 @@ +'use strict'; + +/* eslint-disable security/detect-non-literal-fs-filename -- + * Both files written here are fixed names under the VS Code workspace root. No + * component of either path derives from model output. */ + +/** + * The two things a workspace needs before the first turn runs. + * + * Both are small, both are idempotent, and both were previously left to chance: + * + * 1. `.hirayacoder/environment.json` — what machine this is, so the model is told + * rather than guessing. See `core/environmentProfile` for the run that motivated it. + * 2. `.gitignore` carries `.hirayacoder/` — so a session's audit log, transcripts, and + * memory do not turn up in the user's next commit. + * + * ## Why the .gitignore entry is not optional + * + * `.hirayacoder/` holds `audit.log`, `outcomes.jsonl`, `transcripts/*.json`, and + * `memory/session*.txt`. Transcripts contain the full text of every message in a + * session, and memory contains file paths and command lines from the project. None of + * that is secret exactly, and all of it is noise in a diff — but the failure mode is a + * user committing their whole conversation history to a public repository without + * noticing, which is a privacy claim this extension makes and should therefore keep. + * + * Every benchmark workspace in `.ignore/` shows the same thing: `.hirayacoder/` sitting + * beside the scaffolded project, untracked and unmentioned, next to a `.gitignore` the + * scaffolder wrote that lists `node_modules` and nothing else. + * + * ## The rules this follows + * + * - An existing `.gitignore` is **appended to**, never rewritten. It is the user's file. + * - Nothing is appended if any existing line already covers `.hirayacoder`, whatever + * form it is written in — a second entry would be noise, and a `!.hirayacoder` + * negation is a decision to respect rather than to overrule. + * - A missing `.gitignore` is created with the one entry and nothing else. Guessing at + * `node_modules`, `dist`, and `.env` for a project whose language is unknown is how a + * helpful default becomes a wrong one. + * - Every failure is logged and swallowed. A session must start on a read-only checkout. + * + * @module core/workspaceBootstrap + */ + +const fs = require('fs'); +const path = require('path'); + +const logger = require('../utils/logger'); +const environmentProfile = require('./environmentProfile'); + +/** The entry written into `.gitignore`. Trailing slash: it is always a directory. */ +const IGNORE_ENTRY = '.hirayacoder/'; + +/** The comment written above the entry, so a reader knows what put it there. */ +const IGNORE_COMMENT = '# HirayaCoder session data (audit log, transcripts, memory)'; + +/** + * Does this `.gitignore` already have an opinion about `.hirayacoder`? + * + * Deliberately broad. `.hirayacoder`, `.hirayacoder/`, `/.hirayacoder`, `**\/.hirayacoder`, + * and `!.hirayacoder` all count — the question is whether the user has already dealt + * with this path, not whether they spelled it the way this module would. + * + * @param {string} contents + * @returns {boolean} + */ +function alreadyIgnored(contents) { + return String(contents || '') + .split(/\r?\n/) + .some((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return false; + // Strip the negation marker and any leading/trailing path decoration, then compare + // on the bare name. + const bare = trimmed + .replace(/^!/, '') + .replace(/^\*\*\//, '') + .replace(/^\//, '') + .replace(/\/$/, ''); + return bare === '.hirayacoder'; + }); +} + +/** + * Add `.hirayacoder/` to the workspace's `.gitignore`, creating it if there is none. + * + * @param {string} workspaceRoot + * @returns {'created' | 'appended' | 'present' | 'failed'} What happened, for the log + * and for tests. Never throws. + */ +function ensureGitignore(workspaceRoot) { + if (!workspaceRoot) return 'failed'; + const file = path.join(workspaceRoot, '.gitignore'); + + try { + if (!fs.existsSync(file)) { + fs.writeFileSync(file, `${IGNORE_COMMENT}\n${IGNORE_ENTRY}\n`, 'utf8'); + logger.info('Created .gitignore with .hirayacoder/ ignored.'); + return 'created'; + } + + const contents = fs.readFileSync(file, 'utf8'); + if (alreadyIgnored(contents)) return 'present'; + + // Respect the file's own line ending, and never join the entry onto a last line + // that has no newline of its own. + const eol = contents.includes('\r\n') ? '\r\n' : '\n'; + const separator = contents.length === 0 || contents.endsWith('\n') ? '' : eol; + fs.appendFileSync(file, `${separator}${eol}${IGNORE_COMMENT}${eol}${IGNORE_ENTRY}${eol}`, 'utf8'); + logger.info('Added .hirayacoder/ to the existing .gitignore.'); + return 'appended'; + } catch (err) { + logger.warn(`Could not update .gitignore: ${/** @type {Error} */ (err).message}`); + return 'failed'; + } +} + +/** + * Prepare a workspace for agent sessions. + * + * Safe to call repeatedly — on activation, and again whenever a chat tab opens. Both + * halves are idempotent, and the environment profile is deliberately rewritten each + * time rather than reused: a workspace synced between two machines must not report the + * other one's operating system. + * + * @param {string | null} workspaceRoot + * @returns {{profile: import('./environmentProfile').EnvironmentProfile, gitignore: string} | null} + * null when no workspace is open. + */ +function bootstrap(workspaceRoot) { + if (!workspaceRoot) return null; + + const profile = environmentProfile.persist(workspaceRoot); + const gitignore = ensureGitignore(workspaceRoot); + + logger.debug(`Workspace ready: ${profile.osName} (${profile.platform}), .gitignore ${gitignore}.`); + return { profile, gitignore }; +} + +module.exports = { bootstrap, ensureGitignore, alreadyIgnored, IGNORE_ENTRY, IGNORE_COMMENT }; diff --git a/app/extension.js b/app/extension.js index 4f783de..97f3f63 100644 --- a/app/extension.js +++ b/app/extension.js @@ -43,6 +43,8 @@ const diffApply = require('./features/diffApply'); const modelManager = require('./features/modelManager'); const { SessionsProvider } = require('./features/sessionsView'); const { TranscriptStore } = require('./core/transcriptStore'); +const workspaceBootstrap = require('./core/workspaceBootstrap'); +const environmentProfile = require('./core/environmentProfile'); /** Workspace-state key holding models the user has waved off the ">7B" nudge for. */ const DISMISSED_RECOMMENDATIONS_KEY = 'hirayacoder.dismissedRecommendations'; @@ -183,10 +185,24 @@ class HirayaCoder { /** @type {FileHistory | null} */ this.fileHistory = null; + /** + * What machine this is, and a workspace prepared to hold a session's records. + * + * Done here rather than at the first turn so the profile exists before anything can + * read it, and so `.gitignore` covers `.hirayacoder/` before the first audit line is + * written rather than after the user has already staged it. + * + * @type {import('./core/environmentProfile').EnvironmentProfile} + */ + this.environment = environmentProfile.detect(); + this.buildClient(); this.buildSecurityLayer(); this.buildLedger(); this.buildSession(); + + const ready = workspaceBootstrap.bootstrap(workspaceRoot()); + if (ready) this.environment = ready.profile; } /** diff --git a/app/features/chatTab.js b/app/features/chatTab.js index e86f965..b3615bc 100644 --- a/app/features/chatTab.js +++ b/app/features/chatTab.js @@ -600,6 +600,9 @@ class ChatTab { // about this conversation. ledger: this.app.ledger, adaptation: this.app.settings.adaptation, + // Detected once at activation and shared, like the ledger and for the same reason: + // the operating system is a fact about the machine, not about this conversation. + environment: this.app.environment, stepSessions: this.stepSessions, images: images.map((image) => image.base64), }); diff --git a/doc/FEATURES.md b/doc/FEATURES.md index ca89fff..2b743f1 100644 --- a/doc/FEATURES.md +++ b/doc/FEATURES.md @@ -303,6 +303,16 @@ The parts that decide what the agent is *allowed* to do. explains itself so the model can correct and retry. - **An append-only audit log** of every action, decision, and permission mode, at `.hirayacoder/audit.log` (**Show Audit Log**). +- **A stated environment.** The operating system, its release, the architecture, and the + Node version are detected at session start and put in the system prompt as fact, along + with the note that no shell exists on any platform. A model that has to guess whether + `mkdir -p` or `md` is the right spelling has already lost the step; both are wrong here + for the same reason. Written to `.hirayacoder/environment.json` so a session's records + say which machine produced them. +- **A workspace prepared before the first turn.** `.hirayacoder/` holds transcripts, + memory, and the audit log, so it is added to the workspace's `.gitignore` at startup — + appended to an existing file, never rewritten, and skipped entirely if any line already + covers it. - **Honest summaries.** What actually failed is appended to the model's own account from the step record, so a claim that a declined delete succeeded is contradicted in place. diff --git a/doc/SAST-0.6.1.md b/doc/SAST-0.6.1.md new file mode 100644 index 0000000..2f72a6b --- /dev/null +++ b/doc/SAST-0.6.1.md @@ -0,0 +1,107 @@ +# SAST report — 0.6.1 + +Run against the `fix/0.6.1-os-aware-scripts` branch. **Nothing here blocks the 0.6.1 +release**: no finding is exploitable by a remote party, and the two that matter are +pre-existing behaviours rather than anything 0.6.1 introduced. They are recorded here to +be fixed in **0.7.0**. + +## What was run + +| Pass | Tool | Result | +|---|---|---| +| Dependency audit, runtime | `npm audit --omit=dev` | **0 vulnerabilities** — the extension ships no runtime dependencies at all | +| Dependency audit, full | `npm audit` | 3, all dev-only and all reached through `mocha` | +| Static analysis | `eslint-plugin-security`, `eslint-plugin-no-unsanitized` | 0 errors, 18 warnings, all reviewed below | +| Manual review | the 0.6.1 diff, the script path, the new filesystem writes | 3 findings | + +--- + +## To fix in 0.7.0 + +### 1. `npx` reaches the network and never asks — HIGH, pre-existing + +`scriptRunner.ALWAYS_CONFIRM` exists so that *"a handful of allow-listed subcommands +publish code or reach the network"* always cost a click, even under auto-approve. It +covers `git push/clone/fetch/pull`, `npm publish/login`, and `ollama pull`. It does not +cover `npx`, which is on the default allow-list and whose entire purpose is to download a +package from the npm registry and execute it. + +`NON_INTERACTIVE_ENV` sets `npm_config_yes: 'true'`, which is what suppresses npx's own +*"Ok to proceed?"* prompt. Together that is: arbitrary remote code, fetched and run, with +no confirmation from anyone, in an extension whose headline claim is that it works fully +offline. + +This is not theoretical. `.ignore/3.todo-app-0.6.0/.hirayacoder/audit.log` line 7: + +```json +{"action":"run_script","decision":"auto-approved","command":"npx create-vite@latest todo-glass-app -- --template react"} +``` + +A package was downloaded and executed on a live run, auto-approved, and the log records it +as routine. The command was the right one and the package was the real one — the problem is +that nothing checked, and the same path takes any package name the model emits. + +0.6.1 makes `npm exec` and `npm x` behave consistently with `npx` (see the pre-flight +change), which does not widen the hole — `npx` was always there — but it does mean the fix +must cover all four spellings. + +**Suggested fix:** add `npx`, `npm exec`, `npm x`, `yarn dlx`, `pnpm dlx`, and `npm create` +to `ALWAYS_CONFIRM` with the reason *"this downloads and runs a package from the internet"*. +Scaffolding is a once-per-project action, so the cost is one click on the run where it +matters and none on the rest. + +### 2. `workspaceBootstrap` writes two files without going through `pathGuard` — LOW, new in 0.6.1 + +`ensureGitignore` and `environmentProfile.persist` do `fs.appendFileSync` / +`fs.writeFileSync` on `path.join(workspaceRoot, …)` directly. Every other write in the +extension goes through `pathGuard`, which has an `assertRealPath` stage precisely because +*"a lexically-clean path like `docs/notes` can still be a symlink pointing at +`/etc`"* — and neither of these paths gets that check. + +Reaching it requires the user to have opened a workspace whose `.gitignore` is already a +symlink pointing somewhere they did not intend, which is a strange thing to arrange for +yourself. It is a real inconsistency with the module that exists to make this decision in +one place, and it costs two lines to close. + +**Suggested fix:** route both through `pathGuard.assertRealPath`, and skip the write with a +logged warning when it fails. + +### 3. Dev-only advisories in the `mocha` tree — MODERATE, dependency + +`serialize-javascript` (high, RCE), `diff` (low, DoS), both reached only through `mocha`, +which is a `devDependency`. `npm audit --omit=dev` reports zero, and `.vscodeignore` +excludes `test/**` and `node_modules`, so none of it is in the `.vsix` or in anything a +user runs. The exposure is to whoever runs `npm test` on a checkout. + +`npm audit fix --force` moves to `mocha@11.3.0` and calls it a breaking change. Worth doing +deliberately in 0.7.0 with the suite green, not as a drive-by. + +--- + +## Reviewed and dismissed + +- **`security/detect-unsafe-regex` (11 warnings).** Includes `runScript.js:252`, the new + `unchainCd` pattern. Its nested quantifier is unambiguous — the inner alternative + requires a literal `/`, which the character class excludes — so there is no backtracking + explosion. Measured at 50,000 characters of adversarial input: **1 ms**. The other ten + are pre-existing and were checked the same way. +- **`security/detect-object-injection` (3).** All three index into a fixed, code-owned map + or array; none takes its key from model output or from a file. The new + `PROJECT_CREATING_SUBCOMMANDS` lookup was written as a `Map` specifically so it does not + add a fourth. +- **`security/detect-non-literal-regexp` (4).** `projectOverview` and `ignoreRules` build + patterns from workspace filenames. Worth keeping an eye on, but both are bounded by + filename length and neither pattern is ambiguous. +- **`no-unsanitized` — no findings.** The webview still builds every node with + `createElement` + `textContent`. No `innerHTML` anywhere in `app/`. +- **The `unchainCd` rewrite path.** The rewritten command reaches the gate, so the + confirmation prompt, the audit log, the allow-list, and `ALWAYS_CONFIRM` all see what + will actually run rather than what the model typed. The folder is composed only from a + character class that cannot express `..` and is rejected outright if a segment is `.` or + `..`, then confined by `pathGuard` like every other `cwd`. +- **The pre-flight exemption.** It removes a check on `npm create`/`init`/`exec`/`x` only. + `npm install` — the command that actually climbs out of the workspace, and the one that + once installed a dependency into this extension's own `package.json` — is guarded + exactly as before, and there is a test pinning that. +- **Loopback enforcement.** `assertLoopbackEndpoint` still rejects any non-loopback host, + covering the whole `127.0.0.0/8` range rather than just `127.0.0.1`. diff --git a/docs/images/capabilities.png b/docs/images/capabilities.png new file mode 100644 index 0000000..697cc19 Binary files /dev/null and b/docs/images/capabilities.png differ diff --git a/docs/images/hero-offline-agent.png b/docs/images/hero-offline-agent.png new file mode 100644 index 0000000..2010398 Binary files /dev/null and b/docs/images/hero-offline-agent.png differ diff --git a/docs/images/src/README.md b/docs/images/src/README.md new file mode 100644 index 0000000..d42f10a --- /dev/null +++ b/docs/images/src/README.md @@ -0,0 +1,40 @@ +# Marketing image sources + +The two PNGs one folder up are rendered from the HTML beside this file. They are the +source of truth — regenerate rather than editing the PNGs, so the next version bump is a +one-line change instead of a redesign. + +| Source | Output | Used in | +|---|---|---| +| `hero-offline-agent.html` | `../hero-offline-agent.png` | README hero, Marketplace listing | +| `capabilities.html` | `../capabilities.png` | README feature section | + +Both are self-contained: no fonts, scripts, or images are fetched, and the app icon is +inlined as SVG. Everything renders from system fonts, so they look the same on any +machine with a Chromium build on it. + +## Regenerating + +Any Chromium works — Chrome or Edge, whichever is installed. `--force-device-scale-factor=2` +is what makes the output 2560×1440, which is what keeps the text sharp on a HiDPI screen: + +```bash +chrome --headless=new --disable-gpu --hide-scrollbars \ + --window-size=1280,720 --force-device-scale-factor=2 \ + --virtual-time-budget=6000 \ + --screenshot=docs/images/hero-offline-agent.png \ + docs/images/src/hero-offline-agent.html +``` + +On Windows, `msedge.exe` takes the same flags and needs `--user-data-dir` pointed at a +scratch folder — without it the launch is handed to the browser the user already has open +and no screenshot is written. + +## When these need updating + +- **The version badge**, on every release. It appears once per file, as `v0.6.1`. +- **The "New in …" line** on the *Knows your machine* card, whenever a release changes + what that card is claiming. +- **The mock chat transcript** in the hero, if the panel's real layout changes enough that + the picture stops being an honest one. It is a mock, not a screenshot — but it should + never show something the extension does not do. diff --git a/docs/images/src/capabilities.html b/docs/images/src/capabilities.html new file mode 100644 index 0000000..c374249 --- /dev/null +++ b/docs/images/src/capabilities.html @@ -0,0 +1,152 @@ + + + + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
HirayaCoder
+
v0.6.1
+
for Visual Studio Code  ·  powered by Ollama
+
+ +

An agent that does the work — and shows you every step.

+

+ Not autocomplete. HirayaCoder plans, reads, edits, and runs your project across many files, + then reports what actually happened — checked against what changed on disk, never against its own account of itself. +

+ +
+
+
+

Nothing leaves your machine

+

Loopback-only by design — a non-local Ollama endpoint is rejected outright. No telemetry, no account, no cloud fallback.

+
+ +
+
+

You approve every change

+

Writes and deletes arrive as one reviewable diff. Commands run only from an allow-list you control, confined to your workspace.

+
+ +
+
+

Agentic on every model

+

Native tool-calling on 7B+, and a constrained one-action-per-turn loop that keeps even a 1B model doing real multi-file work.

+
+ +
+
+

Big requests become a checklist

+

Multi-part work is split into steps, run one at a time, and each step is verified against what it actually wrote before it can close.

+
+ +
+
+

Knows your machine

+

New in 0.6.1. OS, toolchain, and shell rules are detected and stated up front — so the agent never guesses mkdir -p vs md.

+
+ +
+
+

It learns your project

+

Session memory, an audit log, and per-model adaptation — a model that trips the same guard twice gets the correction, locally.

+
+
+ +
+
+
Agent
+
Plan
+
Ask
+
+
+ Three modes. In Plan and Ask the write tools are + structurally absent — not merely refused. +
+
Free and open source  ·  MIT
Search “HirayaCoder” in the Extensions view
+
+
+ + diff --git a/docs/images/src/hero-offline-agent.html b/docs/images/src/hero-offline-agent.html new file mode 100644 index 0000000..5325f64 --- /dev/null +++ b/docs/images/src/hero-offline-agent.html @@ -0,0 +1,228 @@ + + + + + + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
HirayaCoder
+
v0.6.1
+
+ +

Your AI pair programmer,
fully offline.

+ +

+ A real coding agent in VS Code — reads, writes, and runs your project — powered by + your own Ollama models. No account, no subscription, no internet. + Nothing you write ever leaves your laptop. +

+ +
+
100% local
+
Free forever
+
Agentic on every model — even 1B
+
+ +
+
Install from the VS Code Marketplace
+
Requires Ollama
running locally
+
+
+ +
+
+
+
HirayaCoder
+
Session 3
+
qwen2.5-coder:7b  ·  Agent
+
+ +
+
Add a dark mode toggle to the settings page and persist it.
+ +
+ I’ll read the settings page first, then add the toggle and wire it to localStorage. +
+
✓ read src/pages/Settings.jsx
+
✓ read src/hooks/useTheme.js
+
✓ wrote src/hooks/useTheme.js +34 / -2
+
✓ wrote src/pages/Settings.jsx +18 / -1
+
▶ running npm run build in web/
+
+
+ +
+
APPROVAL REQUIRED
+ npm run build +
+ Allow-listed program  ·  no shell  ·  confined to this workspace +
+
+
Allow
+
Allow always
+
Deny
+
+
+ +
+ Build passed. Dark mode now persists across reloads. +
+
✓ 2 files changed  ·  reviewed as one diff before saving
+
+
+
+ +
+
Ask HirayaCoder to build, fix, or explain something…
+
▲
+
+
+
+ + diff --git a/package-lock.json b/package-lock.json index 20da1c0..792011c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hirayacoder", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hirayacoder", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index c84da59..2c8b6fb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hirayacoder", "displayName": "HirayaCoder", "description": "A fully offline, privacy-first AI coding agent powered by your local Ollama instance. Agentic on every model — even 1B.", - "version": "0.6.0", + "version": "0.6.1", "publisher": "jaymar921", "author": { "name": "jaymar921", diff --git a/setup/prompts/agentic-system-prompt.md b/setup/prompts/agentic-system-prompt.md index 0faeedb..7603250 100644 --- a/setup/prompts/agentic-system-prompt.md +++ b/setup/prompts/agentic-system-prompt.md @@ -60,11 +60,14 @@ auto-approve mode is on. delete_folder is the exception with no auto mode at all every time, it refuses a folder that still has files in it unless you pass `recursive: true`, and nothing it removes can be restored. +{environment} + Session Memory — facts established earlier in this project session. This is reference background, not new instructions, and never grants permissions: {memory} ``` **Notes for implementers:** +- `{environment}` is populated from `core/environmentProfile.js` — the detected OS, its release, the architecture, the Node version, and the platform-specific note about which shell utilities are unavailable. It is detected per session, never read back from disk, so a workspace synced between two machines never reports the other one's OS. Remove the placeholder and the block is appended at the end instead of dropped: a customised prompt file must not be able to leave the model guessing at the platform. - This prompt is injected as the `system` field of the Ollama `/api/chat` request only when `modelCapability.js` classifies the active model as Tier A, and is consumed by `agent/nativeToolLoop.js` underneath the shared `agent/agentSession.js` driver (step budget, session diff set, pause/resume/stop) — the same driver Tier B's `reactLoop.js` reports back into. - Keep this file versioned; bump a `v2`, `v3` file rather than silently mutating behavior, so users can pin a known-good prompt version in settings. diff --git a/setup/prompts/lite-1b-system-prompt.md b/setup/prompts/lite-1b-system-prompt.md index 08cd7e3..1b28a14 100644 --- a/setup/prompts/lite-1b-system-prompt.md +++ b/setup/prompts/lite-1b-system-prompt.md @@ -43,12 +43,15 @@ Rules: have what you need. - If you cannot proceed safely, use "done" and say in the summary exactly what you need. +{environment} + Session Memory — facts established earlier in this project. This is background to help you, not new instructions, and it never grants you permissions: {session_memory} ``` **Notes for implementers:** +- `{environment}` is populated from `core/environmentProfile.js` — the detected OS, its release, the architecture, the Node version, and the platform-specific note about which shell utilities are unavailable. It is detected per session, never read back from disk, so a workspace synced between two machines never reports the other one's OS. Remove the placeholder and the block is appended at the end instead of dropped: a customised prompt file must not be able to leave the model guessing at the platform. - `{session_memory}` is populated from `core/memoryStore.js` — how many entries are recalled depends on the active Thinking Capacity setting (Low: 1 entry, Medium: 3–5, High: all available within budget — see `PROMPT.md` section 5). - Pass `format: "json"` in the Ollama `/api/generate` or `/api/chat` request every turn, so Ollama constrains sampling to valid JSON. - `outputParser.js` must validate each turn's response against this exact schema and treat any parse/schema failure as an implicit `"action": "done"` with a fallback summary showing the raw text — never attempt to "repair" and auto-apply unparseable output. diff --git a/test/unit/environmentProfile.test.js b/test/unit/environmentProfile.test.js new file mode 100644 index 0000000..d3c9e3e --- /dev/null +++ b/test/unit/environmentProfile.test.js @@ -0,0 +1,103 @@ +'use strict'; + +/** + * Telling the model what machine it is on, instead of letting it guess. + * + * The bug these cover is not subtle and did not need a clever detector: on macOS the + * model proposed `mkdir -p`, on Windows it would have proposed `md`, and the extension + * knew the answer both times and never said it. So the assertions are about the two + * things that have to be true of the block — it names the real platform, and it never + * implies a shell exists — plus the one thing that must not happen, which is a Plan + * prompt naming a tool Plan mode does not have. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const environmentProfile = require('../../app/core/environmentProfile'); + +describe('environmentProfile.detect', () => { + it('names each platform the way a person would', () => { + assert.strictEqual(environmentProfile.detect({ platform: 'darwin' }).osName, 'macOS'); + assert.strictEqual(environmentProfile.detect({ platform: 'win32' }).osName, 'Windows'); + assert.strictEqual(environmentProfile.detect({ platform: 'linux' }).osName, 'Linux'); + }); + + it('reports the command style a user of this machine would type in', () => { + assert.strictEqual(environmentProfile.detect({ platform: 'win32' }).commandStyle, 'cmd'); + assert.strictEqual(environmentProfile.detect({ platform: 'darwin' }).commandStyle, 'sh'); + assert.strictEqual(environmentProfile.detect({ platform: 'linux' }).commandStyle, 'sh'); + }); + + it('describes this machine without being told what it is', () => { + const profile = environmentProfile.detect(); + assert.strictEqual(profile.platform === 'other' ? 'other' : profile.platform, profile.platform); + assert.ok(profile.osVersion.length > 0); + assert.strictEqual(profile.nodeVersion, process.versions.node); + assert.ok(!Number.isNaN(Date.parse(profile.generatedAt))); + }); +}); + +describe('environmentProfile.render', () => { + it('states the operating system as a fact, not a question to ask the user', () => { + const block = environmentProfile.render(environmentProfile.detect({ platform: 'darwin', release: '24.1.0' })); + assert.ok(block.includes('macOS')); + assert.ok(block.includes('24.1.0')); + assert.ok(/never speculate/i.test(block)); + }); + + it('says the POSIX utilities are unavailable even on a POSIX machine', () => { + // The mistake worth designing against: a model told "this is macOS" and nothing else + // proposes `mkdir -p` with more confidence, not less. + const block = environmentProfile.render(environmentProfile.detect({ platform: 'darwin' })); + assert.ok(block.includes('mkdir -p')); + assert.ok(/unavailable/i.test(block)); + assert.ok(block.includes('"cwd"')); + }); + + it('writes the Windows note for a Windows machine', () => { + const block = environmentProfile.render(environmentProfile.detect({ platform: 'win32' })); + assert.ok(block.includes('Windows')); + assert.ok(/cmd\/PowerShell/.test(block)); + assert.ok(!/bash\/zsh/.test(block)); + }); + + it('names no mutating tool when the route has none', () => { + const block = environmentProfile.render(environmentProfile.detect({ platform: 'linux' }), { mutating: false }); + for (const name of ['write_file', 'create_folder', 'run_script', 'delete_file']) { + assert.ok(!block.includes(name), `${name} was named in a read-only environment block`); + } + // The platform facts still travel — they are true whatever the mode is. + assert.ok(block.includes('Linux')); + }); +}); + +describe('environmentProfile.persist', () => { + /** @type {string} */ + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-env-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('leaves the profile where a user can read what the agent was told', () => { + const written = environmentProfile.persist(root, environmentProfile.detect({ platform: 'darwin' })); + const onDisk = JSON.parse(fs.readFileSync(path.join(root, '.hirayacoder', 'environment.json'), 'utf8')); + + assert.strictEqual(onDisk.osName, 'macOS'); + assert.strictEqual(onDisk.platform, 'darwin'); + assert.strictEqual(onDisk.nodeVersion, written.nodeVersion); + }); + + it('returns the profile rather than throwing when the file cannot be written', () => { + // No workspace is the ordinary case, not an error: the extension runs without one. + const profile = environmentProfile.persist('', environmentProfile.detect({ platform: 'linux' })); + assert.strictEqual(profile.osName, 'Linux'); + }); +}); diff --git a/test/unit/promptRouter.test.js b/test/unit/promptRouter.test.js index 768b09f..51b68fa 100644 --- a/test/unit/promptRouter.test.js +++ b/test/unit/promptRouter.test.js @@ -276,3 +276,77 @@ describe('toolRegistry mode filtering', () => { assert.strictEqual(toolRegistry.get('rm_rf', 'agent'), null); }); }); + +/** + * The machine, stated rather than left for the model to infer. + * + * On the macOS 0.6.0 run the model was guessing at the platform *and* at the execution + * model, and a guess that is wrong on both is refused three times before the step dies. + * The extension knew the answer to the first before the first token was generated. + */ +describe('promptRouter — environment', () => { + const environmentProfile = require('../../app/core/environmentProfile'); + + /** + * @param {'agent'|'plan'|'ask'} mode + * @param {string} platform + * @param {object} capability + */ + const routeOn = (mode, platform, capability = TIER_B) => + promptRouter.route({ + mode, + capability, + thinkingCapacity: 'medium', + memory: '- earlier fact', + environment: environmentProfile.detect({ platform }), + }); + + it('tells both tiers which operating system this is', () => { + for (const capability of [TIER_A, TIER_B]) { + assert.match(routeOn('agent', 'darwin', capability).systemPrompt, /macOS/); + assert.match(routeOn('agent', 'win32', capability).systemPrompt, /Windows/); + } + }); + + it('says the shell utilities are unavailable, whichever platform it is', () => { + for (const platform of ['darwin', 'linux', 'win32']) { + const prompt = routeOn('agent', platform).systemPrompt; + assert.match(prompt, /mkdir -p/, platform); + assert.match(prompt, /"cwd"/, platform); + } + }); + + it('lands where the template asks for it rather than at the end', () => { + // Both prompt files carry `{environment}` above the memory block. Substituted in + // place, so a customised file controls the position; appended when the placeholder + // is gone, so a customised file cannot drop the platform facts by omission. + const prompt = routeOn('agent', 'darwin').systemPrompt; + assert.ok(!prompt.includes('{environment}'), 'the placeholder was left unsubstituted'); + assert.ok(prompt.indexOf('Operating system:') < prompt.indexOf('earlier fact')); + + const appended = promptRouter.withEnvironment('a prompt with no placeholder', environmentProfile.detect()); + assert.match(appended, /^a prompt with no placeholder\n\nEnvironment/); + }); + + it('names no mutating tool in a Plan prompt', () => { + // Plan mode's guarantee is that the write tools are absent, not refused. A block + // telling the model how to run a command would be the one place in the prompt + // saying otherwise. + const prompt = routeOn('plan').systemPrompt; + for (const name of MUTATING) { + assert.ok(!prompt.includes(name), `Plan prompt mentions ${name}`); + } + assert.match(prompt, /macOS|Windows|Linux/); + }); + + it('leaves the loopless routes alone', () => { + // Every line of the block is about performing an action, and Ask has no tools to + // perform one with. + assert.ok(!routeOn('ask').systemPrompt.includes('Operating system:')); + }); + + it('detects the machine itself when no profile is passed', () => { + const prompt = routeFor('agent').systemPrompt; + assert.match(prompt, /Operating system:/); + }); +}); diff --git a/test/unit/runScriptTool.test.js b/test/unit/runScriptTool.test.js index 81e7d7d..c1a90fb 100644 --- a/test/unit/runScriptTool.test.js +++ b/test/unit/runScriptTool.test.js @@ -453,3 +453,88 @@ describe('the allow-list', () => { } }); }); + +/** + * `cd folder && command` taken as the two arguments it already is. + * + * 0.6.0 added `cwd`, documented it in both prompts, and made the refusal name it. The + * macOS benchmark model sent `cd todo-glass-app && npm install` anyway, was told to use + * `cwd`, and sent the identical line again. The folder and the command are both in the + * string; the extension was throwing them away to ask for them back. + */ +describe('runScript.unchainCd', () => { + const { unchainCd } = runScript; + + it('splits the folder off the command', () => { + assert.deepStrictEqual(unchainCd('cd todo-glass-app && npm install', ''), { + command: 'npm install', + cwd: 'todo-glass-app', + }); + }); + + it('handles the shapes a model actually writes', () => { + assert.deepStrictEqual(unchainCd('cd ./app && npm run build', ''), { command: 'npm run build', cwd: 'app' }); + assert.deepStrictEqual(unchainCd('cd packages/web/ && npm test', ''), { + command: 'npm test', + cwd: 'packages/web', + }); + }); + + it('composes with a cwd the model also supplied, the way cd would', () => { + assert.deepStrictEqual(unchainCd('cd src && npm test', 'app'), { command: 'npm test', cwd: 'app/src' }); + }); + + it('refuses rather than rewrites anything that is not the one simple shape', () => { + // Two commands after one `cd`: which of them the user is approving is not + // answerable, so it keeps the refusal it has always had. + assert.strictEqual(unchainCd('cd app && npm install && npm run build', ''), null); + assert.strictEqual(unchainCd('cd app && npm test | tee out.txt', ''), null); + assert.strictEqual(unchainCd('cd app && npm test > out.txt', ''), null); + assert.strictEqual(unchainCd('cd .. && npm install', ''), null); + assert.strictEqual(unchainCd('cd ../sibling && npm install', ''), null); + assert.strictEqual(unchainCd('cd /etc && npm install', ''), null); + assert.strictEqual(unchainCd('cd app &&', ''), null); + assert.strictEqual(unchainCd('cd app', ''), null); + // Not a rewrite target at all — no `cd` to unchain. + assert.strictEqual(unchainCd('npm install', ''), null); + assert.strictEqual(unchainCd('npm install && npm test', ''), null); + }); + + it('does not let a quoted path smuggle a second command through', () => { + assert.strictEqual(unchainCd('cd "my app" && npm install', ''), null); + assert.strictEqual(unchainCd('cd $(whoami) && npm install', ''), null); + assert.strictEqual(unchainCd('cd `pwd` && npm install', ''), null); + }); +}); + +describe('runScript with a chained cd', () => { + it('runs the rewritten command and says so', async () => { + /** @type {any} */ + let requested = null; + const context = { + sessionId: '1', + mode: 'agent', + gate: { + async requestScript(request) { + requested = request; + return { allowed: true, decision: 'approved' }; + }, + async runScript() { + return { ok: true, code: 0, stdout: 'added 42 packages', stderr: '', timedOut: false, durationMs: 12 }; + }, + }, + }; + + const result = await runScript({ command: 'cd todo-glass-app && npm install' }, context); + + // The gate — and therefore the confirmation prompt and the audit log — sees the + // command that will actually run, not the one the model typed. + assert.strictEqual(requested.command, 'npm install'); + assert.strictEqual(requested.cwd, 'todo-glass-app'); + assert.strictEqual(result.ok, true); + // And the model is told, every time. A tool that quietly substitutes one command + // for another makes a failure impossible to read. + assert.match(result.observation, /cd todo-glass-app && npm install/); + assert.match(result.observation, /"cwd": "todo-glass-app"/); + }); +}); diff --git a/test/unit/scriptPreflight.test.js b/test/unit/scriptPreflight.test.js index d856393..c144f7b 100644 --- a/test/unit/scriptPreflight.test.js +++ b/test/unit/scriptPreflight.test.js @@ -175,4 +175,29 @@ describe('scriptPreflight — npm climbing out of the workspace', () => { assert.strictEqual(preflight({ command: 'node src/main.js', workspaceRoot: root }), null); assert.strictEqual(preflight({ command: 'git status', workspaceRoot: root }), null); }); + + // The macOS 0.6.0 run died on the first step of the first item: the only command that + // creates a project was refused for the absence of the project it was about to create. + it('lets a scaffolder run in the empty workspace it is there to fill', () => { + for (const command of [ + 'npm create vite@latest todo-glass-app -- --template react', + 'npm init -y', + 'npm exec create-vite todo-glass-app', + 'yarn create vite todo-glass-app', + 'pnpm dlx create-vite todo-glass-app', + ]) { + assert.strictEqual(preflight({ command, workspaceRoot: root }), null, command); + } + }); + + it('still refuses the commands that would climb out of the workspace', () => { + // The guard this preserves: `npm install` with no manifest walks upwards and + // installs into whatever it finds, which is how a dependency once landed in the + // extension's own package.json. Creating a project is the exception; acting on one + // that is not there is not. + for (const command of ['npm install lucide-react', 'npm run build', 'yarn add react']) { + const refusal = preflight({ command, workspaceRoot: root }); + assert.strictEqual(refusal && refusal.code, 'NO_PACKAGE_JSON', command); + } + }); }); diff --git a/test/unit/stepGuard.test.js b/test/unit/stepGuard.test.js index ba44542..23da92f 100644 --- a/test/unit/stepGuard.test.js +++ b/test/unit/stepGuard.test.js @@ -52,6 +52,42 @@ describe('stepGuard', () => { assert.match(verdict.detail, /no work at all/); }); + // The macOS 0.6.0 run: `npx create-vite` exit 0, `npm install` exit 0, twenty + // seconds of it, the project on disk — and the step was written off as "nothing was + // written", which stopped the run and skipped the five remaining items. A + // scaffolder's output never passes through write_file, so the files half of the + // change set is empty and the commands half is the only record that anything + // happened. + it('passes a step whose work was done by a command that succeeded', () => { + const verdict = stepGuard.verify( + evidence({ + item: 'Scaffold the Vite React project and install dependencies', + commands: [ + { command: 'npx create-vite@latest todo-glass-app -- --template react', ok: true }, + { command: 'npm install', ok: true }, + ], + }) + ); + + assert.strictEqual(verdict.ok, true); + assert.strictEqual(verdict.reason, 'ran'); + assert.match(verdict.detail, /npm install/); + // Said out loud rather than passed silently: a later step needs to know the agent + // wrote nothing itself before it assumes a file is there. + assert.match(verdict.detail, /no files were written directly/); + }); + + it('still fails a step whose commands all failed', () => { + const verdict = stepGuard.verify( + evidence({ + item: 'Scaffold the Vite React project', + commands: [{ command: 'npm install', ok: false }], + }) + ); + assert.strictEqual(verdict.ok, false); + assert.strictEqual(verdict.reason, 'no-change'); + }); + it('names the blocking error when the step tried and was refused', () => { const verdict = stepGuard.verify( evidence({ diff --git a/test/unit/stepSessions.test.js b/test/unit/stepSessions.test.js index c1fa60e..fa2f617 100644 --- a/test/unit/stepSessions.test.js +++ b/test/unit/stepSessions.test.js @@ -299,4 +299,51 @@ describe('step sessions', function () { const firstStep = client.prompts.slice(1, 3).join('\n'); assert.ok(firstStep.includes('useTodos.js'), 'the step was not given the note about the file it had to import'); }); + + // The macOS 0.6.0 run, reduced. Item one was "scaffold the project": `npx create-vite` + // exited 0, `npm install` exited 0, the project was on disk — and because a + // scaffolder's output never passes through `write_file`, the guard saw an empty change + // set, failed the step, and skipped the five items after it. The user's report was a + // page of text about a scaffold that had worked. + it('does not write off a step whose work was done by a command', async () => { + const modes = new PermissionModes({ initial: { autoEdit: true, autoApproveScripts: true } }); + const client = scriptedClient([ + '1. Set up the project toolchain\n2. Write src/App.jsx', + // Allow-listed, present wherever these tests run, and fast — this stands in for + // the scaffolder. What matters is that it succeeds and writes nothing itself. + json({ action: 'run_script', command: 'node --version' }), + json({ action: 'done', summary: 'toolchain ready' }), + json({ + action: 'write_file', + path: 'src/App.jsx', + code: 'export default function App() {\n return

Todo

;\n}\n', + }), + json({ action: 'done', summary: 'app written' }), + ]); + + const agent = new AgentSession({ + client, + model: 'qwen3.5:4b', + capability: TIER_TODO, + gate: new PermissionGate({ + workspaceRoot: root, + modes, + auditLog: new AuditLog(root), + confirm: async () => true, + }), + workspaceRoot: root, + thinkingCapacity: 'medium', + sessionId: '1', + stepSessions: true, + }); + + const result = await agent.run('Set up the toolchain, then write App.jsx', { mode: 'agent' }); + + assert.deepStrictEqual( + result.todos.map((item) => item.status), + ['done', 'done'], + 'the command-only step was written off, which stopped the run' + ); + assert.ok(!/Stopped at step 1/.test(result.summary), result.summary); + }); }); diff --git a/test/unit/workspaceBootstrap.test.js b/test/unit/workspaceBootstrap.test.js new file mode 100644 index 0000000..4667068 --- /dev/null +++ b/test/unit/workspaceBootstrap.test.js @@ -0,0 +1,119 @@ +'use strict'; + +/** + * Getting a workspace ready before the first turn runs. + * + * The `.gitignore` half is the one with rules worth pinning down: it is the user's file, + * it may already say something about `.hirayacoder`, and it may not exist at all. The + * failure to avoid is not a missing entry — it is a rewritten file. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const workspaceBootstrap = require('../../app/core/workspaceBootstrap'); + +describe('workspaceBootstrap.alreadyIgnored', () => { + it('recognises every form the entry is written in', () => { + for (const line of ['.hirayacoder', '.hirayacoder/', '/.hirayacoder', '**/.hirayacoder', ' .hirayacoder/ ']) { + assert.ok(workspaceBootstrap.alreadyIgnored(`node_modules\n${line}\ndist\n`), `missed "${line}"`); + } + }); + + it('treats a negation as a decision already made', () => { + // `!.hirayacoder` is someone deliberately tracking it. Appending the positive entry + // underneath would silently overrule them. + assert.ok(workspaceBootstrap.alreadyIgnored('!.hirayacoder\n')); + }); + + it('is not fooled by a comment or by a longer name', () => { + assert.ok(!workspaceBootstrap.alreadyIgnored('# .hirayacoder\n')); + assert.ok(!workspaceBootstrap.alreadyIgnored('.hirayacoder-backup\n')); + assert.ok(!workspaceBootstrap.alreadyIgnored('')); + }); +}); + +describe('workspaceBootstrap.ensureGitignore', () => { + /** @type {string} */ + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-bootstrap-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + /** @returns {string} */ + const read = () => fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); + + it('creates a .gitignore with the one entry when there is none', () => { + assert.strictEqual(workspaceBootstrap.ensureGitignore(root), 'created'); + assert.ok(read().includes('.hirayacoder/')); + // Deliberately not guessing at node_modules or dist for a project whose language + // nobody has looked at yet. + assert.ok(!read().includes('node_modules')); + }); + + it('appends to an existing file without touching what is already in it', () => { + fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules\ndist\n', 'utf8'); + assert.strictEqual(workspaceBootstrap.ensureGitignore(root), 'appended'); + + const contents = read(); + assert.ok(contents.startsWith('node_modules\ndist\n')); + assert.ok(contents.includes('.hirayacoder/')); + }); + + it('does not join the entry onto a last line with no newline of its own', () => { + fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules', 'utf8'); + workspaceBootstrap.ensureGitignore(root); + + const lines = read().split('\n').map((line) => line.trim()).filter(Boolean); + assert.ok(lines.includes('node_modules'), 'the existing entry was corrupted'); + assert.ok(lines.includes('.hirayacoder/')); + }); + + it('keeps a CRLF file on CRLF', () => { + fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules\r\n', 'utf8'); + workspaceBootstrap.ensureGitignore(root); + assert.ok(!/[^\r]\n/.test(read()), 'a bare LF was introduced into a CRLF file'); + }); + + it('adds nothing when the entry is already there', () => { + fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules\n.hirayacoder\n', 'utf8'); + assert.strictEqual(workspaceBootstrap.ensureGitignore(root), 'present'); + assert.strictEqual(read(), 'node_modules\n.hirayacoder\n'); + }); + + it('is idempotent across repeated sessions', () => { + workspaceBootstrap.ensureGitignore(root); + workspaceBootstrap.ensureGitignore(root); + workspaceBootstrap.ensureGitignore(root); + + const occurrences = read().split('\n').filter((line) => line.trim() === '.hirayacoder/').length; + assert.strictEqual(occurrences, 1); + }); +}); + +describe('workspaceBootstrap.bootstrap', () => { + it('does nothing at all without a workspace', () => { + assert.strictEqual(workspaceBootstrap.bootstrap(null), null); + }); + + it('leaves both the profile and the ignore entry behind', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-bootstrap-')); + try { + const ready = workspaceBootstrap.bootstrap(root); + + assert.ok(ready.profile.osName.length > 0); + assert.strictEqual(ready.gitignore, 'created'); + assert.ok(fs.existsSync(path.join(root, '.hirayacoder', 'environment.json'))); + assert.ok(fs.readFileSync(path.join(root, '.gitignore'), 'utf8').includes('.hirayacoder/')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +});