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:
parent
2598c4953a
commit
9bd17c401d
21 changed files with 218 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)'],
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
],
|
||||
|
|
|
|||
11
cli/init.ts
11
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/<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
54
cli/lib/bypass.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
14
cli/mcp.ts
14
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<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
20
cli/settings.ts
Normal 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()}`);
|
||||
}
|
||||
|
|
@ -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
30
docs/bypass.md
Normal 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.
|
||||
|
|
@ -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
39
tests/bypass.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue