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
69 changes: 37 additions & 32 deletions scripts/smoke-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import { packRelease } from './pack-release.mjs';

const projectRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
const npmCliPath = process.env.npm_execpath;
const supportedArgs = new Set(['--skip-build']);
const supportedArgs = new Set(['--exercise-git-install', '--skip-build']);

for (const argument of process.argv.slice(2)) {
assert(supportedArgs.has(argument), `unsupported argument: ${argument}`);
}

const skipBuild = process.argv.includes('--skip-build');
const exerciseGitInstall = process.argv.includes('--exercise-git-install');

const packageJson = JSON.parse(
await readFile(join(projectRoot, 'package.json'), 'utf8'),
Expand Down Expand Up @@ -437,44 +438,48 @@ try {
]);
await verifyInstalledCli('Tarball', tarballInstallPrefix);

logStep(
'Preparing clean git source to exercise npm prepare for git installs...',
);
const gitInstallPrefix = join(tempRoot, 'git-prefix');
const { gitUrl } = await createGitInstallSource();
if (exerciseGitInstall) {
logStep(
'Preparing clean git source to exercise npm prepare for git installs...',
);
const gitInstallPrefix = join(tempRoot, 'git-prefix');
const { gitUrl } = await createGitInstallSource();

logStep('Installing from git dependency URL into isolated prefix...');
const gitInstallResult = runNpm(
['install', '-g', '--prefix', gitInstallPrefix, gitUrl],
{ allowFailure: true },
);
logStep('Installing from git dependency URL into isolated prefix...');
const gitInstallResult = runNpm(
['install', '-g', '--prefix', gitInstallPrefix, gitUrl],
{ allowFailure: true },
);

if (gitInstallResult.status === 0) {
await verifyInstalledCli('Git', gitInstallPrefix);
logStep('Git dependency install route succeeded.');
if (gitInstallResult.status === 0) {
await verifyInstalledCli('Git', gitInstallPrefix);
logStep('Git dependency install route succeeded.');
} else {
assert(
isKnownGitInstallCaveat(gitInstallResult),
[
'git dependency install failed in an unexpected way',
gitInstallResult.stdout.length === 0
? ''
: `stdout:\n${gitInstallResult.stdout}`,
gitInstallResult.stderr.length === 0
? ''
: `stderr:\n${gitInstallResult.stderr}`,
]
.filter((line) => line.length > 0)
.join('\n\n'),
);
logStep(
'Git dependency install matched the known caveat path; tarball fallback remains the guaranteed route.',
);
}
} else {
assert(
isKnownGitInstallCaveat(gitInstallResult),
[
'git dependency install failed in an unexpected way',
gitInstallResult.stdout.length === 0
? ''
: `stdout:\n${gitInstallResult.stdout}`,
gitInstallResult.stderr.length === 0
? ''
: `stderr:\n${gitInstallResult.stderr}`,
]
.filter((line) => line.length > 0)
.join('\n\n'),
);
logStep(
'Git dependency install matched the known caveat path; tarball fallback remains the guaranteed route.',
'Skipping git dependency install route; tarball install is the supported packaging path.',
);
}

logStep(
'Packaging smoke passed: tarball route succeeded, and the current git-install behavior was validated.',
);
logStep('Packaging smoke passed: tarball route succeeded.');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
16 changes: 14 additions & 2 deletions src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type Socket,
} from 'node:net';
import { homedir, tmpdir } from 'node:os';
import { join, normalize } from 'node:path';
import { dirname, join, normalize } from 'node:path';
import process from 'node:process';

import type { CommandContext } from '../context.js';
Expand All @@ -28,6 +28,7 @@ import { emitSuccess } from '../output.js';
import type { CapabilityEntry } from '../../renderer/capabilities.js';

import { createPty } from '../../pty/createPty.js';
import { resolveDefaultPlaywrightBrowsersPath } from '../../renderer/browserPath.js';
import { discoverCapabilities } from '../../renderer/capabilities.js';
import {
artifactPath,
Expand Down Expand Up @@ -217,7 +218,16 @@ function resolvePlaywrightBrowserCachePath(): string {
return normalize(overridePath);
}

return join(resolveSystemHomeDirectory(), '.cache', 'ms-playwright');
const browserCachePath = resolveDefaultPlaywrightBrowsersPath(
resolveSystemHomeDirectory(),
process.platform,
);
assert(
browserCachePath !== null,
`unsupported platform for default Playwright browser cache resolution: ${process.platform}`,
);

return normalize(browserCachePath);
}

function getDoctorDependencies(
Expand Down Expand Up @@ -533,6 +543,8 @@ export async function runSocketViabilityCheck(
overrides,
async (sessionDirectory, deps) => {
const socketFile = socketPath(sessionDirectory);
await deps.mkdir(dirname(socketFile), { recursive: true });

let server: Server | null = null;
let client: Socket | null = null;
let acceptedConnection = false;
Expand Down
7 changes: 4 additions & 3 deletions src/host/hostMain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import { rename, rm } from 'node:fs/promises';
import { mkdir, rename, rm } from 'node:fs/promises';
import { dirname } from 'node:path';
import process from 'node:process';

import { ulid } from 'ulid';
Expand Down Expand Up @@ -666,8 +667,7 @@ export async function runHost(sessionId: string): Promise<void> {
return `${command}\nprintf '%s%s\\n' '${markerPart1}' '${markerPart2}'\n`;
})()
: `${command}\n`;
const encoded = `${encodePaste(injectedText)}${encodeKey('enter')}`;
pty.write(encoded);
pty.write(injectedText);
lastActivityAt = Date.now();

const seq = await eventLog.append('input_run', {
Expand Down Expand Up @@ -1247,6 +1247,7 @@ export async function runHost(sessionId: string): Promise<void> {

try {
await writeManifest(mPath, state.snapshot());
await mkdir(dirname(sPath), { recursive: true });

if (!isSessionRunning(state)) {
await initiateShutdown();
Expand Down
56 changes: 56 additions & 0 deletions src/pty/createPty.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { chmodSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import process from 'node:process';

import type { IPty } from 'node-pty';
Expand All @@ -13,6 +16,57 @@ export interface PtyOptions {
term: string;
}

const EXECUTABLE_PERMISSION_MASK = 0o111;
const require = createRequire(import.meta.url);
const NODE_PTY_PACKAGE_DIRECTORY = dirname(
require.resolve('node-pty/package.json'),
);

function resolveDarwinSpawnHelperPath(): string | null {
if (process.platform !== 'darwin') {
return null;
}

const prebuildDirectory =
process.arch === 'arm64'
? 'darwin-arm64'
: process.arch === 'x64'
? 'darwin-x64'
: null;
if (prebuildDirectory === null) {
return null;
}

return join(
NODE_PTY_PACKAGE_DIRECTORY,
'prebuilds',
prebuildDirectory,
'spawn-helper',
);
}

function ensureDarwinSpawnHelperExecutable(): void {
const helperPath = resolveDarwinSpawnHelperPath();
if (helperPath === null) {
return;
}

try {
const helperStats = statSync(helperPath);
if (!helperStats.isFile()) {
return;
}

if ((helperStats.mode & EXECUTABLE_PERMISSION_MASK) !== 0) {
return;
}

chmodSync(helperPath, helperStats.mode | EXECUTABLE_PERMISSION_MASK);
} catch {
// Best-effort repair; node-pty will still surface a clear spawn failure.
}
}

export function createPty(options: PtyOptions): IPty {
const { command, cwd, cols, rows, env, term } = options;

Expand All @@ -35,6 +89,8 @@ export function createPty(options: PtyOptions): IPty {
invariant(typeof entryValue === 'string', 'PTY env values must be strings');
}

ensureDarwinSpawnHelperExecutable();

return spawn(file, command.slice(1), {
cwd,
cols,
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/browserPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}

function resolveDefaultPlaywrightBrowsersPath(
export function resolveDefaultPlaywrightBrowsersPath(
capturedHome: string,
platform: NodeJS.Platform,
): string | null {
Expand Down
87 changes: 80 additions & 7 deletions src/storage/sessionPaths.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { dirname, isAbsolute, resolve } from 'node:path';
import crypto from 'node:crypto';
import { basename, dirname, isAbsolute, resolve } from 'node:path';

import {
EVENT_LOG_FILENAME,
MANIFEST_FILENAME,
SOCKET_FILENAME,
} from '../config/defaults.js';
import { EVENT_LOG_FILENAME, MANIFEST_FILENAME } from '../config/defaults.js';
import { invariant } from '../util/assert.js';

const SOCKET_ROOT_DIRECTORY = '/tmp/agent-tty';
const SOCKET_HOME_ID_LENGTH = 8;
const SOCKET_ID_LENGTH = 12;

function assertNonEmptyString(
value: string,
label: string,
Expand Down Expand Up @@ -66,6 +67,78 @@ export function eventLogPath(sessionDirectory: string): string {
return childPath(sessionDirectory, EVENT_LOG_FILENAME);
}

function resolveSocketDirectory(home: string): string {
assertAbsolutePath(home, 'home');

const directory = resolve(
SOCKET_ROOT_DIRECTORY,
crypto
.createHash('sha256')
.update(resolve(home))
.digest('hex')
.slice(0, SOCKET_HOME_ID_LENGTH),
);
invariant(
dirname(directory) === resolve(SOCKET_ROOT_DIRECTORY),
'socket directory must stay within the socket root directory',
);
invariant(
basename(directory).length === SOCKET_HOME_ID_LENGTH,
'socket home identifier must have the expected length',
);

return directory;
}

function deriveSessionIdentity(sessionDirectory: string): {
home: string;
sessionId: string;
} {
assertAbsolutePath(sessionDirectory, 'sessionDir');

const normalizedSessionDirectory = resolve(sessionDirectory);
const sessionId = basename(normalizedSessionDirectory);
assertSessionId(sessionId);

const sessionsRoot = dirname(normalizedSessionDirectory);
const home = dirname(sessionsRoot);

invariant(
sessionsRoot === resolve(home, 'sessions'),
'session directory must stay within the sessions root',
);

return {
home,
sessionId,
};
}

function socketFileId(sessionId: string): string {
assertSessionId(sessionId);

const digest = crypto
.createHash('sha256')
.update(sessionId)
.digest('hex')
.slice(0, SOCKET_ID_LENGTH);
invariant(
digest.length === SOCKET_ID_LENGTH,
'socket file identifier must have the expected length',
);

return digest;
}

export function socketPath(sessionDirectory: string): string {
return childPath(sessionDirectory, SOCKET_FILENAME);
const { home, sessionId } = deriveSessionIdentity(sessionDirectory);
const socketDirectory = resolveSocketDirectory(home);
const socketFile = resolve(socketDirectory, socketFileId(sessionId));

invariant(
dirname(socketFile) === socketDirectory,
'socket path must stay within the socket directory',
);

return socketFile;
}
13 changes: 12 additions & 1 deletion test/integration/renderer-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { resolveDefaultPlaywrightBrowsersPath } from '../../src/renderer/browserPath.js';
import { resolveProfile } from '../../src/renderer/profiles.js';
import type { ReplayInput } from '../../src/renderer/types.js';
import { GhosttyWebBackend } from '../../src/renderer/ghosttyWeb/index.js';
Expand Down Expand Up @@ -127,9 +128,19 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => {

await backend.boot();

const expectedBrowserCachePath = resolveDefaultPlaywrightBrowsersPath(
previousHome,
process.platform,
);
if (expectedBrowserCachePath === null) {
throw new Error(
`expected a default Playwright browser cache path for ${process.platform}`,
);
}

expect(backend.isBooted).toBe(true);
expect(process.env.PLAYWRIGHT_BROWSERS_PATH).toBe(
join(previousHome, '.cache', 'ms-playwright'),
expectedBrowserCachePath,
);
} finally {
if (previousBrowsersPath === undefined) {
Expand Down
Loading
Loading