Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/headless-run-honest-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@kilocode/cli": patch
---

Non-interactive `kilo run` no longer reports success for runs that did not complete. A plain
headless run (neither `--auto` nor `--dangerously-skip-permissions`) in which the CLI
auto-rejected at least one permission ask now exits 1 with a stderr diagnostic naming the cause,
and a run whose session errors mid-stream now prints that diagnostic to stderr under
`--format json` as well (previously the JSON branch swallowed it). Runs that complete their turn
with no auto-rejected permission still exit 0. Under `--format json` the auto-reject path adds a
new `error` event to the stream; existing event shapes are unchanged. The same exit-1 rule applies
to a plain non-interactive `--attach` run that auto-rejects an ask (that run was equally crippled);
interactive mode is untouched.
7 changes: 6 additions & 1 deletion .github/docs-sync/edit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile}
break
}

// Headless `kilo run` auto-rejects every permission ask; without --auto the
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
// to an agent steered by external PR content. Hardening deferred: a scoped
// permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the
// required shell patterns are stable (see PR #12605 review thread).
const result = runKilo({
args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
streamStdout: true,
label: `edit batch ${index} attempt ${attempt}`,
Expand Down
66 changes: 62 additions & 4 deletions .github/docs-sync/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -167,20 +167,64 @@ export function sleepSync(ms) {
const STDERR_TAIL_LINES = 20
const STDERR_TAIL_CHARS = 4_000

// CSI sequences (colour, cursor moves, erases). kilo renders its TUI to stderr,
// so an unstripped tail lands in the rolling PR's pending table as
// "^[[0m→ ^[[0mRead packages/..." and the cause is unreadable. Stripped before
// the line/char slice so escapes do not eat the budget. The persisted
// docs-sync-out/kilo-stderr-*.log stays raw — that is the debugging record.
// eslint-disable-next-line no-control-regex
const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g

function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) {
const s = String(text ?? "").trim()
const s = String(text ?? "")
.replace(ANSI_CSI, "")
.trim()
if (!s) return ""
const lastLines = s.split("\n").slice(-lines).join("\n")
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
}

/**
* Artifact files are raw: GitHub masks secret values in log streams only, and the runner
* env holds long-lived secrets (KILO_API_KEY), so exact values of secret-looking env vars
* are redacted before stdout/stderr is persisted or printed.
* Matching is exact-substring and case-sensitive on values — JSON-escaped, base64'd, or
* line-wrapped renderings and values shorter than 8 chars survive (same limitation as
* GitHub's own log masking); this is defense-in-depth, not a guarantee the logs are clean.
*/
export function redactEnvSecrets(text) {
let out = String(text ?? "")
// Also match CREDENTIAL/PASSWORD/ORG_ID/_PAT (e.g. KILO_ORG_ID, GH_PAT) beyond KEY|TOKEN|SECRET.
const nameRe = /KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|ORG_ID|_PAT$/i
const candidates = []
for (const [name, value] of Object.entries(process.env)) {
if (!nameRe.test(name)) continue
if (typeof value !== "string" || value.length < 8) continue
candidates.push(value)
}
// Longer values first so a shorter secret that is a prefix of a longer one cannot leave a remainder.
candidates.sort((a, b) => b.length - a.length)
for (const value of candidates) {
if (!out.includes(value)) continue
out = out.split(value).join("***")
}
return out
}

/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */
const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024

/**
* Run `kilo` via spawnSync so stderr is always recoverable — including when
* the child exits 0 after writing a diagnostic (execFileSync cannot return
* piped stderr on exit 0; that path lost every diagnostic on run 30122603016).
*
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
* (triage parses it). stderr is always buffered.
*
* Always writes the full captured stderr to
* docs-sync-out/kilo-stderr-<sanitized-label>.log (unconditional — success and
* failure). The console return value still uses the short tailText.
*/
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
const result = spawnSync("kilo", args, {
Expand All @@ -193,16 +237,30 @@ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo"
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
const exitCode =
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
const stderrTail = tailText(result.stderr)
const stdout = streamStdout ? "" : String(result.stdout ?? "")
const stderrRaw = String(result.stderr ?? "")
const stderrSafe = redactEnvSecrets(stderrRaw)
const stderrTail = tailText(stderrSafe)
const stdoutSafe = streamStdout ? "" : redactEnvSecrets(String(result.stdout ?? ""))
// ok is "process finished without OS-level failure". Callers still treat a
// missing summary / unparseable output as failure even when ok is true —
// exit 0 is not success for the docs-sync bot.
const ok = !result.error && result.status === 0

// Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is
// generous (megabytes) so long batch dumps keep auto-rejecting lines; console
// still uses the short tail above.
try {
fs.mkdirSync("docs-sync-out", { recursive: true })
const safe = label.replace(/[^A-Za-z0-9._-]/g, "-")
const body = stderrSafe.length > STDERR_LOG_MAX_CHARS ? stderrSafe.slice(-STDERR_LOG_MAX_CHARS) : stderrSafe
fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body)
Comment thread
iscekic marked this conversation as resolved.
} catch (err) {
console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`)
}

if (result.error && !timedOut) {
console.warn(`${label}: spawn error: ${result.error.message}`)
}

return { ok, stdout, stderrTail, exitCode, timedOut }
return { ok, stdout: stdoutSafe, stderrTail, exitCode, timedOut }
}
25 changes: 25 additions & 0 deletions .github/docs-sync/redact-stream.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// kilocode_change - new file

/**
* Line-wise stdin→stdout filter that redacts secret-looking env values.
* Used in the docs-sync workflow so kilo stdout piped to edit-log.txt is safe.
* Env values contain no newlines, so line-wise processing never splits a value.
*/

import { redactEnvSecrets } from "./lib.mjs"

let carry = ""

process.stdin.setEncoding("utf8")
process.stdin.on("data", (chunk) => {
carry += chunk
let idx
while ((idx = carry.indexOf("\n")) !== -1) {
const line = carry.slice(0, idx + 1)
carry = carry.slice(idx + 1)
process.stdout.write(redactEnvSecrets(line))
}
})
process.stdin.on("end", () => {
if (carry.length > 0) process.stdout.write(redactEnvSecrets(carry))
})
Loading
Loading