Skip to content

Commit fb7057f

Browse files
committed
Cover persisted yolo default edge cases
1 parent c114203 commit fb7057f

10 files changed

Lines changed: 146 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Changed
17+
18+
- **`/yolo` persists as the user-global skip-permissions default.** Exec
19+
inherits it; `--dangerously-skip-permissions` still forces the current
20+
process. Secret-guard and authz still apply.
21+
1422
## [0.2.99] - 2026-08-21
1523

1624
Skywalker is the primary orchestrator over a closed director fleet: product write tools stay off the primary, and you cannot spawn Skywalker as a task leaf. Workers are not done until they return the four-heading report. First-party action skills ship as slashes; eval runners require an explicit provider/model pair; the style skill no longer refuses non-git folders.

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ Intent defaults: implement/explore/plan → same-named director; review → crit
166166

167167
### Auto Mode
168168

169-
Auto mode defaults **on** (`config.auto = true` from `loadConfig`; pass `--no-auto` to start off, or `--auto` to force on). It is toggled only via those CLI flags — there is currently no in-session key bound to it. The permission gate reads the flag (`getAuto`/`setAuto` in `src/permission/gate.ts`) on the next tool call. `--dangerously-skip-permissions` still forces this process. `/yolo [on|off|toggle]` (bare `/yolo` also toggles) persists as the user-global default and wires `getSkipPermissions`/`setSkipPermissions` so the gate and pre-gate sandboxes honor the change on the next tool call without rebuilding plugins. Secret-guard and authz still apply.
169+
Auto mode defaults **on** (`config.auto = true` from `loadConfig`; pass `--no-auto` to start off, or `--auto` to force on). It is toggled only via those CLI flags — there is currently no in-session key bound to it. The permission gate reads the flag (`getAuto`/`setAuto` in `src/permission/gate.ts`) on the next tool call. `--dangerously-skip-permissions` still forces this process. `/yolo [on|off|toggle]` (bare `/yolo` also toggles) persists as the user-global default and wires `getSkipPermissions`/`setSkipPermissions` so the gate and pre-gate sandboxes honor the change on the next tool call without rebuilding plugins. `/yolo` writes the same `config.globalSettingsPath` target as the other `/settings`-style toggles, including a `--config` override. Secret-guard and authz still apply.
170170

171171
When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOLS` and any `run_shell` that does not match the auto-shell policy. The policy (`autoShellRuleForCall` / `AUTO_SHELL_RULES` in `src/permission/auto-shell-policy.ts`) peels wrappers via `expandShellSubjects` (`bash`/`sh`/`zsh -c`, `xargs`, transparent prefixes), then applies:
172172

docs/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ $ corbits exec "Add JWT auth to the API"
6767
$ corbits run "Add JWT auth to the API"
6868
```
6969

70-
Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. The exec bootstrap is a deliberate fork of the TUI path (not a shared factory yet); see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set (or auto mode covers them). In the TUI, `/yolo` persists as the user-global default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply. `ask_operator` reads a single line from stdin when available.
70+
Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. The exec bootstrap is a deliberate fork of the TUI path (not a shared factory yet); see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set, a persisted `/yolo` default is on, or auto mode covers them. `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply. `ask_operator` reads a single line from stdin when available.
7171

7272
Local multi-model capability checks use this path (`bun run eval:capability`); see `evals/capability/README.md`.
7373

src/config.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,33 @@ describe("loadConfig", () => {
479479
}
480480
});
481481

482+
test("settings dangerouslySkipPermissions false without the CLI flag stays false", async () => {
483+
const cwd = await emptyCwd();
484+
try {
485+
const globalPath = join(cwd, "global.json");
486+
await writeFile(
487+
globalPath,
488+
JSON.stringify({
489+
defaultProvider: "fireworks",
490+
providers: {
491+
fireworks: {
492+
baseURL: "https://api.fireworks.ai/inference",
493+
apiKey: "test-key",
494+
models: ["accounts/fireworks/routers/kimi-k2p6-turbo"],
495+
},
496+
},
497+
dangerouslySkipPermissions: false,
498+
}),
499+
);
500+
const config = await loadConfig(["--cwd", cwd, "do something"], {
501+
globalSettingsPath: globalPath,
502+
});
503+
expect(config.dangerouslySkipPermissions).toBe(false);
504+
} finally {
505+
await rm(cwd, { recursive: true, force: true });
506+
}
507+
});
508+
482509
test("CLI --dangerously-skip-permissions still wins over settings false", async () => {
483510
const cwd = await emptyCwd();
484511
try {

src/config/settings.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,18 @@ export async function saveGlobalSettings(path: string, settings: Settings): Prom
10161016
await rename(tmp, path);
10171017
}
10181018

1019+
// Silent helper: callers log or notice. Skips when the write base is null so a
1020+
// corrupt settings file is never replaced with a one-key rewrite.
1021+
export async function persistSkipPermissionsDefault(
1022+
path: string,
1023+
value: boolean,
1024+
): Promise<"ok" | "skipped"> {
1025+
const base = await loadGlobalSettingsWriteBase(path);
1026+
if (base === null) return "skipped";
1027+
await saveGlobalSettings(path, { ...base, dangerouslySkipPermissions: value });
1028+
return "ok";
1029+
}
1030+
10191031
// Stamp the global `onboarded` flag. Reads the on-disk global settings fresh
10201032
// (never an in-memory Settings that may carry injected OAuth provider entries
10211033
// with short-lived access tokens) and re-saves with onboarded set. When the

src/settings.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from "bun:test";
2-
import { chmod, mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
2+
import { chmod, mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join, dirname } from "node:path";
55

@@ -24,6 +24,7 @@ import {
2424
validateTaskMaxTurns,
2525
toolWatchdogFromSettings,
2626
loadGlobalSettingsWriteBase,
27+
persistSkipPermissionsDefault,
2728
markLastChangelogVersion,
2829
pushRecentModel,
2930
toggleFavoriteModel,
@@ -739,6 +740,56 @@ describe("loaders", () => {
739740
});
740741
});
741742

743+
describe("persistSkipPermissionsDefault", () => {
744+
test("writes true", async () => {
745+
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
746+
try {
747+
const path = join(dir, "settings.json");
748+
await saveGlobalSettings(path, firepass);
749+
expect(await persistSkipPermissionsDefault(path, true)).toBe("ok");
750+
expect(await loadSettings(path)).toEqual({
751+
...firepass,
752+
dangerouslySkipPermissions: true,
753+
});
754+
} finally {
755+
await rm(dir, { recursive: true, force: true });
756+
}
757+
});
758+
759+
test("writes false", async () => {
760+
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
761+
try {
762+
const path = join(dir, "settings.json");
763+
await saveGlobalSettings(path, { ...firepass, dangerouslySkipPermissions: true });
764+
expect(await persistSkipPermissionsDefault(path, false)).toBe("ok");
765+
expect(await loadSettings(path)).toEqual({
766+
...firepass,
767+
dangerouslySkipPermissions: false,
768+
});
769+
} finally {
770+
await rm(dir, { recursive: true, force: true });
771+
}
772+
});
773+
774+
test("skips invalid or unreadable settings and leaves the file unchanged", async () => {
775+
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
776+
try {
777+
const path = join(dir, "settings.json");
778+
const garbage = "{ not json";
779+
await writeFile(path, garbage);
780+
expect(await persistSkipPermissionsDefault(path, true)).toBe("skipped");
781+
expect(await readFile(path, "utf8")).toBe(garbage);
782+
783+
const wrongShape = JSON.stringify({ providers: "wrong-shape" });
784+
await writeFile(path, wrongShape);
785+
expect(await persistSkipPermissionsDefault(path, true)).toBe("skipped");
786+
expect(await readFile(path, "utf8")).toBe(wrongShape);
787+
} finally {
788+
await rm(dir, { recursive: true, force: true });
789+
}
790+
});
791+
});
792+
742793
describe("sessionMode", () => {
743794
test("loadSettings drops legacy single sessionMode", async () => {
744795
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
@@ -791,6 +842,8 @@ test("loadSettings round-trips dangerouslySkipPermissions", async () => {
791842
const path = join(dir, ".corbits", "settings.json");
792843
await saveGlobalSettings(path, { ...firepass, dangerouslySkipPermissions: true });
793844
expect(await loadSettings(path)).toEqual({ ...firepass, dangerouslySkipPermissions: true });
845+
await saveGlobalSettings(path, { ...firepass, dangerouslySkipPermissions: false });
846+
expect(await loadSettings(path)).toEqual({ ...firepass, dangerouslySkipPermissions: false });
794847
} finally {
795848
await rm(dir, { recursive: true, force: true });
796849
}

src/tui/commands/built-in.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,17 @@ describe("/yolo command", () => {
8383
};
8484
expect(getCommand("yolo")!.handler("", ctx)).toEqual({
8585
type: "message",
86-
text: "Yolo mode on — permission prompts skipped.",
86+
text: "Yolo mode on — permission prompts skipped. Saved as the default.",
8787
});
8888
expect(skip).toBe(true);
8989
expect(getCommand("yolo")!.handler("", ctx)).toEqual({
9090
type: "message",
91-
text: "Yolo mode off — permission prompts restored.",
91+
text: "Yolo mode off — permission prompts restored. Saved as the default.",
9292
});
9393
expect(skip).toBe(false);
9494
expect(getCommand("yolo")!.handler("toggle", ctx)).toEqual({
9595
type: "message",
96-
text: "Yolo mode on — permission prompts skipped.",
96+
text: "Yolo mode on — permission prompts skipped. Saved as the default.",
9797
});
9898
expect(skip).toBe(true);
9999
});
@@ -109,12 +109,12 @@ describe("/yolo command", () => {
109109
};
110110
expect(getCommand("yolo")!.handler("on", ctx)).toEqual({
111111
type: "message",
112-
text: "Yolo mode on — permission prompts skipped.",
112+
text: "Yolo mode on — permission prompts skipped. Saved as the default.",
113113
});
114114
expect(skip).toBe(true);
115115
expect(getCommand("yolo")!.handler("off", ctx)).toEqual({
116116
type: "message",
117-
text: "Yolo mode off — permission prompts restored.",
117+
text: "Yolo mode off — permission prompts restored. Saved as the default.",
118118
});
119119
expect(skip).toBe(false);
120120
});

src/tui/commands/built-in.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,13 @@ export function registerBuiltInCommands(): void {
217217
if (next) {
218218
return {
219219
type: "message",
220-
text: "Yolo mode on — permission prompts skipped.",
220+
text: "Yolo mode on — permission prompts skipped. Saved as the default.",
221221
};
222222
}
223-
return { type: "message", text: "Yolo mode off — permission prompts restored." };
223+
return {
224+
type: "message",
225+
text: "Yolo mode off — permission prompts restored. Saved as the default.",
226+
};
224227
},
225228
});
226229

src/tui/commands/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export type CommandContext = {
2525
beginFeedbackCapture?: () => void;
2626
/** Whether skip-permissions (yolo) is active for this session. */
2727
getSkipPermissions?: () => boolean;
28-
/** Toggle skip-permissions for the rest of the session (`/yolo`). */
28+
/** Live-flip skip-permissions and persist `/yolo` as the user-global default. */
2929
setSkipPermissions?: (value: boolean) => void;
3030
};
3131

src/tui/runner.ts

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
loadSettings,
2929
localSettingsPath,
3030
markTelemetryNoticeShown,
31+
persistSkipPermissionsDefault,
3132
pushRecentModel,
3233
saveGlobalSettings,
3334
saveLocalSettings,
@@ -1846,38 +1847,47 @@ export async function runTUI(initialConfig: Config): Promise<number> {
18461847
});
18471848
}
18481849

1850+
// One tail for every RMW of config.globalSettingsPath from this runner so
1851+
// /yolo and /settings toggles cannot stale-RMW each other.
1852+
let persistTail = Promise.resolve();
1853+
const enqueueGlobalPersist = <T>(job: () => Promise<T>): Promise<T> => {
1854+
const run = persistTail.then(job);
1855+
persistTail = run.then(() => undefined, () => undefined);
1856+
return run;
1857+
};
1858+
18491859
// Absent file → fresh base; unreadable/invalid → skip the write rather than
1850-
// clobber a corrupt settings file with a minimal shell. Returns false when
1851-
// the write is skipped so `/yolo` can notice that the live flip did not persist.
1852-
const persistGlobalSettings = async (
1860+
// clobber a corrupt settings file with a minimal shell.
1861+
const persistGlobalSettings = (
18531862
what: string,
18541863
apply: (base: Settings) => Settings,
1855-
): Promise<boolean> => {
1856-
const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath);
1857-
if (base === null) {
1858-
tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", {
1859-
what,
1860-
path: config.globalSettingsPath,
1861-
});
1862-
return false;
1863-
}
1864-
await saveGlobalSettings(config.globalSettingsPath, apply(base));
1865-
return true;
1866-
};
1864+
): Promise<boolean> =>
1865+
enqueueGlobalPersist(async () => {
1866+
const base = await loadGlobalSettingsWriteBase(config.globalSettingsPath);
1867+
if (base === null) {
1868+
tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", {
1869+
what,
1870+
path: config.globalSettingsPath,
1871+
});
1872+
return false;
1873+
}
1874+
await saveGlobalSettings(config.globalSettingsPath, apply(base));
1875+
return true;
1876+
});
18671877

18681878
const commandContext: CommandContext = {
18691879
signalClear: newSession,
18701880
getSkipPermissions: () => permissionGate.getSkipPermissions(),
18711881
setSkipPermissions: (value: boolean) => {
18721882
permissionGate.setSkipPermissions(value);
18731883
config.dangerouslySkipPermissions = value;
1874-
void (async () => {
1884+
void enqueueGlobalPersist(async () => {
18751885
try {
1876-
const written = await persistGlobalSettings("skip-permissions default", (base) => ({
1877-
...base,
1878-
dangerouslySkipPermissions: value,
1879-
}));
1880-
if (!written) {
1886+
const result = await persistSkipPermissionsDefault(
1887+
config.globalSettingsPath,
1888+
value,
1889+
);
1890+
if (result === "skipped") {
18811891
systemNotice(
18821892
"Yolo flipped for this session, but the default did not stick.",
18831893
);
@@ -1887,7 +1897,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
18871897
"Yolo flipped for this session, but the default did not stick.",
18881898
);
18891899
}
1890-
})();
1900+
});
18911901
},
18921902
getCostSummary: (): CostSummary => {
18931903
const usage = runSink.getTokenUsage();

0 commit comments

Comments
 (0)