@isomorph.ai/app-sdk 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -448,16 +448,33 @@ export default async function viewComputer(input: { taskId: string; pauseId: str
448
448
 
449
449
  Keep that table protected by the app's user row rules. A trusted action must still check `owner_id` against `ctx.user.id`; never use workload identity as proof of human ownership.
450
450
 
451
- - `requestTakeover(reason, requiredInput?)` requests a pause; `requiredInput` is `human` (default) or `login`. Poll `check()` until `waiting_for_input` gives `taskId`, `pauseId`, `reason`, `requiredInput` and optional login result.
452
- - `view(pauseId)` returns `{status:'pausing'}` or `{status:'ready', screenshot, url}`. Reuse the existing sign-in screenshot UI: display the base64 PNG as an image, map clicks to image pixels, and forward keyboard/scroll events through app actions. Viewing does not itself resume the agent.
453
- - `input(pauseId, action)` sends the same `SignInAction` click/type/key/scroll commands used by `sendSignInInput`. The CUA accepts human input only while the task is paused; it blocks the helper and main agent from acting at the same time.
451
+ - `requestTakeover(reason, requiredInput?)` requests a pause; `requiredInput` is `human` (default) or `login`. It answers the pause (`taskId`, `pauseId`, `status`, `reason`, `requiredInput`; the same pause again if one exists). Poll `check()` until `waiting_for_input` gives the pause plus the optional login result; while the task is not waiting, `check()` answers its progress (`status`, `steps`, `lastStep`, `summary`, `finalUrl`, `waiting`, `message`).
452
+ - `view(pauseId)` returns `{status:'ready', screenshot, url}`; while the task is still pausing, or once it resumed, the API answers 409 and the call throws. Reuse the existing sign-in screenshot UI: display the base64 PNG as an image, map clicks to image pixels, and forward keyboard/scroll events through app actions. Viewing does not itself resume the agent.
453
+ - `input(pauseId, action)` sends the same `SignInAction` click/type/key/scroll commands used by `sendSignInInput` (`typeText` may be up to 4096 characters here) and answers what the computer did. The CUA accepts human input only while the task is paused; it blocks the helper and main agent from acting at the same time.
454
454
  - `completeLogin(pauseId, {allowedHosts, username, password})`, or `completeLogin(pauseId, {otp})`, queues the separate browser-action-only login agent on this computer. **Call only from private server code, never expose this method or credentials as model tool inputs, prompts, progress, logs or job input/results.** Poll `check()` for `login.status: success | needs_input | human_help_needed`, with `input:'otp'` when another code is needed. Helper success leaves the main agent paused.
455
- - `resume(pauseId)` explicitly resumes; it does not wait for completion. On Continue, call this from the authorized app action, then enqueue the app's continuation job. That job creates `createComputerUseTool({...jobOptions, runKey: originalRunKey, attach:true})`: it polls the same task without sending `/tasks` again. Keep the original saved-session and download options if needed. Stale/duplicate resumes fail with 409; do not silently retry with a new pause ID. If enqueue fails, retry attaching to the already resumed task, not another resume.
455
+ - `resume(pauseId)` explicitly resumes and answers the pause with `status: 'resume_queued'`; it does not wait for completion. On Continue, call this from the authorized app action, then enqueue the app's continuation job. That job creates `createComputerUseTool({...jobOptions, runKey: originalRunKey, attach:true})`: it polls the same task without sending `/tasks` again. Keep the original saved-session and download options if needed. Stale/duplicate resumes fail with 409; do not silently retry with a new pause ID. If enqueue fails, retry attaching to the already resumed task, not another resume.
456
456
  - `cancel()` stops the task, including while waiting.
457
457
 
458
458
  The outer agent may use app-defined tools for takeover and opening the authorized view. Resume requires an explicit user or app decision. Keep any outer-agent conversation in the app's existing job/task storage when ending a waiting job; this SDK does not persist the outer model's messages. The CUA persists its own conversation/resume state under the same task folder as screenshots and progress in its existing result bucket, without passwords or OTPs.
459
459
 
460
- P0 `getSavedLogin(accountRef, website)` and `getOtp(taskId, website)` return `{status:'unavailable'}` and call no integration. Use human takeover when no approved backend input is supplied. Governance saved-login/website storage and email/SMS OTP lookup are P0.5, not part of this SDK change. These task helpers require the matching CUA handoff release; existing new-computer sign-in helpers below remain separate.
460
+ A website that texts or mails a one-time code: `signInWithOtp` from `dist/computer-use-otp.js` does the password login, waits for the helper to ask for the code, reads it from the company's own phone number or mailbox through the app's integration grant, answers it once and returns when the helper succeeded or needs a person. The app process does the reading; the computer-use service never calls governance.
461
+
462
+ ```ts
463
+ import { signInWithOtp } from '@isomorph.ai/app-sdk/dist/computer-use-otp.js';
464
+ const answer = await signInWithOtp(computer, input.pauseId, { allowedHosts: ['netbanking.hdfcbank.com'], username, password }, {
465
+ execute: app.integrations.execute,
466
+ sms: { connection: 'company-phone', from: 'HDFCBK' }, // phone.sms.list on the company line
467
+ // email: { connection: 'company-gmail', provider: 'gmail', from: 'alerts@hdfcbank.net' }, // or the connected mailbox (gmail | outlook)
468
+ });
469
+ // { status: 'success' } | { status: 'human_help_needed', reason } | { status: 'ended', taskStatus }
470
+ ```
471
+
472
+ - Only messages that arrived after the login started count; the code is the 4 to 8 digits next to "OTP", "code" or "password" (`extract` overrides it). It waits `timeoutMs` (3 min) for the message, checking every `pollIntervalMs` (5 s).
473
+ - One code per pause: a website that asks for a second one gets a person (`human_help_needed`), as does a helper that gives up. Success leaves the task paused; resume it as above.
474
+ - `readOtp(source, since)` reads a code on its own, for a page that asks for one outside a login.
475
+ - The connection needs the app's grant for `phone.sms.list`, or `gmail.thread.list` + `gmail.message.read` (Outlook: `outlook.message.list` + `outlook.message.read`), and the portal account must be registered to that number or mailbox.
476
+
477
+ `getSavedLogin(accountRef, website)` still returns `{status:'unavailable'}`; governance saved-login storage is not part of the SDK. These task helpers require the matching CUA handoff release; existing new-computer sign-in helpers below remain separate.
461
478
 
462
479
  ## Browser sign-in for computer use
463
480
 
@@ -27,12 +27,34 @@ export declare function waitingInput(answer: {
27
27
  message?: unknown;
28
28
  }): ComputerUseWaiting;
29
29
  export type ComputerUsePage = {
30
- status: 'pausing';
31
- } | {
32
30
  status: 'ready';
33
31
  screenshot: string;
34
32
  url: string;
35
33
  };
34
+ /** A pause as the computer-use API describes it on takeover, login and resume; `status` is the task's, not only `waiting_for_input`. */
35
+ export type ComputerUsePause = {
36
+ taskId: string;
37
+ pauseId: string;
38
+ status: string;
39
+ reason: string;
40
+ requiredInput: 'login' | 'otp' | 'human';
41
+ };
42
+ /** What the paused computer answered to one input: the desktop function's own result, passed through. */
43
+ export type ComputerUseInputResult = Record<string, unknown>;
44
+ /** Progress of a task that is not waiting: the same fields `createComputerUseTool` reads while it polls. */
45
+ export type ComputerUseProgress = {
46
+ status: string;
47
+ steps?: number;
48
+ lastStep?: string | null;
49
+ summary?: string | null;
50
+ finalUrl?: string | null;
51
+ waiting?: {
52
+ reason: string;
53
+ since: string;
54
+ attempts: number;
55
+ } | null;
56
+ message?: ComputerUseMessage;
57
+ };
36
58
  export type ComputerUseLoginInput = {
37
59
  allowedHosts: readonly string[];
38
60
  username: string;
@@ -49,23 +71,14 @@ export interface ComputerUseHandoffOptions extends ComputerUseTransport {
49
71
  authorize(): Promise<boolean> | boolean;
50
72
  }
51
73
  export declare function createComputerUseHandoff(options: ComputerUseHandoffOptions): {
52
- requestTakeover(reason: string, requiredInput?: "login" | "human"): Promise<{
53
- status: string;
54
- }>;
74
+ requestTakeover(reason: string, requiredInput?: "login" | "human"): Promise<ComputerUsePause>;
55
75
  view(pauseId: string): Promise<ComputerUsePage>;
56
- input(pauseId: string, action: SignInAction): Promise<{
57
- status: "accepted";
58
- }>;
76
+ input(pauseId: string, action: SignInAction): Promise<ComputerUseInputResult>;
59
77
  completeLogin(pauseId: string, input: ComputerUseLoginInput): Promise<{
60
78
  status: "login_queued";
61
79
  }>;
62
- check(): Promise<ComputerUseWaiting | {
63
- message?: ComputerUseMessage | undefined;
64
- status: string;
65
- }>;
66
- resume(pauseId: string): Promise<{
67
- status: "resume_queued";
68
- }>;
80
+ check(): Promise<ComputerUseWaiting | ComputerUseProgress>;
81
+ resume(pauseId: string): Promise<ComputerUsePause>;
69
82
  cancel(): Promise<{
70
83
  status: string;
71
84
  }>;
@@ -73,6 +86,5 @@ export declare function createComputerUseHandoff(options: ComputerUseHandoffOpti
73
86
  export declare function getSavedLogin(_accountRef: string, _website: string): Promise<{
74
87
  status: 'unavailable';
75
88
  }>;
76
- export declare function getOtp(_taskId: string, _website: string): Promise<{
77
- status: 'unavailable';
78
- }>;
89
+ /** A one-time code comes from the app's own mailbox or phone number: `signInWithOtp` in `computer-use-otp.js`. */
90
+ export { readOtp, signInWithOtp } from './computer-use-otp.js';
@@ -46,6 +46,11 @@ export function createComputerUseHandoff(options) {
46
46
  throw refused(path, answer.status, privateInput ? undefined : answer.body);
47
47
  return answer.body;
48
48
  };
49
+ const pauseOf = (answer) => {
50
+ if (typeof answer.taskId !== 'string' || typeof answer.pauseId !== 'string' || !['login', 'otp', 'human'].includes(String(answer.requiredInput)))
51
+ throw new Error('The computer use API returned an incomplete pause.');
52
+ return { taskId: answer.taskId, pauseId: answer.pauseId, status: String(answer.status), reason: typeof answer.reason === 'string' ? answer.reason : 'Outside input needed.', requiredInput: answer.requiredInput };
53
+ };
49
54
  const paused = (pauseId) => {
50
55
  if (!pauseId || typeof pauseId !== 'string')
51
56
  throw new Error('Computer use handoff needs the current pauseId.');
@@ -55,12 +60,11 @@ export function createComputerUseHandoff(options) {
55
60
  requestTakeover(reason, requiredInput = 'human') {
56
61
  if (!reason.trim() || !['login', 'human'].includes(requiredInput))
57
62
  throw new Error('Takeover needs a reason and login or human input.');
58
- return post('/tasks/takeover', { reason, requiredInput });
63
+ return post('/tasks/takeover', { reason, requiredInput }).then(pauseOf);
59
64
  },
65
+ // The API answers 409 while the task is still pausing or after it resumed; `ready` is the only 200.
60
66
  async view(pauseId) {
61
67
  const page = await post('/tasks/view', paused(pauseId));
62
- if (page.status === 'pausing')
63
- return { status: 'pausing' };
64
68
  if (page.status !== 'ready' || typeof page.screenshot !== 'string' || typeof page.url !== 'string')
65
69
  throw new Error('The computer use API returned an incomplete screen.');
66
70
  return { status: 'ready', screenshot: page.screenshot, url: page.url };
@@ -88,10 +92,23 @@ export function createComputerUseHandoff(options) {
88
92
  if (answer.status === 'waiting_for_input')
89
93
  return waitingInput(answer);
90
94
  const message = computerUseMessage(answer.message);
91
- return { status: String(answer.status), ...(message ? { message } : {}) };
95
+ const progress = { status: String(answer.status) };
96
+ if (typeof answer.steps === 'number')
97
+ progress.steps = answer.steps;
98
+ if (answer.last_step !== undefined)
99
+ progress.lastStep = answer.last_step;
100
+ if (answer.summary !== undefined)
101
+ progress.summary = answer.summary;
102
+ if (answer.final_url !== undefined)
103
+ progress.finalUrl = answer.final_url;
104
+ if (answer.waiting !== undefined)
105
+ progress.waiting = answer.waiting;
106
+ if (message)
107
+ progress.message = message;
108
+ return progress;
92
109
  },
93
110
  resume(pauseId) {
94
- return post('/tasks/resume', paused(pauseId));
111
+ return post('/tasks/resume', paused(pauseId)).then(pauseOf);
95
112
  },
96
113
  cancel() {
97
114
  return post('/tasks/cancel');
@@ -104,6 +121,5 @@ function validSecret(value) {
104
121
  export async function getSavedLogin(_accountRef, _website) {
105
122
  return { status: 'unavailable' };
106
123
  }
107
- export async function getOtp(_taskId, _website) {
108
- return { status: 'unavailable' };
109
- }
124
+ /** A one-time code comes from the app's own mailbox or phone number: `signInWithOtp` in `computer-use-otp.js`. */
125
+ export { readOtp, signInWithOtp } from './computer-use-otp.js';
@@ -0,0 +1,62 @@
1
+ import type { createComputerUseHandoff } from './computer-use-handoff.js';
2
+ type Handoff = ReturnType<typeof createComputerUseHandoff>;
3
+ /** `app.integrations.execute` from `createClient()`: the app's own grant, identity and rate limit. */
4
+ export type IntegrationExecute = (connection: string, request: {
5
+ operation: string;
6
+ resource: string;
7
+ input?: Record<string, unknown>;
8
+ }) => Promise<unknown>;
9
+ export interface OtpSource {
10
+ execute: IntegrationExecute;
11
+ /** The connected mailbox the website mails the code to. `from` is the website's sender address. */
12
+ email?: {
13
+ connection: string;
14
+ provider: 'gmail' | 'outlook';
15
+ from: string;
16
+ resource?: string;
17
+ };
18
+ /** The company phone number the website texts the code to. `from` is the sender id, such as `HDFCBK`. */
19
+ sms?: {
20
+ connection: string;
21
+ from?: string;
22
+ resource?: string;
23
+ };
24
+ /** The code in a message's text; the default takes 4 to 8 digits next to "OTP", "code" or "password", else the first such group. */
25
+ extract?: (text: string) => string | undefined;
26
+ /** How long to wait for the message, default 3 minutes; checked every `pollIntervalMs`, default 5 s. */
27
+ timeoutMs?: number;
28
+ pollIntervalMs?: number;
29
+ }
30
+ export type OtpSignIn = {
31
+ status: 'success';
32
+ }
33
+ /** The website wants a person: no code arrived in time, a second code was asked for, or the helper gave up. The task is still paused. */
34
+ | {
35
+ status: 'human_help_needed';
36
+ reason: string;
37
+ }
38
+ /** The task ended while signing in. */
39
+ | {
40
+ status: 'ended';
41
+ taskStatus: string;
42
+ };
43
+ export declare function extractOtp(text: string): string | undefined;
44
+ /** Reads the website's one-time code sent since `since`, waiting up to `timeoutMs` for it to arrive. */
45
+ export declare function readOtp(options: OtpSource, since: Date, now?: () => number): Promise<string | undefined>;
46
+ /**
47
+ * Signs in on a paused task's computer with a username and password, answers
48
+ * the website's one-time code from the mailbox or phone the app may read, and
49
+ * returns once the helper succeeded or needs a person. Call it only from
50
+ * private server code: the values never reach a model, a log or a job result.
51
+ * Success leaves the task paused; the app resumes it when it decides to.
52
+ */
53
+ export declare function signInWithOtp(handoff: Handoff, pauseId: string, login: {
54
+ allowedHosts: readonly string[];
55
+ username: string;
56
+ password: string;
57
+ }, otp: OtpSource, options?: {
58
+ pollIntervalMs?: number;
59
+ timeoutMs?: number;
60
+ now?: () => number;
61
+ }): Promise<OtpSignIn>;
62
+ export {};
@@ -0,0 +1,91 @@
1
+ const CODE_NEAR_WORD = /(?:otp|one[\s-]?time|passcode|password|code|pin)\D{0,40}?(\d{4,8})\b/i;
2
+ const CODE_ALONE = /\b(\d{4,8})\b/;
3
+ export function extractOtp(text) {
4
+ return CODE_NEAR_WORD.exec(text)?.[1] ?? CODE_ALONE.exec(text)?.[1];
5
+ }
6
+ function source(options) {
7
+ if (typeof options.execute !== 'function' || (options.email === undefined) === (options.sms === undefined))
8
+ throw new Error('An OTP source is execute plus exactly one of email or sms.');
9
+ return { kind: options.email ? 'email' : 'sms' };
10
+ }
11
+ /** The newest message from the website since `since`, as text, or undefined when none has arrived. */
12
+ async function latestMessage(options, since) {
13
+ const arrived = (at) => new Date(at).getTime() >= since.getTime();
14
+ if (options.sms) {
15
+ const { connection, from, resource = 'company-line' } = options.sms;
16
+ const answer = await options.execute(connection, { operation: 'phone.sms.list', resource, input: { since: since.toISOString(), ...(from ? { from } : {}) } });
17
+ const message = (answer.messages ?? []).filter(item => arrived(item.receivedAt)).sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))[0];
18
+ return message?.body;
19
+ }
20
+ const { connection, provider, from, resource = 'inbox' } = options.email;
21
+ if (provider === 'gmail') {
22
+ const list = await options.execute(connection, { operation: 'gmail.thread.list', resource, input: { q: `from:${from} newer_than:1d`, limit: 10 } });
23
+ const thread = (list.threads ?? []).filter(item => arrived(item.date)).sort((a, b) => b.date.localeCompare(a.date))[0];
24
+ if (!thread)
25
+ return undefined;
26
+ const message = await options.execute(connection, { operation: 'gmail.message.read', resource, input: { messageId: thread.messageId } });
27
+ return `${message.subject}\n${message.text}`;
28
+ }
29
+ const list = await options.execute(connection, { operation: 'outlook.message.list', resource, input: { q: `from:${from}` } });
30
+ const summary = (list.mailMessages ?? []).filter(item => arrived(item.receivedAt) && item.from.toLowerCase().includes(from.toLowerCase())).sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))[0];
31
+ if (!summary)
32
+ return undefined;
33
+ const message = await options.execute(connection, { operation: 'outlook.message.read', resource, input: { messageId: summary.id } });
34
+ return `${message.subject}\n${message.text}`;
35
+ }
36
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
37
+ /** Reads the website's one-time code sent since `since`, waiting up to `timeoutMs` for it to arrive. */
38
+ export async function readOtp(options, since, now = Date.now) {
39
+ source(options);
40
+ const extract = options.extract ?? extractOtp;
41
+ const deadline = now() + (options.timeoutMs ?? 180_000);
42
+ for (;;) {
43
+ const text = await latestMessage(options, since);
44
+ const code = text === undefined ? undefined : extract(text);
45
+ if (code)
46
+ return code;
47
+ if (now() >= deadline)
48
+ return undefined;
49
+ await sleep(options.pollIntervalMs ?? 5_000);
50
+ }
51
+ }
52
+ /**
53
+ * Signs in on a paused task's computer with a username and password, answers
54
+ * the website's one-time code from the mailbox or phone the app may read, and
55
+ * returns once the helper succeeded or needs a person. Call it only from
56
+ * private server code: the values never reach a model, a log or a job result.
57
+ * Success leaves the task paused; the app resumes it when it decides to.
58
+ */
59
+ export async function signInWithOtp(handoff, pauseId, login, otp, options = {}) {
60
+ source(otp);
61
+ const now = options.now ?? Date.now;
62
+ // A clock a minute ahead of the mail server must not hide the code.
63
+ const since = new Date(now() - 60_000);
64
+ await handoff.completeLogin(pauseId, login);
65
+ let answered = false;
66
+ const deadline = now() + (options.timeoutMs ?? 600_000);
67
+ for (;;) {
68
+ await sleep(options.pollIntervalMs ?? 5_000);
69
+ const check = await handoff.check();
70
+ if (check.status === 'done' || check.status === 'failed')
71
+ return { status: 'ended', taskStatus: check.status };
72
+ if (now() >= deadline)
73
+ return { status: 'human_help_needed', reason: 'The sign-in helper did not finish in time.' };
74
+ if (check.status !== 'waiting_for_input')
75
+ continue;
76
+ const login = check.login;
77
+ if (login?.status === 'success')
78
+ return { status: 'success' };
79
+ if (login?.status === 'human_help_needed')
80
+ return { status: 'human_help_needed', reason: 'The sign-in helper could not finish on its own.' };
81
+ if (login?.status === 'needs_input' && login.input === 'otp') {
82
+ if (answered)
83
+ return { status: 'human_help_needed', reason: 'The website asked for a second code.' };
84
+ const code = await readOtp(otp, since, now);
85
+ if (!code)
86
+ return { status: 'human_help_needed', reason: `No code arrived by ${otp.email ? 'email' : 'SMS'} in time.` };
87
+ await handoff.completeLogin(pauseId, { otp: code });
88
+ answered = true;
89
+ }
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.40.0",
3
+ "version": "1.42.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",