@robota-sdk/agent-ui-terminal 3.0.0-beta.83 → 3.0.0-beta.84
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/CHANGELOG.md +35 -0
- package/README.md +32 -13
- package/dist/node/index.d.ts +18 -3
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +21 -21
- package/dist/node/index.js.map +1 -1
- package/package.json +14 -13
- package/src/PermissionPrompt.tsx +23 -6
- package/src/SupervisedSessionView.tsx +101 -16
- package/src/TuiInteractionChannel.ts +9 -1
- package/src/__tests__/SupervisedSessionView.test.tsx +108 -0
- package/src/__tests__/helpers/terminal-profiles.ts +4 -4
- package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +11 -3
- package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
- package/src/__tests__/tui-interaction-queues.test.ts +10 -0
- package/src/__tests__/tui-state-manager.test.ts +31 -0
- package/src/__tests__/wire-tui-channel.test.ts +12 -0
- package/src/index.ts +2 -0
- package/src/tui-interaction-queues.ts +9 -1
- package/src/tui-session-event-projector.ts +12 -1
- package/src/tui-state-manager.ts +8 -2
- package/src/types.ts +3 -1
- package/src/wire-tui-channel.ts +1 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #3268 — an interactive start in an untrusted repository asks whether to trust it, on the
|
|
3
|
+
* BUILT binary in a real PTY. A yes records the grant and starts Trusted, so the next start does not
|
|
4
|
+
* ask; a no starts Restricted and records nothing, so the next start asks again.
|
|
5
|
+
*
|
|
6
|
+
* Runs under `test:pty` against the built CLI (`pnpm --filter @robota-sdk/agent-cli build` first).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
23
|
+
|
|
24
|
+
import { spawnTui } from './pty-driver.js';
|
|
25
|
+
|
|
26
|
+
import type { IPtySession } from './pty-driver.js';
|
|
27
|
+
|
|
28
|
+
const WAIT_MS = 20_000;
|
|
29
|
+
const CASE_TIMEOUT_MS = 120_000;
|
|
30
|
+
const QUESTION = /Trust this folder\? \[y\/N\]/;
|
|
31
|
+
const READY = /Type a message or \/help/;
|
|
32
|
+
|
|
33
|
+
interface IFixture {
|
|
34
|
+
readonly root: string;
|
|
35
|
+
readonly home: string;
|
|
36
|
+
readonly repo: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeFixture(): IFixture {
|
|
40
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), 'robota-trust-prompt-')));
|
|
41
|
+
const home = join(root, 'home');
|
|
42
|
+
const repo = join(root, 'repo');
|
|
43
|
+
mkdirSync(join(home, '.robota'), { recursive: true });
|
|
44
|
+
// Skip the first-run welcome and provider setup: neither is what this scenario observes.
|
|
45
|
+
writeFileSync(join(home, '.robota', 'onboarded'), new Date().toISOString(), 'utf8');
|
|
46
|
+
writeFileSync(
|
|
47
|
+
join(home, '.robota', 'settings.json'),
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
currentProvider: 'anthropic',
|
|
50
|
+
providers: {
|
|
51
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
'utf8',
|
|
55
|
+
);
|
|
56
|
+
execFileSync('git', ['init', '-q', repo]);
|
|
57
|
+
return { root, home, repo };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function trustedGrants(fixture: IFixture): number {
|
|
61
|
+
const store = join(fixture.home, '.robota', 'workspace-trust.json');
|
|
62
|
+
if (!existsSync(store)) return 0;
|
|
63
|
+
const parsed = JSON.parse(readFileSync(store, 'utf8')) as { grants: { state: string }[] };
|
|
64
|
+
return parsed.grants.filter((grant) => grant.state === 'trusted').length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function answerAndExit(session: IPtySession, answer: 'y' | 'n'): Promise<void> {
|
|
68
|
+
await session.waitFor(QUESTION, WAIT_MS);
|
|
69
|
+
await session.sendKeys(answer);
|
|
70
|
+
await session.pressEnter();
|
|
71
|
+
await session.waitFor(READY, WAIT_MS);
|
|
72
|
+
await session.sendKeys('/exit');
|
|
73
|
+
await session.pressEnter();
|
|
74
|
+
await session.waitFor(/Exit the session\?/, WAIT_MS);
|
|
75
|
+
await session.pressEnter();
|
|
76
|
+
await session.expectExit(WAIT_MS);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('the trust question at an interactive start (issue #3268)', () => {
|
|
80
|
+
let fixture: IFixture | undefined;
|
|
81
|
+
let session: IPtySession | undefined;
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
await session?.disposeAsync();
|
|
85
|
+
session = undefined;
|
|
86
|
+
if (fixture) rmSync(fixture.root, { recursive: true, force: true });
|
|
87
|
+
fixture = undefined;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it(
|
|
91
|
+
'a yes records the grant, and the next start is Trusted without asking',
|
|
92
|
+
async () => {
|
|
93
|
+
fixture = makeFixture();
|
|
94
|
+
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
|
|
95
|
+
await answerAndExit(session, 'y');
|
|
96
|
+
expect(trustedGrants(fixture)).toBe(1);
|
|
97
|
+
|
|
98
|
+
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
|
|
99
|
+
await session.waitFor(READY, WAIT_MS);
|
|
100
|
+
expect(session.snapshot()).not.toMatch(QUESTION);
|
|
101
|
+
},
|
|
102
|
+
CASE_TIMEOUT_MS,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
['--safe-mode', ['--safe-mode']],
|
|
107
|
+
// A resumed or continued session keeps the store it was saved in, so it is not asked.
|
|
108
|
+
['--continue', ['--continue']],
|
|
109
|
+
])(
|
|
110
|
+
'does not ask with %s',
|
|
111
|
+
async (_case, args) => {
|
|
112
|
+
fixture = makeFixture();
|
|
113
|
+
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home, args: [...args] });
|
|
114
|
+
await session.waitFor(READY, WAIT_MS);
|
|
115
|
+
expect(session.snapshot()).not.toMatch(QUESTION);
|
|
116
|
+
expect(trustedGrants(fixture)).toBe(0);
|
|
117
|
+
},
|
|
118
|
+
CASE_TIMEOUT_MS,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
it(
|
|
122
|
+
'a no starts Restricted, records nothing, and the next start asks again',
|
|
123
|
+
async () => {
|
|
124
|
+
fixture = makeFixture();
|
|
125
|
+
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
|
|
126
|
+
await answerAndExit(session, 'n');
|
|
127
|
+
expect(trustedGrants(fixture)).toBe(0);
|
|
128
|
+
|
|
129
|
+
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
|
|
130
|
+
await session.waitFor(QUESTION, WAIT_MS);
|
|
131
|
+
},
|
|
132
|
+
CASE_TIMEOUT_MS,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
@@ -136,4 +136,14 @@ describe('TuiPermissionQueue', () => {
|
|
|
136
136
|
queue.current?.resolve(true);
|
|
137
137
|
await expect(second).resolves.toBe(true);
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
it('issue #3288 §1: carries the requester onto the current entry', async () => {
|
|
141
|
+
const queue = new TuiPermissionQueue(vi.fn());
|
|
142
|
+
const requester = { kind: 'background-agent' as const, label: 'general-purpose', taskId: 'agent_1' };
|
|
143
|
+
const pending = queue.enqueue('Glob', { pattern: '**/*' }, 'permission-1', false, undefined, requester);
|
|
144
|
+
|
|
145
|
+
expect(queue.current?.requester).toEqual(requester);
|
|
146
|
+
queue.current?.resolve(false);
|
|
147
|
+
await expect(pending).resolves.toBe(false);
|
|
148
|
+
});
|
|
139
149
|
});
|
|
@@ -71,6 +71,37 @@ describe('TuiStateManager', () => {
|
|
|
71
71
|
expect(mgr.activeTools[0]!.result).toBe('success');
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it('#3288: two parallel same-named calls are attributed by executionId, not by name+running order', () => {
|
|
75
|
+
const mgr = new TuiStateManager();
|
|
76
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: 'a.ts', isRunning: true, executionId: 'exec-a' });
|
|
77
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: 'b.ts', isRunning: true, executionId: 'exec-b' });
|
|
78
|
+
// The FIRST-started call (a.ts) finishes first, while the SECOND (b.ts) is still running.
|
|
79
|
+
// `findLastIndex` by name+running alone would pick the most-recently-STARTED entry (b.ts) —
|
|
80
|
+
// the wrong one — because it is still running and sits last in the array.
|
|
81
|
+
mgr.onToolEnd({
|
|
82
|
+
toolName: 'Read',
|
|
83
|
+
firstArg: 'a.ts',
|
|
84
|
+
isRunning: false,
|
|
85
|
+
result: 'success',
|
|
86
|
+
executionId: 'exec-a',
|
|
87
|
+
});
|
|
88
|
+
const a = mgr.activeTools.find((t) => t.executionId === 'exec-a');
|
|
89
|
+
const b = mgr.activeTools.find((t) => t.executionId === 'exec-b');
|
|
90
|
+
expect(a?.isRunning).toBe(false);
|
|
91
|
+
expect(a?.firstArg).toBe('a.ts');
|
|
92
|
+
expect(b?.isRunning).toBe(true);
|
|
93
|
+
expect(b?.firstArg).toBe('b.ts');
|
|
94
|
+
|
|
95
|
+
mgr.onToolEnd({
|
|
96
|
+
toolName: 'Read',
|
|
97
|
+
firstArg: 'b.ts',
|
|
98
|
+
isRunning: false,
|
|
99
|
+
result: 'success',
|
|
100
|
+
executionId: 'exec-b',
|
|
101
|
+
});
|
|
102
|
+
expect(mgr.activeTools.every((t) => !t.isRunning)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
74
105
|
it('clears tools on thinking=true (next execution start)', () => {
|
|
75
106
|
const mgr = new TuiStateManager();
|
|
76
107
|
mgr.onToolStart({ toolName: 'Read', firstArg: '', isRunning: true });
|
|
@@ -306,6 +306,18 @@ describe('WireTuiChannel', () => {
|
|
|
306
306
|
await channel.stop();
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
+
it('issue #3288 §1: carries a background agent requester onto the shown permission request', async () => {
|
|
310
|
+
const { channel, link } = await attached();
|
|
311
|
+
const requester = { kind: 'background-agent' as const, label: 'general-purpose', taskId: 'agent_1' };
|
|
312
|
+
link.push({
|
|
313
|
+
type: 'permission_request',
|
|
314
|
+
event: { id: 'p1', toolName: 'Glob', toolArgs: { pattern: '**/*' }, requester },
|
|
315
|
+
});
|
|
316
|
+
expect(channel.getSnapshot().permissionRequest).toMatchObject({ toolName: 'Glob', requester });
|
|
317
|
+
channel.getSnapshot().permissionRequest?.resolve(false);
|
|
318
|
+
await channel.stop();
|
|
319
|
+
});
|
|
320
|
+
|
|
309
321
|
it('answers an ask through resolveUserAction as ask-response', async () => {
|
|
310
322
|
const { channel, link } = await attached();
|
|
311
323
|
link.sent.length = 0;
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type {
|
|
|
8
8
|
ISupervisedAttachRequest,
|
|
9
9
|
ISupervisedViewRow,
|
|
10
10
|
ISupervisedSessionViewProps,
|
|
11
|
+
ISupervisedStartTrustQuestion,
|
|
12
|
+
TSupervisedStartTrustChoice,
|
|
11
13
|
TSupervisedViewExit,
|
|
12
14
|
} from './SupervisedSessionView.js';
|
|
13
15
|
export { TuiInteractionChannel } from './TuiInteractionChannel.js';
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { IPendingPermissionRequest } from './types.js';
|
|
2
2
|
import type { IActionRequest, TActionResponse, TToolArgs } from '@robota-sdk/agent-core';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
TPermissionRequester,
|
|
5
|
+
TPermissionResultValue,
|
|
6
|
+
} from '@robota-sdk/agent-interface-session';
|
|
4
7
|
|
|
5
8
|
interface IUserActionQueueEntry {
|
|
6
9
|
presentedRequest: IActionRequest;
|
|
@@ -13,6 +16,8 @@ interface IPermissionQueueEntry {
|
|
|
13
16
|
toolArgs: TToolArgs;
|
|
14
17
|
canPersistProjectPermission?: boolean;
|
|
15
18
|
requestedByPeer?: string;
|
|
19
|
+
/** Issue #3288 §1: a background agent's own request, forwarded to the person. */
|
|
20
|
+
requester?: TPermissionRequester;
|
|
16
21
|
resolve: (result: TPermissionResultValue) => void;
|
|
17
22
|
id?: string;
|
|
18
23
|
}
|
|
@@ -96,6 +101,7 @@ export class TuiPermissionQueue {
|
|
|
96
101
|
id?: string,
|
|
97
102
|
canPersistProjectPermission?: boolean,
|
|
98
103
|
requestedByPeer?: string,
|
|
104
|
+
requester?: TPermissionRequester,
|
|
99
105
|
): Promise<TPermissionResultValue> {
|
|
100
106
|
return new Promise<TPermissionResultValue>((resolve) => {
|
|
101
107
|
this.entries.push({
|
|
@@ -105,6 +111,7 @@ export class TuiPermissionQueue {
|
|
|
105
111
|
...(id !== undefined ? { id } : {}),
|
|
106
112
|
...(canPersistProjectPermission !== undefined ? { canPersistProjectPermission } : {}),
|
|
107
113
|
...(requestedByPeer !== undefined ? { requestedByPeer } : {}),
|
|
114
|
+
...(requester !== undefined ? { requester } : {}),
|
|
108
115
|
});
|
|
109
116
|
this.processNext();
|
|
110
117
|
});
|
|
@@ -149,6 +156,7 @@ export class TuiPermissionQueue {
|
|
|
149
156
|
? { canPersistProjectPermission: next.canPersistProjectPermission }
|
|
150
157
|
: {}),
|
|
151
158
|
...(next.requestedByPeer !== undefined ? { requestedByPeer: next.requestedByPeer } : {}),
|
|
159
|
+
...(next.requester !== undefined ? { requester: next.requester } : {}),
|
|
152
160
|
resolve: (result) => {
|
|
153
161
|
if (this.entries[0] !== next) return;
|
|
154
162
|
this.entries.shift();
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
IExecutionResult,
|
|
14
14
|
IInteractiveSessionEvents,
|
|
15
15
|
TInteractiveEventName,
|
|
16
|
+
TPermissionRequester,
|
|
16
17
|
TPermissionResultValue,
|
|
17
18
|
} from '@robota-sdk/agent-interface-session';
|
|
18
19
|
|
|
@@ -25,6 +26,8 @@ export interface ITuiSessionEventProjectorOptions {
|
|
|
25
26
|
id: string,
|
|
26
27
|
canPersistProjectPermission?: boolean,
|
|
27
28
|
requestedByPeer?: string,
|
|
29
|
+
/** Issue #3288 §1: a background agent's own request, forwarded to the person. */
|
|
30
|
+
requester?: TPermissionRequester,
|
|
28
31
|
) => Promise<TPermissionResultValue>;
|
|
29
32
|
askUser: (request: IActionRequest, id: string) => Promise<TActionResponse>;
|
|
30
33
|
dismissPrompt: (id: string) => void;
|
|
@@ -86,6 +89,7 @@ export class TuiSessionEventProjector {
|
|
|
86
89
|
toolArgs,
|
|
87
90
|
canPersistProjectPermission,
|
|
88
91
|
requesterDriverId,
|
|
92
|
+
requester,
|
|
89
93
|
}) => {
|
|
90
94
|
attention?.onNeedsInput();
|
|
91
95
|
// A peer turn's ask names the peer, printed only as a plain identifier.
|
|
@@ -93,7 +97,14 @@ export class TuiSessionEventProjector {
|
|
|
93
97
|
? printablePeerDriver(requesterDriverId)
|
|
94
98
|
: undefined;
|
|
95
99
|
void this.options
|
|
96
|
-
.requestPermission(
|
|
100
|
+
.requestPermission(
|
|
101
|
+
toolName,
|
|
102
|
+
toolArgs,
|
|
103
|
+
id,
|
|
104
|
+
canPersistProjectPermission,
|
|
105
|
+
requestedByPeer,
|
|
106
|
+
requester,
|
|
107
|
+
)
|
|
97
108
|
.then((result) => session.resolvePermission(id, result))
|
|
98
109
|
.catch(() => session.resolvePermission(id, false));
|
|
99
110
|
};
|
package/src/tui-state-manager.ts
CHANGED
|
@@ -130,8 +130,14 @@ export class TuiStateManager {
|
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
onToolEnd = (state: IToolState): void => {
|
|
133
|
-
//
|
|
134
|
-
|
|
133
|
+
// #3288: attribute by executionId first — two same-named parallel calls can finish out of
|
|
134
|
+
// start order, and `findLastIndex` by name alone would close the wrong (most recently
|
|
135
|
+
// started, not most recently finished) running entry. Fall back to the name+running-order
|
|
136
|
+
// heuristic only when the event carries no executionId (legacy fixtures / old hosts).
|
|
137
|
+
const idx =
|
|
138
|
+
state.executionId !== undefined
|
|
139
|
+
? this.activeTools.findIndex((t) => t.executionId === state.executionId && t.isRunning)
|
|
140
|
+
: this.activeTools.findLastIndex((t) => t.toolName === state.toolName && t.isRunning);
|
|
135
141
|
if (idx !== -1) {
|
|
136
142
|
const updated = [...this.activeTools];
|
|
137
143
|
updated[idx] = state;
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** UI-layer permission types for the Ink TUI */
|
|
2
2
|
|
|
3
3
|
import type { TToolArgs } from '@robota-sdk/agent-core';
|
|
4
|
-
import type { TPermissionResultValue } from '@robota-sdk/agent-interface-session';
|
|
4
|
+
import type { TPermissionRequester, TPermissionResultValue } from '@robota-sdk/agent-interface-session';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Permission result: true (allow once), false (deny), 'allow-session' (remember for session),
|
|
@@ -15,5 +15,7 @@ export interface IPendingPermissionRequest {
|
|
|
15
15
|
canPersistProjectPermission?: boolean;
|
|
16
16
|
/** The peer session whose message this call serves, when a peer turn asked. */
|
|
17
17
|
requestedByPeer?: string;
|
|
18
|
+
/** Issue #3288 §1: a background agent's own request, forwarded to the person. */
|
|
19
|
+
requester?: TPermissionRequester;
|
|
18
20
|
resolve: (result: TPermissionResult) => void;
|
|
19
21
|
}
|
package/src/wire-tui-channel.ts
CHANGED
|
@@ -730,6 +730,7 @@ export class WireTuiChannel implements ITuiAppChannelPort {
|
|
|
730
730
|
event.id,
|
|
731
731
|
event.canPersistProjectPermission,
|
|
732
732
|
requestedByPeer,
|
|
733
|
+
event.requester,
|
|
733
734
|
)
|
|
734
735
|
.then((result) =>
|
|
735
736
|
this.answer(generation, event.id, { type: 'permission-response', id: event.id, result }),
|