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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ release gates. Stable promotion and publication require approval of the exact te

- Automatically continue the original agent thread after approving a server for a hidden variable; no manual “continue” message is needed.

- Redact hidden proxy placeholders from detached background-shell logs before persistence,
while keeping explicitly Readable configuration visible in job output.

- Clarify that the network tool's policy is advisory in `/sudo` direct-host threads, with no
OS-level egress cutoff. Ordinary proxy-mode container enforcement is unchanged.

Expand Down
3 changes: 2 additions & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1557,7 +1557,8 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks.
pending product decision, not shipped).
- **Secret handling:** masked dialogs collect password proof. Known credential values are redacted
from primary/fallback replies, stream events, errors and background delivery checkpoints. Shell
jobs also redact injected channel secrets inside their detached container process before stdout
jobs also redact injected channel secrets and their hidden proxy placeholders inside their
detached container process before stdout
or stderr reaches the durable artifact log. Independent stream buffers protect split values,
and the wrapper survives the daemon while preserving the command's terminal status. The wrapper
receives secret names only; jobs do not inherit engine service credentials. Existing historical
Expand Down
6 changes: 5 additions & 1 deletion TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -5029,7 +5029,11 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated
explicit exits 17/23 remain recoverable through the terminal marker. Short final tails survive.
- [x] Runtime integration: `test/runtime-integration-jobs.test.js` confirms the isolated spawn
receives only secret names in wrapper arguments and values through its existing environment,
keeps its container run ID/lease, and uses the same durable recovery path.
keeps its container run ID/lease, and uses the same durable recovery path. The 2026-11-06 proxy
regression starts a real `BackgroundJobs` shell through the egress resolver and actual wrapper:
full and interleaved split hidden placeholders are redacted in raw logs, status and continuation
input, while explicit Readable configuration survives. The lease releases, exit status remains
truthful, and exactly one continuation retires the durable row.
- [x] Candidate wrapper executed directly as a detached exec in an owned live container with a
disposable random canary: raw artifact bytes contained exactly two `[REDACTED]` markers and a
recoverable exit 23. This wrapper-level check does not substitute for the engine-origin gate.
Expand Down
6 changes: 4 additions & 2 deletions src/gateway/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,10 @@ export class BackgroundJobs {
child = target.runtime.spawn(target, {
cmd: "bash",
// Isolated: a runtime-side wrapper redacts BOTH streams before writing the durable
// log, and starts the command in one login shell. The outer shell only execs the wrapper.
args: isolatedJob ? ["-c", containerJobScript(cmd, logFile, Object.keys(jobEnv).filter((name) => jobSecretValues.includes(jobEnv[name])))] : ["-lc", cmd],
// log, and starts the command in one login shell. Hidden proxy placeholders are selected
// by resolver metadata: their bytes differ from the real redaction values. Readable
// configuration has no placeholder and remains shareable. The outer shell only execs the wrapper.
args: isolatedJob ? ["-c", containerJobScript(cmd, logFile, Object.keys(jobEnv).filter((name) => Object.hasOwn(jobRunEnv.placeholders, name) || jobSecretValues.includes(jobEnv[name])))] : ["-lc", cmd],
cwd,
env: buildShellEnv(shellEnv),
stdio: ["ignore", "pipe", "pipe"],
Expand Down
66 changes: 66 additions & 0 deletions test/runtime-integration-jobs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// restart can only be found again through the backend — its pid belonged to a client process that
// died with the old daemon.
import path from "node:path";
import { randomBytes } from "node:crypto";
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
import test from "node:test";
import assert from "node:assert/strict";
Expand Down Expand Up @@ -191,3 +192,68 @@ test("a container job in Auto mode starts without a card; an unisolated one stil
assert.equal(hostCard.approvalType, "agent");
assert.match(hostRefusal.error, /run outside every container/);
});

test("proxy-mode background jobs redact hidden placeholders before persistence and preserve readable config", async () => {
const quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
const backend = createFakeRuntimeBackend({ egress: { active: true, mode: "proxy", network: "none" } });
const channelId = "C_JOB_PROXY_REDACT";
const { entry, meta } = await autoChannel(channelId, "job-proxy-redact");
const hidden = "synthetic-hidden-" + randomBytes(16).toString("hex");
const readable = "intentionally-readable-" + randomBytes(8).toString("hex");
await saveChannelMeta(entry.slug, { ...meta, env: {
QA_HIDDEN_RULED: { provider: "local", value: hidden, exposure: "hidden", hosts: ["example.com"] },
QA_READABLE_CONFIG: { provider: "local", value: readable, exposure: "readable" },
} });
const continuations = [], delivered = [];
const jobs = new BackgroundJobs({
resolveTarget: (slug, currentMeta) => fakeTarget(backend, slug, currentMeta),
runner: async ({ text }) => {
continuations.push(text);
return { content: "safe fixture summary", engine: "claude" };
},
deliver: async (_client, { result }) => { delivered.push(result.content); },
});
// Keep completion pending until its durable log and status have both been inspected.
jobs._client = () => null;
const source = `const s=process.env.QA_HIDDEN_RULED;
process.stdout.write('full '+s+'\\n');
process.stdout.write('out '+s.slice(0,12));
setTimeout(()=>process.stderr.write('err '+s.slice(0,17)),20);
setTimeout(()=>process.stdout.write(s.slice(12)+' end\\n'),40);
setTimeout(()=>process.stderr.write(s.slice(17)+' end\\n'),60);
setTimeout(()=>process.stdout.write('visible '+process.env.QA_READABLE_CONFIG+'\\n'),80);`;
const started = await jobs.start({
channelId, authorId: "U_JOB", threadKey: "proxy-redaction", label: "safe synthetic integration",
command: "node -e " + quote(source),
});
assert.equal(started.ok, true, started.error);
const rec = jobs.jobs.get(started.id);
const spec = backend.calls.spawn.at(-1);
const placeholder = spec.env.QA_HIDDEN_RULED;
assert.notEqual(placeholder, hidden, "the actual resolver replaces hidden values in proxy mode");
assert.match(placeholder, /^cgph_/);
assert.equal(spec.env.QA_READABLE_CONFIG, readable);
const deadline = Date.now() + 12_000;
while (!rec.pendingDelivery && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 25));
assert.ok(rec.pendingDelivery, "the actual wrapper must finish and checkpoint completion");
const raw = readFileSync(rec.logFile, "utf8");
const status = jobs.status(started.id);
jobs._client = () => ({ platform: "slack", post: async () => ({ ok: true }) });
await jobs._deliver(rec);

// Check booleans so a failing assertion cannot print the disposable credential or placeholder.
for (const text of [raw, status.tail, continuations.join("\n")]) {
assert.equal(text.includes(hidden), false, "no real hidden value reaches output");
assert.equal(text.includes(placeholder), false, "no hidden placeholder reaches output");
assert.equal(text.includes(placeholder.slice(0, 12)), false, "interleaved writes cannot expose a held prefix");
assert.equal(text.includes(readable), true, "explicit readable configuration remains shareable");
assert.equal((text.match(/\[REDACTED\]/g) || []).length, 3);
}
assert.ok(spec.args[1].includes('["QA_HIDDEN_RULED"]'), "the wrapper receives the hidden env name");
assert.equal(spec.args.join("").includes(hidden) || spec.args.join("").includes(placeholder), false);
assert.equal(parseContainerJobExit(raw), 0);
assert.equal(continuations.length, 1);
assert.deepEqual(delivered, ["safe fixture summary"]);
assert.equal(persistedRow(started.id), null, "successful continuation retires the durable row");
assert.ok(backend.calls.leases.every((lease) => lease.released));
});
Loading