@@ -193,16 +185,14 @@ export default meta;
type Story = StoryObj;
export const CommonCase: Story = {};
-export const NarrowHelperHeader: Story = { args: { initialScenario: 'preserved', paneWidth: 660 } };
+export const NarrowHelperHeader: Story = { args: { initialScenario: 'userInput', paneWidth: 660 } };
export const NoPorts: Story = { args: { initialScenario: 'noPorts' } };
-export const AutorunRunning: Story = { args: { initialScenario: 'running' } };
export const DifferentDirectory: Story = { args: { initialScenario: 'differentDirectory' } };
export const MultiplePorts: Story = { args: { initialScenario: 'multiplePorts' } };
export const Notification: Story = { args: { initialScenario: 'notification' } };
-export const AutorunOff: Story = { args: { initialScenario: 'autorunOff' } };
+export const HelperExited: Story = { args: { initialScenario: 'exited' } };
export const PortScanFailed: Story = { args: { initialScenario: 'scanFailed' } };
export const TitleSources: Story = { args: { initialDetail: 'title' } };
-export const ModifyAutorun: Story = { args: { initialDetail: 'modify' } };
export const ResetConfirmation: Story = { args: { initialScenario: 'editor', initialDetail: 'reset' } };
export const MinimumWidth: Story = { args: { paneWidth: 280, paneHeight: 620 } };
diff --git a/lib/src/stories/settle-terminals.ts b/lib/src/stories/settle-terminals.ts
index 98a3ddbc1..e6032ee9c 100644
--- a/lib/src/stories/settle-terminals.ts
+++ b/lib/src/stories/settle-terminals.ts
@@ -47,21 +47,9 @@ export async function settleTerminals(opts?: { timeoutMs?: number }): Promise {
await requireElement('[data-terminal-context]', 'terminal context');
- const settled = () => {
- const status = document.querySelector('[data-terminal-context] [aria-label$="terminal status"]');
- return !!status && !status.querySelector('.animate-spin');
- };
- await waitForCondition(settled, opts);
- if (!settled()) throw new Error('terminal context autorun never finished');
await settleTerminals(opts);
}
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index 3c0185826..ea05f6155 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -27,13 +27,13 @@
"docs/specs/security-audit.md": 5400,
"docs/specs/security-ci.md": 3400,
"docs/specs/security-hosted.md": 4800,
- "docs/specs/security-local.md": 7200,
+ "docs/specs/security-local.md": 7150,
"docs/specs/security-remote.md": 8250,
"docs/specs/security-supply-chain.md": 1650,
"docs/specs/security.md": 3350,
"docs/specs/shortcuts.md": 1200,
"docs/specs/standalone.md": 6750,
- "docs/specs/terminal-context.md": 1100,
+ "docs/specs/terminal-context.md": 850,
"docs/specs/terminal-escapes.md": 1850,
"docs/specs/terminal-state.md": 2450,
"docs/specs/theme.md": 1850,
diff --git a/standalone/sidecar/helper-terminal.test.js b/standalone/sidecar/helper-terminal.test.js
index 6db3bba08..5543aca79 100644
--- a/standalone/sidecar/helper-terminal.test.js
+++ b/standalone/sidecar/helper-terminal.test.js
@@ -118,13 +118,13 @@ test('helper ownership survives a live listing and promotion preserves the PTY a
},
}, { replay: true, onHelper: (id, helper) => decisions.push([id, helper]) });
manager.spawn('parent');
- manager.spawn('helper', { helper: { parentId: 'parent', command: 'git status' } });
+ manager.spawn('helper', { helper: { parentId: 'parent' } });
assert.deepEqual(decisions, [['parent', false], ['helper', true]]);
assert.equal(manager.getHelperParentId('helper'), 'parent');
assert.equal(manager.getHelperParentId('parent'), undefined);
spawned[1].output('unsaved editor text');
manager.list();
- assert.deepEqual(events.findLast(e => e.event === 'list').data.ptys[1].helper, { parentId: 'parent', command: 'git status' });
+ assert.deepEqual(events.findLast(e => e.event === 'list').data.ptys[1].helper, { parentId: 'parent' });
assert.equal(events.findLast(e => e.event === 'replay').data.data, 'unsaved editor text');
manager.context({ op: 'promote', id: 'helper' }, 'promote-1');
assert.deepEqual(decisions.at(-1), ['helper', false]);
@@ -133,17 +133,17 @@ test('helper ownership survives a live listing and promotion preserves the PTY a
assert.equal(events.findLast(e => e.event === 'list').data.ptys[1].helper, undefined);
assert.equal(spawned.length, 2);
assert.equal(events.findLast(e => e.event === 'replay').data.data, 'unsaved editor text');
- manager.context({ op: 'promote', id: 'helper', restore: { parentId: 'parent', command: 'git status' } }, 'restore-1');
+ manager.context({ op: 'promote', id: 'helper', restore: { parentId: 'parent' } }, 'restore-1');
manager.list();
assert.equal(events.findLast(e => e.event === 'list').data.ptys[1].helper.parentId, 'parent');
assert.deepEqual(decisions.at(-1), ['helper', true]);
const decided = decisions.length;
- manager.context({ op: 'promote', id: 'parent', restore: { parentId: 'parent', command: '' } }, 'invalid-self');
+ manager.context({ op: 'promote', id: 'parent', restore: { parentId: 'parent' } }, 'invalid-self');
assert.match(events.at(-1).data.error, /Invalid helper owner/);
// A refused promotion decides nothing.
assert.equal(decisions.length, decided);
manager.spawn('other');
- manager.context({ op: 'promote', id: 'other', restore: { parentId: 'parent', command: '' } }, 'invalid-duplicate');
+ manager.context({ op: 'promote', id: 'other', restore: { parentId: 'parent' } }, 'invalid-duplicate');
assert.match(events.at(-1).data.error, /Invalid helper owner/);
manager.killAll();
});
@@ -168,9 +168,9 @@ test('a helper cannot own another helper or reference an absent owner', () => {
spawn() { spawns++; return { pid: 42, write() {}, resize() {}, kill() {}, onData() {}, onExit() {} }; },
});
manager.spawn('parent');
- manager.spawn('helper', { helper: { parentId: 'parent', command: '' } });
- manager.spawn('nested', { helper: { parentId: 'helper', command: '' } });
- manager.spawn('orphan', { helper: { parentId: 'missing', command: '' } });
+ manager.spawn('helper', { helper: { parentId: 'parent' } });
+ manager.spawn('nested', { helper: { parentId: 'helper' } });
+ manager.spawn('orphan', { helper: { parentId: 'missing' } });
assert.equal(spawns, 2);
assert.deepEqual(events.filter(e => e.event === 'exit').map(e => e.data.id), ['nested', 'orphan']);
manager.killAll();
@@ -197,19 +197,41 @@ test('directory opening touches the path only through the host resolver', async
assert.equal(realpath.mock.callCount(), 0);
});
-test('settings answer an empty autorun until the user saves one, which then persists', async () => {
+// `helper-terminal.json` is what an earlier build saved. Nothing here may read
+// it, rewrite it, or let its command reach a helper.
+test('a stored helper command reaches nothing: no answer, no write, no rewrite', async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dor-helper-settings-'));
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
process.env.HOME = process.env.USERPROFILE = home;
try {
- const answers = [];
- const manager = create((event, data) => { if (event === 'context') answers.push(data); }, { spawn: () => { throw new Error('no spawn'); } });
- await manager.context({ op: 'settings' }, 'factory');
- assert.equal(answers.at(-1).command, '');
- assert.equal(fs.existsSync(path.join(home, '.dormouse', 'helper-terminal.json')), false);
- await manager.context({ op: 'settings', command: 'git status' }, 'opt-in');
- await manager.context({ op: 'settings' }, 'read');
- assert.equal(answers.at(-1).command, 'git status');
+ const stored = path.join(home, '.dormouse', 'helper-terminal.json');
+ fs.mkdirSync(path.dirname(stored));
+ const contents = JSON.stringify({ command: 'git status' });
+ fs.writeFileSync(stored, contents);
+ const events = [];
+ const writes = [];
+ const manager = create((event, data) => events.push({ event, data }), {
+ spawn: () => ({ pid: 42, write: (data) => writes.push(data), resize() {}, kill() {}, onData() {}, onExit() {} }),
+ });
+ const answer = async (request) => {
+ await manager.context(request, 'context');
+ return events.findLast(e => e.event === 'context').data;
+ };
+ assert.deepEqual(await answer({ op: 'home' }), { requestId: 'context', home });
+ manager.spawn('parent');
+ manager.spawn('helper', { helper: { parentId: 'parent' } });
+ assert.equal(manager.getHelperParentId('helper'), 'parent');
+ assert.deepEqual(writes, []);
+ // An older webview's edit is refused, its shape otherwise harmless.
+ assert.match((await answer({ op: 'settings', command: 'echo edited' })).error, /Unknown/);
+ assert.equal(fs.readFileSync(stored, 'utf8'), contents);
+ manager.spawn('older-parent');
+ manager.spawn('older', { helper: { parentId: 'older-parent', command: 'git status' } });
+ assert.equal(manager.getHelperParentId('older'), 'older-parent');
+ manager.list();
+ assert.deepEqual(events.findLast(e => e.event === 'list').data.ptys.find(pty => pty.id === 'older').helper, { parentId: 'older-parent' });
+ assert.deepEqual(writes, []);
+ manager.killAll();
} finally {
for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
fs.rmSync(home, { recursive: true, force: true });
diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js
index 9340704a5..6b1cd34ef 100644
--- a/standalone/sidecar/pty-core.js
+++ b/standalone/sidecar/pty-core.js
@@ -910,11 +910,6 @@ async function readProcessTable(runtime) {
return null;
}
-/** The global autorun command: one line, at most 4096 characters (docs/specs/terminal-context.md). */
-function validCommand(value) {
- return typeof value === 'string' && value.length <= 4096 && !/[\r\n\0]/.test(value);
-}
-
/** Format a Linux /proc/net hex IPv4 address (little-endian per byte). */
function parseHexIpv4(hex) {
const octets = [];
@@ -1532,7 +1527,7 @@ module.exports.create = function create(send, ptyModule, { platform = process.pl
const pty = ptyModule;
const ptys = new Map(); // id -> pty.IPty
- const helpers = new Map(); // id -> { parentId, command } for helper PTYs
+ const helpers = new Map(); // id -> { parentId } for helper PTYs
// Every spawned, unkilled PTY (exited ones included). Only the standalone host
// asks for `replay`; VS Code's extension host keeps its own buffers.
// `chars` is what the buffer currently holds (a trim decrements it);
@@ -1600,7 +1595,7 @@ module.exports.create = function create(send, ptyModule, { platform = process.pl
}
function validHelperOwner(id, helper) {
- return typeof helper.parentId === 'string' && validCommand(helper.command)
+ return typeof helper.parentId === 'string'
&& helper.parentId !== id && sessions.has(helper.parentId) && !helpers.has(helper.parentId)
&& ![...helpers].some(([otherId, other]) => otherId !== id && other.parentId === helper.parentId);
}
@@ -1627,7 +1622,7 @@ module.exports.create = function create(send, ptyModule, { platform = process.pl
return;
}
if (options?.helper && validHelperOwner(id, options.helper)) {
- helpers.set(id, { parentId: options.helper.parentId, command: options.helper.command });
+ helpers.set(id, { parentId: options.helper.parentId });
} else if (options?.helper) {
send("exit", { id, exitCode: 1 });
return;
@@ -1907,31 +1902,13 @@ module.exports.create = function create(send, ptyModule, { platform = process.pl
}
}
- // Only explicit settings edits write this installation-global preference. No
- // terminal transcripts or session layout are persisted here.
- const settingsPath = path.join(os.homedir(), '.dormouse', 'helper-terminal.json');
- function savedCommand() {
- try {
- const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
- if (validCommand(saved.command)) return saved.command;
- } catch { /* Factory default until explicitly saved. */ }
- return '';
- }
const shellBasename = (value) => path.basename(value).replace(/^-/, '').replace(/\.exe$/i, '').toLowerCase();
async function context(request, requestId) {
const result = { requestId };
try {
const p = ptys.get(request.id);
- if (request.op === 'settings') {
- if (request.command !== undefined) {
- if (!validCommand(request.command)) throw new Error('Use a single command line (up to 4096 characters)');
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
- const temporary = settingsPath + '.' + process.pid + '.tmp';
- fs.writeFileSync(temporary, JSON.stringify({ command: request.command }), { mode: 0o600 });
- fs.renameSync(temporary, settingsPath);
- }
+ if (request.op === 'home') {
result.home = os.homedir();
- result.command = request.command !== undefined ? request.command : savedCommand();
} else if (request.op === 'info') {
// Left null when inspection throws: unknown work is never idle.
result.busy = null;
@@ -1943,7 +1920,7 @@ module.exports.create = function create(send, ptyModule, { platform = process.pl
} else if (request.op === 'promote') {
if (request.restore) {
if (!sessions.has(request.id) || !validHelperOwner(request.id, request.restore)) throw new Error('Invalid helper owner');
- helpers.set(request.id, { parentId: request.restore.parentId, command: request.restore.command });
+ helpers.set(request.id, { parentId: request.restore.parentId });
} else helpers.delete(request.id);
onHelper(request.id, helpers.has(request.id));
} else if (request.op === 'openDirectory') {
diff --git a/standalone/src/window-restore.test.ts b/standalone/src/window-restore.test.ts
index 53718ffdb..8a205a54d 100644
--- a/standalone/src/window-restore.test.ts
+++ b/standalone/src/window-restore.test.ts
@@ -91,7 +91,7 @@ describe("routeUnownedPtys", () => {
const ptys: PtyInfo[] = [
{ id: "a1", alive: true },
{ id: "b1", alive: true },
- { id: "h1", alive: true, helper: { parentId: "b1", command: "git status" } },
+ { id: "h1", alive: true, helper: { parentId: "b1" } },
];
const { extra, unowned } = routeUnownedPtys(ptys, saved);
@@ -109,7 +109,7 @@ describe("routeUnownedPtys", () => {
};
const ptys: PtyInfo[] = [
{ id: "fresh", alive: true },
- { id: "h1", alive: true, helper: { parentId: "fresh", command: "git status" } },
+ { id: "h1", alive: true, helper: { parentId: "fresh" } },
];
const { extra, unowned } = routeUnownedPtys(ptys, saved);
@@ -132,7 +132,7 @@ describe("restoreWindowOrFresh", () => {
],
activeWorkspaceId: "ws-a",
};
- const helper = { parentId: "b1", command: "git status" };
+ const helper = { parentId: "b1" };
const { platform } = fakePlatform(
[
{ id: "a1", alive: true },
@@ -150,7 +150,7 @@ describe("restoreWindowOrFresh", () => {
expect(plans["ws-b"].initialPaneIds).toEqual(["b1"]);
expect(plans["ws-b"].restoredLathLayout).toEqual(saved.workspaces[1].session.lathLayout);
// The helper is restored as one — parked beside its source, not a pane.
- expect(getHelper("b1")?.status).toBe("preserved");
+ expect(getHelper("b1")).toBeDefined();
forgetHelper("b1");
});
diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts
index 7583066e6..e7da3d0bd 100644
--- a/vscode-ext/src/message-router.ts
+++ b/vscode-ext/src/message-router.ts
@@ -563,7 +563,7 @@ export function attachRouter(
switch (msg.type) {
case 'pty:context': {
const request = msg.request;
- if ((request.op !== 'settings' && !ownedPtyIds.has(request.id)) || (request.op === 'promote' && request.restore && !ownedPtyIds.has(request.restore.parentId))) {
+ if ((request.op !== 'home' && !ownedPtyIds.has(request.id)) || (request.op === 'promote' && request.restore && !ownedPtyIds.has(request.restore.parentId))) {
post({ type: 'pty:contextResult', requestId: msg.requestId, result: { error: 'Terminal is not owned by this workspace' } });
break;
}
diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts
index 50e00dafa..e8d374409 100644
--- a/vscode-ext/test/message-router.test.ts
+++ b/vscode-ext/test/message-router.test.ts
@@ -189,7 +189,7 @@ it('reports rejected helper creation as an exited terminal', () => {
const webview = fakeWebview();
const disposable = router.attachRouter(webview.channel, {});
try {
- webview.send({ type: 'pty:spawn', id: 'rejected-helper', options: { helper: { parentId: 'unowned-parent', command: 'git status' } } });
+ webview.send({ type: 'pty:spawn', id: 'rejected-helper', options: { helper: { parentId: 'unowned-parent' } } });
expect(webview.posted).toContainEqual({ type: 'pty:exit', id: 'rejected-helper', exitCode: 1 });
} finally { disposable.dispose(); }
});
diff --git a/website/src/lib/playground-shells.test.ts b/website/src/lib/playground-shells.test.ts
index 191351c70..1224b7866 100644
--- a/website/src/lib/playground-shells.test.ts
+++ b/website/src/lib/playground-shells.test.ts
@@ -117,7 +117,7 @@ describe("PlaygroundShellRegistry with a filesystem", () => {
const registry = new PlaygroundShellRegistry(adapter, () => createProgram(), { fs, cwd: "/home/demo/p" });
adapter.spawnPty("one");
- adapter.spawnPty("helper", { helper: { parentId: "one", command: "git status" } });
+ adapter.spawnPty("helper", { helper: { parentId: "one" } });
await Promise.resolve();
expect(output.one).toContain("~/p");