Bypass mode (owner opt-in, default off): storebound settings bypass on|off|status and a one-time question in init; MCP owner_step (owner-only commands, dry run first), owner-only api operations with --yes --confirm, console plans may press buttons.final; every action logged to .storebound/owner-actions.log; docs/bypass.md, agent rules, tests

This commit is contained in:
CRTkafa 2026-10-22 02:27:34 +08:00
parent 2598c4953a
commit 9bd17c401d
21 changed files with 218 additions and 13 deletions

View file

@ -20,6 +20,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that

View file

@ -20,6 +20,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that

View file

@ -21,6 +21,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that

View file

@ -2,6 +2,12 @@
## Unreleased
- **Bypass mode (owner opt-in, default off).** `storebound settings bypass on` (or "y" in `storebound init`) lets the AI
agent press the owner-only buttons too: MCP `owner_step` (submit, release, rollout, testers, replies, prices, ...;
dry run first), owner-only `storebound api` operations with `--yes --confirm <operationId>`, console plans'
`buttons.final`. Every action is logged to `.storebound/owner-actions.log`; `bypass off` restores the old behaviour.
docs/bypass.md.
- **Build dependencies in `doctor` and the desktop Dependencies screen.** JDK (with the version the project needs from
the Android Gradle plugin / jvmToolchain, "too old" when below it), Android SDK command-line tools, CocoaPods, Node.js,
Bun and bundletool join ffmpeg, Chrome, Xcode, adb and git. `storebound doctor --install <tool> --yes` runs this OS's

View file

@ -19,6 +19,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that

View file

@ -10,6 +10,7 @@
// owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments: cli/api/gate.ts)
// also need the operation id typed at the prompt. Under MCP or the desktop app no write is ever sent.
import { readFileSync, writeFileSync } from 'node:fs';
import { bypassOn } from './lib/bypass';
import { resolve } from 'node:path';
import type { Args } from './lib/args';
import { allOps, authFor, type BuiltRequest, buildRequest, resolveOp, send, sendAll } from './api/client';
@ -145,7 +146,7 @@ export async function run(args: Args): Promise<void> {
kind, method: op.method, dry: args.has('dry'), yes: args.has('yes'), getOnly,
tty: stdinIsTTY() && !!process.stdout.isTTY,
agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1',
confirm: args.get('confirm'), opId: op.id,
confirm: args.get('confirm'), opId: op.id, bypass: bypassOn(),
});
if (decision.action === 'refuse') {
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;

View file

@ -83,6 +83,8 @@ export interface GateInput {
confirm?: string;
/** the operation id the call resolved to (what --confirm must equal) */
opId?: string;
/** the owner turned bypass mode on (storebound settings bypass on): owner-only operations take --confirm too */
bypass?: boolean;
}
export type Decision =
@ -102,16 +104,17 @@ export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does n
export function decide(g: GateInput): Decision {
if (g.getOnly && g.method.toUpperCase() !== 'GET') return { action: 'refuse', why: 'api get runs GET operations only' };
if (g.kind === 'read') return g.dry ? { action: 'dry', why: '--dry' } : { action: 'send' };
if (g.agent) return { action: 'refuse', why: g.kind === 'owner'
if (g.agent && !g.bypass) return { action: 'refuse', why: g.kind === 'owner'
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
if (g.confirm !== undefined) {
// an agent's typed confirmation: only for plain writes, only when it names this exact operation
if (g.kind === 'owner') return { action: 'refuse', why: OWNER_NO_CONFIRM };
if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM };
if (!g.opId || g.confirm !== g.opId) return { action: 'refuse', why: `refused: --confirm ${g.confirm} does not equal the operation id ${g.opId ?? '?'}. Nothing was sent.` };
return { action: 'send' };
}
if (g.agent) return { action: 'refuse', why: 'bypass mode: an agent sends writes with --yes --confirm <operationId>' };
if (!g.tty) return { action: 'refuse', why: NOT_A_TTY };
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
}

View file

@ -15,6 +15,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app'],
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile> | default <profile> (the machine default; --clear)'],
'settings': [() => import('./settings'), 'per-machine settings: bypass on | off | status (owner opt-in: the agent also presses the owner-only buttons; default off)'],
'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install <tool> --yes; --json'],
'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'],
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],

View file

@ -797,6 +797,7 @@
"serve": [
"stdio"
],
"settings": [],
"shots capture": [
"concurrency",
"device",
@ -1504,6 +1505,9 @@
"storebound scan --write-only [--dry] only that write (with --dry: the config diff), no report (desktop Fix button)",
"storebound scan --originality [--against <folders>] [--threshold 0.7] also the 4.3 / Play repetitive content"
],
"settings": [
"storebound settings bypass on | off | status owner opt-in: the agent also presses the owner-only buttons"
],
"shots capture": [
"storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder]"
],

View file

@ -6,11 +6,13 @@
// App Privacy answers, Data safety section, listing copy and screenshot copy; every answer is a starting point to edit.
// Lookup per file: templates/profiles/<profile>/<file>, then the profile it builds on, then templates/<file>.
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { bypassOn, readSettings, setBypass } from './lib/bypass';
import { stdinIsTTY } from './lib/tty';
import { dirname, join, relative, resolve } from 'node:path';
import type { Args } from './lib/args';
import { type AppProfile, PROFILES } from './lib/config';
import { ignoreInRepo } from './lib/git-ignore';
import { log } from './lib/log';
import { log, promptOut } from './lib/log';
import { TEMPLATE_TEXT } from './lib/template-files';
import * as rt from './lib/rt';
@ -74,5 +76,12 @@ export async function run(args: Args): Promise<void> {
// store art (rendered or pulled from the stores) is never committed
const art = ignoreInRepo(join(root, 'store', 'renders'), 'storebound: store art (rendered or pulled from the stores), never committed');
if (art) log('init', `.gitignore: added ${art}`);
// bypass mode is asked once per machine, only of a person at a terminal (default no); agents never answer it
if (readSettings().bypass === undefined && stdinIsTTY() && process.stdout.isTTY && !args.has('yes')) {
promptOut().write('Bypass mode: let your AI agent also press the owner-only buttons (Submit for review, releases, rollouts, testers, replies, prices, console Create)? Every action is logged; turn it off any time with storebound settings bypass off. [y/N] ');
const a = await rt.readLine();
setBypass(a !== null && /^y(es)?$/i.test(a.trim()));
log('init', `bypass mode: ${bypassOn() ? 'ON' : 'off'} (storebound settings bypass on | off)`);
}
log('init', 'next: edit storebound.config.ts, store/*.md and .storebound/credentials.json, then `storebound check`; store/workflows/*.yml: storebound flow list');
}

54
cli/lib/bypass.ts Normal file
View file

@ -0,0 +1,54 @@
// Bypass mode: the owner's opt-in to let an AI agent press the owner-only buttons too (Submit for review, releases and
// rollouts, testers, review replies, prices, deletes, cancels, console final buttons). Default OFF. Stored per machine
// in ~/.storebound/settings.json, never in a project's config or repo:
// storebound settings bypass on | off | status
// When ON: the MCP server lists `owner_step` (runs one owner command, dry run first), `storebound api` sends owner-only
// operations with --yes --confirm <operationId>, console plans tell the agent it may press buttons.final. Every owner
// action an agent runs is appended to <project>/.storebound/owner-actions.log. `bypass off` restores the old behaviour
// at once. An AI client may still ask the owner itself for legal agreements, passwords and 2FA (its own rules).
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
export interface Settings { bypass?: { on: boolean; since: string } }
export const settingsFile = (env: Record<string, string | undefined> = process.env): string =>
join(env.STOREBOUND_HOME ?? join(homedir(), '.storebound'), 'settings.json');
export function readSettings(env: Record<string, string | undefined> = process.env): Settings {
try { return JSON.parse(readFileSync(settingsFile(env), 'utf8')) as Settings; } catch { return {}; }
}
export function writeSettings(s: Settings, env: Record<string, string | undefined> = process.env): void {
const f = settingsFile(env);
mkdirSync(dirname(f), { recursive: true });
writeFileSync(f, `${JSON.stringify(s, null, 2)}\n`);
}
/** Whether the owner turned bypass mode on for this machine. */
export const bypassOn = (env: Record<string, string | undefined> = process.env): boolean => readSettings(env).bypass?.on === true;
export function setBypass(on: boolean, env: Record<string, string | undefined> = process.env): Settings {
const s = readSettings(env);
s.bypass = { on, since: new Date().toISOString() };
writeSettings(s, env);
return s;
}
/** Owner-only commands `owner_step` may run in bypass mode (CLI words). Everything else has its own MCP tool. */
export const OWNER_COMMANDS = [
'apple submit', 'apple resubmit', 'apple iap-submit', 'apple release', 'apple preorder',
'play release', 'play rollout',
'reviews', 'apple testflight', 'play products', 'play subscriptions', 'apple iap', 'apple subscriptions', 'prices',
] as const;
export type OwnerCommand = typeof OWNER_COMMANDS[number];
export const BYPASS_NOTICE = 'bypass mode is ON (storebound settings bypass off turns it off): the agent runs owner steps itself; each one is logged to .storebound/owner-actions.log';
/** One line per owner action an agent ran: when, what, the result. */
export function logOwnerAction(root: string, entry: { command: string; dry: boolean; exit: number; summary?: string }): string {
const f = join(root, '.storebound', 'owner-actions.log');
if (!existsSync(dirname(f))) mkdirSync(dirname(f), { recursive: true });
appendFileSync(f, `${new Date().toISOString()}\t${entry.dry ? 'dry' : 'RAN'}\texit ${entry.exit}\t${entry.command}${entry.summary ? `\t${entry.summary.replace(/\s+/g, ' ').slice(0, 300)}` : ''}\n`);
return f;
}

View file

@ -191,7 +191,7 @@ const DRIVING = [
'Text fields: click the field and TYPE the text (key presses), then read the value back. Never set it programmatically (form_input, el.value = …): App Store Connect\'s New App kept its Name and SKU empty after a value set (verified 2026-10-13).',
'Owner steps: hand the owner plan.ownerBatch in ONE message before you start (the "before" items: answers, the IARC Terms of Use box) and run without stopping until the "after" items (the final button).',
'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.',
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.',
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command. (Bypass mode on, storebound settings bypass: press buttons.final yourself after the owner\'s standing opt-in; agreements, passwords and 2FA still go to the owner.)',
'Never sign in (never type the owner\'s own password), accept an agreement or tick a declaration: the owner does that in the browser. The one password you type is the App Review demo account from .storebound/credentials.json (reviewDemoAccount) on Play\'s Sign in details card, never printed.',
];

View file

@ -46,6 +46,7 @@ export const DESKTOP_COVERAGE: Record<string, Coverage> = {
'import': { where: 'App folders / first-run guide: Import from fastlane', methods: ['host:project_import_fastlane'] },
'install': { where: `${T}: Install for an AI tool`, methods: ['agent.install'] },
'mcp': { not: 'the stdio server for AI tools; the window itself is the GUI over the same commands' },
'settings': { not: 'per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway' },
'serve': { not: 'the JSON-RPC sidecar the window runs on; nothing to click' },
'apps': { where: 'Accounts (store apps per account, key check) and App folders (list, add, remove)', methods: ['host:store_apps_refresh', 'host:account_check', 'host:projects_list', 'host:project_add', 'host:project_remove'] },
'desktop': { not: 'opens or installs this window from a terminal' },

View file

@ -2,6 +2,7 @@
// Rules: one tool per CLI command; every tool that writes anything has `dry_run` defaulting to true; nothing that
// submits for review, releases to the public, resumes / completes a rollout, cancels a submission or deletes is
// exposed. Those stay terminal commands the owner runs (they also ask for confirmation there).
import { OWNER_COMMANDS } from './lib/bypass';
export interface JsonSchema { type: string; description?: string; enum?: (string | number)[]; default?: unknown; items?: JsonSchema; minimum?: number; maximum?: number; pattern?: string; additionalProperties?: JsonSchema | boolean }
@ -479,8 +480,17 @@ export const OUTPUT_SCHEMA = {
required: ['exitCode', 'command', 'output'],
} as const;
export function listTools(): Record<string, unknown>[] {
return TOOLS.map(t => ({
/** Bypass mode only (storebound settings bypass on): one owner-only command, dry run by default. */
export const OWNER_STEP: ToolDef = {
name: 'owner_step', title: 'Owner step (bypass mode)', write: true, destructive: true, network: true,
description: `Bypass mode is on: run one owner-only command yourself (${OWNER_COMMANDS.join(', ')}). Pass the CLI words as command and the rest as args (ids, build numbers, --track, --percent ...). Always call it with dry_run: true first and read the plan; then dry_run: false runs it with --yes. Every call is logged to .storebound/owner-actions.log. Legal agreements, passwords and 2FA stay with the owner.`,
command: [], props: { command: { type: 'string', enum: [...OWNER_COMMANDS], description: 'The owner-only command' }, args: { type: 'array', items: { type: 'string' }, description: 'Its arguments and options' } },
required: ['command'],
args: i => [...String(i.command).split(' '), ...(Array.isArray(i.args) ? i.args.map(String) : [])],
};
export function listTools(bypass = false): Record<string, unknown>[] {
return [...TOOLS, ...(bypass ? [OWNER_STEP] : [])].map(t => ({
name: t.name, title: t.title, description: t.description,
inputSchema: inputSchema(t), outputSchema: OUTPUT_SCHEMA,
annotations: { title: t.title, readOnlyHint: !t.write, destructiveHint: t.write ? !!t.destructive : false, idempotentHint: !!t.idempotent || !t.write, openWorldHint: t.network },

View file

@ -25,7 +25,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import pkg from '../package.json' with { type: 'json' };
import { runCommand, type Running } from './lib/runner';
import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools';
import { listTools, OWNER_STEP, TOOLS, toArgv, validateInput } from './mcp-tools';
import { bypassOn, logOwnerAction } from './lib/bypass';
import * as rt from './lib/rt';
/** Current (modern, stateless) revision. */
@ -102,18 +103,23 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{
case 'server/discover':
return ok({ supportedVersions: SUPPORTED, capabilities: { tools: {} }, instructions: INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
case 'ping': return ok({});
case 'tools/list': return ok({ tools: listTools(), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) });
case 'tools/list': return ok({ tools: listTools(bypassOn()), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) });
case 'tools/call': {
const name = String(m.params?.name ?? '');
const tool = TOOLS.find(t => t.name === name);
const tool = TOOLS.find(t => t.name === name) ?? (name === OWNER_STEP.name && bypassOn() ? OWNER_STEP : undefined);
if (!tool) return err(-32602, `Unknown tool: ${name}`);
const input = (m.params?.arguments ?? {}) as Record<string, unknown>;
const problems = validateInput(tool, input);
if (problems.length) return ok({ content: [{ type: 'text', text: `Invalid arguments: ${problems.join('; ')}` }], isError: true });
const { argv: raw, dryRun } = toArgv(tool, input);
const argv = textToFile(raw);
// owner_step runs the real command with --yes; its dry run is the command's own --dry
const argv = tool === OWNER_STEP && !dryRun ? [...textToFile(raw), '--yes'] : textToFile(raw);
const r = await run(argv, m.id);
let out = clip(r.out, argv.includes('--json'));
if (tool === OWNER_STEP) {
const file = logOwnerAction(process.env.STOREBOUND_PROJECT || process.cwd(), { command: `storebound ${argv.join(' ')}`, dry: dryRun, exit: r.code, summary: r.out.trim().split('\n').slice(-1)[0] });
out += `\n(bypass mode: logged to ${file})`;
}
if (dryRun) out += '\n\n(dry run: nothing was changed. Ask the owner before calling again with dry_run: false.)';
const structured = { exitCode: r.code, dryRun, command: `storebound ${argv.join(' ')}`, output: out };
return ok({ content: [{ type: 'text', text: `$ storebound ${argv.join(' ')}\n${out}\n[exit ${r.code}]` }], structuredContent: structured, isError: r.code !== 0 });

20
cli/settings.ts Normal file
View file

@ -0,0 +1,20 @@
// Per-machine storebound settings (never in a project's config or repo).
// storebound settings bypass on | off | status owner opt-in: the agent also presses the owner-only buttons
// (docs/bypass.md); default off
import type { Args } from './lib/args';
import { BYPASS_NOTICE, bypassOn, OWNER_COMMANDS, readSettings, setBypass, settingsFile } from './lib/bypass';
import { log, printJson } from './lib/log';
export async function run(args: Args): Promise<void> {
const [what, value] = args._;
if (what !== 'bypass') throw new Error('usage: storebound settings bypass on | off | status');
if (value === 'on' || value === 'off') {
setBypass(value === 'on');
log('settings', value === 'on'
? `${BYPASS_NOTICE}. Unlocked: ${OWNER_COMMANDS.join(', ')}, owner-only API operations (storebound api ... --yes --confirm <operationId>) and console final buttons.`
: 'bypass mode is OFF: owner-only steps are the owner\'s again (MCP hides owner_step, api refuses owner operations, console plans stop at buttons.final)');
} else if (value && value !== 'status') throw new Error('usage: storebound settings bypass on | off | status');
const s = readSettings();
if (args.has('json')) { printJson({ bypass: bypassOn(), since: s.bypass?.since ?? null, file: settingsFile() }); return; }
if (!value || value === 'status') log('settings', `bypass mode: ${bypassOn() ? 'ON' : 'off'}${s.bypass?.since ? ` (since ${s.bypass.since})` : ''} · ${settingsFile()}`);
}

View file

@ -15,6 +15,10 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that

30
docs/bypass.md Normal file
View file

@ -0,0 +1,30 @@
# Bypass mode
Default **off**. The owner turns it on once per machine when they want their AI agent to press the owner-only buttons
too, instead of stopping and asking:
```sh
storebound settings bypass on # or answer "y" to the question in storebound init
storebound settings bypass status
storebound settings bypass off # back to the owner pressing every final button, at once
```
It is stored in `~/.storebound/settings.json` (this machine only), never in a project's config or repo.
## What it unlocks
- MCP: the `owner_step` tool appears. It runs one of: `apple submit`, `apple resubmit`, `apple iap-submit`, `apple release`,
`apple preorder`, `play release`, `play rollout`, `reviews` (replies), `apple testflight` (testers), `play products`,
`play subscriptions`, `apple iap`, `apple subscriptions`, `prices` (live prices). Dry run first (`dry_run` defaults to
true), then `dry_run: false` runs it with `--yes`.
- `storebound api`: owner-only operations (submit, release, delete, ...) are sent for an agent with
`--yes --confirm <operationId>`.
- Console plans: the agent may press `buttons.final` (Publish, Create app, Send for review) itself.
## What stays
- Every owner action an agent runs is appended to `<project>/.storebound/owner-actions.log` (time, dry or ran, exit
code, the command, the last line of its output).
- Dry runs still come first and must pass.
- An AI client may still ask the owner for legal agreements, passwords and 2FA codes: those are its own rules, and a
storebound setting cannot change them.

View file

@ -5,7 +5,7 @@ Every CLI command (cli/commands.ts) and the desktop place that runs it. "covered
(serve.ts, or a host command `host:`) and the window calls it; tests/desktop-coverage.test.ts fails on a gap.
Owner-only parts are owner actions in the window or owner steps with their terminal command (More tools).
**95 commands:** 91 covered, 0 gaps, 4 not for the desktop.
**96 commands:** 91 covered, 0 gaps, 5 not for the desktop.
| command | status | where in the window | methods | owner step |
|---|---|---|---|---|
@ -17,6 +17,7 @@ Owner-only parts are owner actions in the window or owner steps with their termi
| `apps` | covered | Accounts (store apps per account, key check) and App folders (list, add, remove) | `host:store_apps_refresh`, `host:account_check`, `host:projects_list`, `host:project_add`, `host:project_remove` | |
| `desktop` | not-for-desktop | opens or installs this window from a terminal | | |
| `accounts` | covered | Accounts screen (profiles shared with the CLI) | `host:accounts_list`, `host:account_add_play`, `host:account_add_apple`, `host:account_update` | |
| `settings` | not-for-desktop | per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway | | |
| `doctor` | covered | Dependencies screen | `system.tools`, `host:tools_info` | |
| `scan` | covered | Checks tab: Scan, Fix | `scan`, `scan.write` | |
| `api` | not-for-desktop | raw API calls by operation id: every task has its own screen, and raw writes stay a terminal step the owner types | | |

39
tests/bypass.test.ts Normal file
View file

@ -0,0 +1,39 @@
// Bypass mode: off by default; when the owner turns it on, owner-only steps open to the agent and every one is logged.
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { decide } from '../cli/api/gate';
import { bypassOn, logOwnerAction, setBypass } from '../cli/lib/bypass';
import { listTools, OWNER_STEP, toArgv } from '../cli/mcp-tools';
const owner = { kind: 'owner' as const, method: 'POST', dry: false, yes: true, tty: false, agent: true, getOnly: false, confirm: 'reviewSubmissions_createInstance', opId: 'reviewSubmissions_createInstance' };
describe('bypass mode', () => {
test('off by default; on / off per machine (settings.json)', () => {
const env = { STOREBOUND_HOME: mkdtempSync(join(tmpdir(), 'sb-home-')) };
expect(bypassOn(env)).toBe(false);
setBypass(true, env);
expect(bypassOn(env)).toBe(true);
setBypass(false, env);
expect(bypassOn(env)).toBe(false);
});
test('the api gate: owner operations refused for an agent unless bypass, then only with --yes --confirm <operationId>', () => {
expect(decide(owner).action).toBe('refuse');
expect(decide({ ...owner, bypass: true }).action).toBe('send');
expect(decide({ ...owner, bypass: true, confirm: 'other' }).action).toBe('refuse');
expect(decide({ ...owner, bypass: true, confirm: undefined }).action).toBe('refuse');
expect(decide({ ...owner, bypass: true, yes: false }).action).toBe('dry');
});
test('MCP lists owner_step only in bypass mode; dry run first', () => {
expect(listTools(false).some(t => t.name === 'owner_step')).toBe(false);
expect(listTools(true).some(t => t.name === 'owner_step')).toBe(true);
expect(toArgv(OWNER_STEP, { command: 'apple submit', args: ['--build', '42'] }).argv).toEqual(['apple', 'submit', '--build', '42', '--dry']);
expect(toArgv(OWNER_STEP, { command: 'play rollout', args: ['--percent', '20'], dry_run: false })).toEqual({ argv: ['play', 'rollout', '--percent', '20'], dryRun: false });
});
test('every owner action is logged in the project', () => {
const root = mkdtempSync(join(tmpdir(), 'sb-owner-'));
const f = logOwnerAction(root, { command: 'storebound apple submit --build 42 --yes', dry: false, exit: 0, summary: 'submitted' });
expect(readFileSync(f, 'utf8')).toMatch(/\tRAN\texit 0\tstorebound apple submit --build 42 --yes\tsubmitted\n$/);
});
});

View file

@ -8,7 +8,7 @@ import { describeMethods } from '../cli/serve';
import { analyze, render } from '../tools/gen-desktop-coverage';
/** The only commands that make no sense in a window. Adding one here needs a reason in the coverage map. */
const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api'];
const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings'];
describe('desktop coverage', () => {
const { rows, extra } = analyze();