diff --git a/.cursor/rules/storebound.mdc b/.cursor/rules/storebound.mdc index 8ba9940..18a30b6 100644 --- a/.cursor/rules/storebound.mdc +++ b/.cursor/rules/storebound.mdc @@ -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 `, 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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5515c62..7ab0224 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 `, 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 diff --git a/AGENTS.md b/AGENTS.md index abbc502..ea2ee8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `, 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 14004de..89ac8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, 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 --yes` runs this OS's diff --git a/GEMINI.md b/GEMINI.md index 05becf9..3677fc4 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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 `, 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 diff --git a/cli/api.ts b/cli/api.ts index b8b5568..a629aed 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -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 { 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; diff --git a/cli/api/gate.ts b/cli/api/gate.ts index 599721e..d3f6ef7 100644 --- a/cli/api/gate.ts +++ b/cli/api/gate.ts @@ -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 ' }; if (!g.tty) return { action: 'refuse', why: NOT_A_TTY }; return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' }; } diff --git a/cli/commands.ts b/cli/commands.ts index 7d1846f..789219c 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -15,6 +15,7 @@ export const PLAIN: Record Promise, 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 [] | remove : 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 | default (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 --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 [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index be6a24c..a93f66b 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 ] [--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]" ], diff --git a/cli/init.ts b/cli/init.ts index bb77b19..56c3608 100644 --- a/cli/init.ts +++ b/cli/init.ts @@ -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//, then the profile it builds on, then templates/. 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 { // 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'); } diff --git a/cli/lib/bypass.ts b/cli/lib/bypass.ts new file mode 100644 index 0000000..2b3d15e --- /dev/null +++ b/cli/lib/bypass.ts @@ -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 , console plans tell the agent it may press buttons.final. Every owner +// action an agent runs is appended to /.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 = process.env): string => + join(env.STOREBOUND_HOME ?? join(homedir(), '.storebound'), 'settings.json'); + +export function readSettings(env: Record = process.env): Settings { + try { return JSON.parse(readFileSync(settingsFile(env), 'utf8')) as Settings; } catch { return {}; } +} + +export function writeSettings(s: Settings, env: Record = 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 = process.env): boolean => readSettings(env).bypass?.on === true; + +export function setBypass(on: boolean, env: Record = 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; +} diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index f800c59..f333e0d 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -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.', ]; diff --git a/cli/lib/desktop-coverage.ts b/cli/lib/desktop-coverage.ts index ba5a082..e127b05 100644 --- a/cli/lib/desktop-coverage.ts +++ b/cli/lib/desktop-coverage.ts @@ -46,6 +46,7 @@ export const DESKTOP_COVERAGE: Record = { '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' }, diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index bd3a0ba..ea8e7f1 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -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[] { - 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[] { + 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 }, diff --git a/cli/mcp.ts b/cli/mcp.ts index 1bf1f64..95405cc 100644 --- a/cli/mcp.ts +++ b/cli/mcp.ts @@ -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; 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 }); diff --git a/cli/settings.ts b/cli/settings.ts new file mode 100644 index 0000000..f058b7f --- /dev/null +++ b/cli/settings.ts @@ -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 { + 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 ) 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()}`); +} diff --git a/docs/agents-core.md b/docs/agents-core.md index de13e68..0482818 100644 --- a/docs/agents-core.md +++ b/docs/agents-core.md @@ -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 `, 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 diff --git a/docs/bypass.md b/docs/bypass.md new file mode 100644 index 0000000..c82b6fc --- /dev/null +++ b/docs/bypass.md @@ -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 `. +- 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 `/.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. diff --git a/docs/desktop-coverage.md b/docs/desktop-coverage.md index 7d1e8ee..73c9dde 100644 --- a/docs/desktop-coverage.md +++ b/docs/desktop-coverage.md @@ -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 | | | diff --git a/tests/bypass.test.ts b/tests/bypass.test.ts new file mode 100644 index 0000000..2e67793 --- /dev/null +++ b/tests/bypass.test.ts @@ -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 ', () => { + 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$/); + }); +}); diff --git a/tests/desktop-coverage.test.ts b/tests/desktop-coverage.test.ts index 7c4b686..cef5fcf 100644 --- a/tests/desktop-coverage.test.ts +++ b/tests/desktop-coverage.test.ts @@ -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();