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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Fixed: a script recording aborted by a second `open` is no longer published by a later bare `close` (#1533). `open <app> --save-script` followed by a second successful `open` terminates the recording and warns "Script publication was aborted…", and `close --save-script` correctly refuses it with "Retry with plain close; it will tear down the session without writing." But when that second `open` itself carried `--save-script`, the flag re-armed recording behind the terminal status, and a bare `close` then wrote the full session log to disk — publishing a recording the caller had been told was aborted, and breaking the promise the refusal makes. An aborted authoring lifecycle is now terminal by construction: `--save-script` arms nothing on any surface that handles it — the re-open builder, the close finalizer, and the recorded-action ingress — and the script writer refuses the lifecycle from every path that reaches it (bare `close`, teardown, idle-reap, active publication). This also stops an aborted session from paying recording-time costs it can never publish: a re-opened aborted recording no longer keeps the direct iOS selector fast paths for `click` and `get` disabled. Armed recordings, published recordings, and every repair transaction are unaffected.
- `agent-device mcp` now serves the stateless MCP `2026-07-28` revision alongside the handshake-based revisions it already spoke, as the spec's "dual-era server". Modern clients probe `server/discover`, which advertises the supported revisions, the tools capability, and server identity; their requests declare a protocol version in `_meta`, and their results carry `resultType: "complete"` plus `_meta["io.modelcontextprotocol/serverInfo"]`. `tools/list` and `server/discover` now return the `ttlMs`/`cacheScope` cache hints, so a client can cache the 55-tool, ~223KB tool list for an hour instead of re-fetching it on every start; the list was already emitted in a deterministic (sorted) order, which is the other half of what makes it cacheable. Each revision is answered on its own wire contract: a request declaring `2025-11-25` or `2025-06-18` through modern framing still gets the legacy result shape, and `initialize` never agrees to `2026-07-28`, which has no handshake to establish. A declared revision this server does not implement is rejected with `UnsupportedProtocolVersionError` (`-32022`) naming the ones it does, rather than being served under a version the client did not ask for, and modern framing that omits its required `protocolVersion`/`clientCapabilities` metadata — or supplies a `clientInfo` that is not a valid `Implementation` — is rejected as invalid params. `initialize` and `ping` were removed in `2026-07-28`, so a modern-framed call to either is answered `-32601` rather than served inside a `resultType: "complete"` envelope. Responses to legacy clients are unchanged byte-for-byte — `initialize` and `ping` are still served, and no cache, `resultType`, or `_meta` field is added to their results. Nothing here affects the CLI, Node, or daemon surfaces: the stdio transport, the tool set, and every tool's input/output schema are untouched.
- Fixed: the MCP `initialize` handshake now answers with the protocol revision the client requested when it is one this server implements, instead of always answering `2025-11-25`. A client pinned to `2025-06-18` was told to speak a revision it had not asked for, which the lifecycle contract answers by disconnecting.
- `agent-device help workflow` is now a compact ~8KB card instead of a ~41KB dump; the same depth still exists, split into `help scripting` (save-script, secret-safe fills, batch JSON, replay divergence/repair, recording) and `help gestures` (multi-touch shapes and platform quirks), plus a few paragraphs folded into the topics that already owned the subject (`help debugging`, `help physical-device`, `help validate`). Every `help <topic>` first line is now `agent-device <version> — <topic>` so an agent can read the installed version from its mandatory first help read instead of a separate `agent-device --version` call.
Expand Down
23 changes: 22 additions & 1 deletion docs/adr/0016-active-session-script-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,28 @@ fresh session is the only re-arming boundary.
> without writing. This is distinct from
> [#1533](https://github.com/callstack/agent-device/issues/1533), which is about an
> already-ARMED-then-ABORTED session whose flag ingress re-enables `recordSession` and lets a
> *bare* `close` (no `--save-script` on the close itself) publish; that case is unresolved here.
> *bare* `close` (no `--save-script` on the close itself) publish; that case is resolved by the
> amendment below.

> **Amendment (#1533, shipped).** ABORTED terminality above was enforced only by
> `abortAuthoringOnSecondOpen` clearing `session.recordSession` — inert by ordering, not by
> construction. `recordSession` is an evidence-capture flag that several surfaces set directly, so
> a later `--save-script` re-armed it while the status stayed ABORTED, and a *bare* `close` then
> published the full session log through a writer that only knew how to refuse repair
> transactions. The refusal this ADR specifies for `close --save-script` in ABORTED promises the
> caller that plain `close` "tears down without writing", so the gap also made an existing error
> message untrue.
>
> ABORTED is now terminal by construction. `--save-script` arms recording through one rule owned by
> the publication projection (`recordSessionAfterSaveScriptFlag`), which answers "not recording"
> for an ABORTED lifecycle on every surface that handles the flag — the re-open builder, the close
> finalizer, and the recorded-action ingress — so the flag can no longer contradict the status.
> Publication authorization is likewise the aggregate's to answer: the writer asks one
> publication-blocked question covering not-recording, an uncommittable or committed repair, and an
> ABORTED authoring lifecycle alike, so every path that reaches it (bare `close`, teardown,
> idle-reap, active publication) refuses. ARMED and PUBLISHED lifecycles and every repair
> transaction are unchanged; no state name, transition, or entry point in the lifecycle above is
> added or altered.

This lifecycle is distinct from ADR 0012's repair transaction. `session save-script` rejects a session
with `saveScriptBoundary` set and directs the caller to finish or abort the repair through its existing
Expand Down
60 changes: 59 additions & 1 deletion src/daemon/__tests__/session-action-recorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
*/
import { test, expect } from 'vitest';
import { recordActionEntry } from '../session-action-recorder.ts';
import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts';
import {
authoringPublication,
makeIosSession,
} from '../../__tests__/test-utils/session-factories.ts';

test('an observation-only action is excluded while repair-armed and no --record is given', () => {
const session = makeIosSession('default', {
Expand Down Expand Up @@ -261,3 +264,58 @@ test('whitespace-only fills collapse ambiguous recorder output and keys', () =>
});
expect(JSON.stringify(session.actions)).not.toContain(whitespace);
});

test('#1533: the --save-script ingress does not re-arm an aborted authoring lifecycle', () => {
const session = makeIosSession('s', {
// What `abortAuthoringOnSecondOpen` leaves behind: terminal status, recording off.
recordSession: false,
scriptPublication: authoringPublication('aborted'),
});

// The second `open --save-script` records its own action, carrying the flag
// that caused the abort into the recorder's shared ingress.
recordActionEntry(session, {
command: 'open',
positionals: ['com.example.other'],
flags: { saveScript: true },
});

expect(session.recordSession).toBe(false);
expect(session.scriptPublication).toEqual(authoringPublication('aborted'));
});

test('#1533: an aborted lifecycle cannot be retargeted by a later --save-script=<path> either', () => {
const session = makeIosSession('s', {
recordSession: false,
scriptPublication: authoringPublication('aborted', { path: '/tmp/original.ad' }),
});

recordActionEntry(session, {
command: 'open',
positionals: ['com.example.other'],
flags: { saveScript: '/tmp/hijacked.ad', force: true },
});

expect(session.recordSession).toBe(false);
expect(session.scriptPublication).toEqual(
authoringPublication('aborted', { path: '/tmp/original.ad' }),
);
});

test('#1533: an ARMED authoring lifecycle still takes the ingress (retarget + force unchanged)', () => {
const session = makeIosSession('s', {
recordSession: true,
scriptPublication: authoringPublication('armed'),
});

recordActionEntry(session, {
command: 'close',
positionals: [],
flags: { saveScript: '/tmp/out.ad', force: true },
});

expect(session.recordSession).toBe(true);
expect(session.scriptPublication).toEqual(
authoringPublication('armed', { path: '/tmp/out.ad', force: true }),
);
});
46 changes: 46 additions & 0 deletions src/daemon/__tests__/session-script-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,3 +680,49 @@ test('write() publishes atomically: no stray temp file survives a successful rep
expect(fs.readdirSync(path.dirname(outPath))).toEqual([path.basename(outPath)]);
expect(fs.readFileSync(outPath, 'utf8')).toContain(HEAL_COMPLETE_SENTINEL);
});

// --- #1533: an ABORTED ordinary authoring recording publishes nothing, and the
// lifecycle alone decides that — a session handed to the writer still recording
// is refused on the strength of its terminal status ---

test('#1533: write() publishes nothing for an aborted authoring lifecycle that is still recording', () => {
const root = mkdtempForTestSync('agent-device-script-writer-authoring-aborted-');
const sessionsDir = path.join(root, 'sessions');
const writer = new SessionScriptWriter(sessionsDir);
// The production shape after `open --save-script` -> `open --save-script`:
// the second open aborted the lifecycle AND cleared recording, then its own
// recorded action's `--save-script` ingress set the boolean back to true.
const session = makeAuthoringSession('default', {
scriptPublication: authoringPublication('aborted'),
actions: [action({ command: 'click', positionals: ['id="save"'] })],
});

expect(writer.write(session)).toEqual({ written: false });
expect(fs.existsSync(sessionsDir)).toBe(false);
});

test('#1533: the abort is terminal per-target too — an explicit --save-script path is not written either', () => {
const root = mkdtempForTestSync('agent-device-script-writer-authoring-aborted-explicit-');
const writer = new SessionScriptWriter(path.join(root, 'sessions'));
const outPath = path.join(root, 'flows', 'aborted.ad');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const session = makeAuthoringSession('default', {
scriptPublication: authoringPublication('aborted', { path: outPath }),
actions: [action({ command: 'click', positionals: ['id="save"'] })],
});

expect(writer.write(session, { force: true })).toEqual({ written: false });
expect(fs.readdirSync(path.dirname(outPath))).toEqual([]);
});

test('#1533: an ARMED authoring recording is untouched by the abort gate', () => {
const root = mkdtempForTestSync('agent-device-script-writer-authoring-armed-');
const writer = new SessionScriptWriter(path.join(root, 'sessions'));
const session = makeAuthoringSession('default', {
scriptPublication: authoringPublication('armed'),
actions: [action({ command: 'click', positionals: ['id="save"'] })],
});

const { parsed } = writeAndParse(writer, session);
expect(parsed.actions.map((a) => a.command)).toEqual(['click']);
});
61 changes: 59 additions & 2 deletions src/daemon/handlers/__tests__/session-close-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import { afterEach, expect, test, vi } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
authoringPublication,
makeIosSession,
makeRepairCompleteSession,
} from '../../../__tests__/test-utils/session-factories.ts';
Expand All @@ -25,7 +26,8 @@ afterEach(() => {
function setup(name: string, session = makeIosSession(name, { appBundleId: 'com.example.app' })) {
const root = mkdtempForTestSync('agent-device-session-close-script-');
roots.push(root);
const sessionStore = new SessionStore(path.join(root, 'sessions'));
const sessionsDir = path.join(root, 'sessions');
const sessionStore = new SessionStore(sessionsDir);
sessionStore.set(name, session);
const req: DaemonRequest = {
token: 'token',
Expand All @@ -34,7 +36,7 @@ function setup(name: string, session = makeIosSession(name, { appBundleId: 'com.
positionals: [],
flags: {},
};
return { req, session, sessionStore };
return { req, session, sessionStore, sessionsDir };
}

test('failed repair publication removes only its synthetic close before retry', () => {
Expand Down Expand Up @@ -110,3 +112,58 @@ test('ordinary publication failure retains its close action after making the err
},
});
});

// --- #1533: a bare `close` after an aborted --save-script re-arm publishes nothing ---
//
// `close --save-script` on this session is refused by `assertTerminalRecordingCloseAllowed`
// with "Retry with plain close; it will tear down the session without writing." These pin
// that promise: the plain-close teardown path must write nothing.

test('#1533: bare close on an aborted authoring session writes no script', () => {
const { req, session, sessionStore, sessionsDir } = setup(
'aborted',
makeIosSession('aborted', {
appBundleId: 'com.example.app',
// Post-`abortAuthoringOnSecondOpen`, then re-armed by the second open's own
// `--save-script` flag ingress.
recordSession: true,
scriptPublication: authoringPublication('aborted'),
actions: [{ ts: 1, command: 'click', positionals: ['id="save"'], flags: {} }],
}),
);

expect(
finalizeOrdinaryCloseScript({
req,
session,
sessionStore,
platformCloseError: undefined,
}),
).toBeUndefined();

expect(sessionStore.get('aborted')?.scriptPublication).toEqual(authoringPublication('aborted'));
expect(fs.existsSync(sessionsDir) ? fs.readdirSync(sessionsDir) : []).toEqual([]);
});

test('#1533: an ordinary armed authoring session still publishes on bare close', () => {
const { req, session, sessionStore, sessionsDir } = setup(
'armed',
makeIosSession('armed', {
appBundleId: 'com.example.app',
recordSession: true,
scriptPublication: authoringPublication('armed'),
actions: [{ ts: 1, command: 'click', positionals: ['id="save"'], flags: {} }],
}),
);

expect(
finalizeOrdinaryCloseScript({
req,
session,
sessionStore,
platformCloseError: undefined,
}),
).toBeUndefined();

expect(fs.readdirSync(sessionsDir)).toHaveLength(1);
});
62 changes: 61 additions & 1 deletion src/daemon/handlers/__tests__/session-open-surface.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { buildOpenResult, resolveRequestedOpenSurface } from '../session-open-surface.ts';
import {
buildNextOpenSession,
buildOpenResult,
resolveRequestedOpenSurface,
} from '../session-open-surface.ts';
import {
authoringPublication,
makeIosSession,
} from '../../../__tests__/test-utils/session-factories.ts';
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts';

test('resolveRequestedOpenSurface rejects surface flag on iOS', () => {
assert.throws(
Expand Down Expand Up @@ -48,3 +57,54 @@ test('buildOpenResult exposes the Vega serial without leaking an internal platfo
assert.equal(result.serial, 'amazon-vvd');
assert.equal(result.target, 'tv');
});

// --- #1533: `--save-script` arms recording through the publication lifecycle, not around it ---
//
// A re-open is the surface that used to set `recordSession` on its own. An ABORTED authoring
// lifecycle is terminal, so the flag must arm nothing here either — otherwise the session keeps
// paying recording-time costs (the direct-selector fast paths stay disabled) for a recording that
// can never publish, and the writer is the only thing standing between it and a stray script.

function reopen(existingSession: ReturnType<typeof makeIosSession>, saveScript: boolean) {
return buildNextOpenSession({
existingSession,
sessionName: existingSession.name,
device: IOS_SIMULATOR,
surface: 'app',
appBundleId: 'com.example.other',
saveScript,
});
}

test('#1533: re-opening an aborted authoring session with --save-script does not re-arm recording', () => {
const aborted = makeIosSession('s', {
recordSession: false,
scriptPublication: authoringPublication('aborted'),
});

assert.equal(reopen(aborted, true).recordSession, false);
});

test('#1533: an aborted lifecycle that is already recording is corrected, not carried forward', () => {
const drifted = makeIosSession('s', {
recordSession: true,
scriptPublication: authoringPublication('aborted'),
});

assert.equal(reopen(drifted, true).recordSession, false);
});

test('a re-open with --save-script still arms recording for a session with no publication yet', () => {
const plain = makeIosSession('s', { recordSession: false });

assert.equal(reopen(plain, true).recordSession, true);
});

test('a re-open without --save-script leaves an armed authoring session recording', () => {
const armed = makeIosSession('s', {
recordSession: true,
scriptPublication: authoringPublication('armed'),
});

assert.equal(reopen(armed, false).recordSession, true);
});
5 changes: 4 additions & 1 deletion src/daemon/handlers/session-close-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
effectiveWriteForce,
isSessionScriptPublished,
markCloseGeneratedPublicationDone,
recordSessionAfterSaveScriptFlag,
} from '../session-script-publication-capability.ts';
import { abortRepairTransaction, isRepairArmedSession } from '../session-replay-transaction.ts';

Expand Down Expand Up @@ -80,7 +81,9 @@ export function finalizeOrdinaryCloseScript(params: {
// The recorded close action already armed target/force through the recorder's flag ingress.
// On a platform-close failure that action was never recorded; the log still publishes, but —
// as before this migration — to the session default, not the request's explicit path.
if (req.flags?.saveScript) session.recordSession = true;
if (req.flags?.saveScript) {
session.recordSession = recordSessionAfterSaveScriptFlag(session, true);
}

try {
const result = sessionStore.writeSessionLog(session, {
Expand Down
9 changes: 6 additions & 3 deletions src/daemon/handlers/session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,12 @@ async function stopOrRetainAppleRunnerAfterClose(
// fallback chains but no `target-v1` recording-time evidence — degraded replay verification with
// no signal to the caller. Recording-time evidence can only be captured from action zero
// (`armAuthoringOnOpen`), so an unarmed session has nothing to retroactively arm; the only
// correct response is refusal, before any teardown or publication work runs. This intentionally
// does not resolve #1533 (aborted-mid-recording close --save-script); that is a distinct,
// already-armed case with its own resolution.
// correct response is refusal, before any teardown or publication work runs.
//
// #1533 (aborted-mid-recording) is the adjacent already-armed case: this refusal promises "plain
// close tears down without writing", and an ABORTED authoring lifecycle keeps that promise —
// `--save-script` re-arms nothing on any surface (`recordSessionAfterSaveScriptFlag`) and the
// writer refuses to publish the lifecycle from every path that reaches it.
function assertTerminalRecordingCloseAllowed(req: DaemonRequest, session: SessionState): void {
if (!req.flags?.saveScript) return;
if (isAuthoringArmedSession(session)) return;
Expand Down
Loading
Loading