원격 릴리스 빌드에 토큰 파일 인증과 응답 제한을 적용한다

This commit is contained in:
2026-10-09 04:31:44 +09:00
parent 3d9f7392df
commit 6755f09ee7
9 changed files with 351 additions and 51 deletions
+4
View File
@@ -107,3 +107,7 @@ VITE_CASUAL_CHAT_URL=
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
TURN_BUILD_COMMIT_SHA=
# Set both only when builds use the dedicated remote builder; never expose token to VITE_*.
RELEASE_BUILDER_URL=
RELEASE_BUILDER_TOKEN_FILE=
+148 -37
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { readFileSync, statSync } from 'node:fs';
import path from 'node:path';
export interface BuildCommand {
@@ -33,7 +34,8 @@ export interface BuildRunner {
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
const RELEASE_BUILD_ENV_NAME =
/^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
export const sanitizeReleaseBuildEnv = (
env: NodeJS.ProcessEnv | Record<string, string> | undefined
@@ -236,11 +238,100 @@ interface RemoteBuildMessage {
error?: string;
}
export const MAX_REMOTE_BUILD_FRAME_BYTES = 512 * 1024;
const REMOTE_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
const record = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const validRemoteMessage = (value: unknown): value is RemoteBuildMessage => {
if (!record(value)) return false;
if (Object.keys(value).length !== 1) return false;
if (value.result) {
const result = value.result;
return (
record(result) &&
typeof result.ok === 'boolean' &&
(!result.ok || (result.exitCode === 0 && result.aborted !== true)) &&
(result.exitCode === null || (typeof result.exitCode === 'number' && Number.isInteger(result.exitCode))) &&
typeof result.output === 'string' &&
result.output.length <= MAX_BUILD_OUTPUT_CHARS &&
(result.aborted === undefined || typeof result.aborted === 'boolean')
);
}
if (typeof value.error === 'string') return value.error.length <= 2000;
if (!record(value.event)) return false;
const event = value.event;
if (event.type === 'OUTPUT')
return (
typeof event.stream === 'string' &&
['stdout', 'stderr'].includes(event.stream) &&
typeof event.message === 'string' &&
event.message.length <= 2000
);
if (
typeof event.type !== 'string' ||
!['COMMAND_START', 'COMMAND_END'].includes(event.type) ||
!record(event.command)
)
return false;
const command = event.command;
if (
typeof command.command !== 'string' ||
!['pnpm', 'node'].includes(command.command) ||
typeof command.cwd !== 'string' ||
command.cwd.length > 2048 ||
!Array.isArray(command.args) ||
command.args.length > 40 ||
!command.args.every((arg: unknown) => typeof arg === 'string' && arg.length <= 2048)
)
return false;
if (
command.env !== undefined &&
(!record(command.env) ||
Object.keys(command.env).length > 32 ||
!Object.values(command.env).every((entry) => typeof entry === 'string' && entry.length <= 8192))
)
return false;
return (
event.type === 'COMMAND_START' ||
event.exitCode === null ||
(typeof event.exitCode === 'number' && Number.isInteger(event.exitCode))
);
};
const readRemoteBuildToken = (filename: string | undefined): string => {
try {
if (!filename || !statSync(filename).isFile() || statSync(filename).size > 256) throw new Error();
const token = readFileSync(filename, 'utf8').trim();
if (token.startsWith('replace-with-') || !/^[A-Za-z0-9_-]{43,128}$/.test(token)) throw new Error();
return token;
} catch {
throw new Error('A valid RELEASE_BUILDER_TOKEN_FILE is required.');
}
};
export interface RemoteBuildOptions {
tokenFile?: string;
timeoutMs?: number;
}
export class RemoteBuildRunner implements BuildRunner {
private readonly endpoint: string;
private readonly timeoutMs: number;
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
constructor(
baseUrl: string,
private readonly fetchImpl: typeof fetch = fetch,
private readonly config: RemoteBuildOptions = {}
) {
const parsed = new URL('/v1/builds', baseUrl);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
throw new Error('Invalid release builder endpoint.');
}
this.endpoint = parsed.toString();
this.timeoutMs = config.timeoutMs ?? REMOTE_BUILD_TIMEOUT_MS;
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > REMOTE_BUILD_TIMEOUT_MS) {
throw new Error('Invalid release builder deadline.');
}
}
async run(
@@ -249,73 +340,92 @@ export class RemoteBuildRunner implements BuildRunner {
options?: BuildRunOptions
): Promise<BuildResult> {
let output = '';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
timeout.unref();
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const token = readRemoteBuildToken(this.config.tokenFile);
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
redirect: 'error',
headers: { 'content-type': 'application/json', 'x-release-builder-token': token },
body: JSON.stringify({
commands: commands.map((command) => ({
...command,
env: sanitizeReleaseBuildEnv(command.env),
})),
commands: commands.map((command) => ({ ...command, env: sanitizeReleaseBuildEnv(command.env) })),
}),
signal: options?.signal,
signal,
});
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => '');
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
};
await response.body?.cancel().catch(() => undefined);
return { ok: false, exitCode: null, output: `Release builder returned HTTP ${response.status}.` };
}
const decoder = new TextDecoder();
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let buffer = '';
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') {
output = appendOutputTail(output, `${result.event.message}\n`);
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) {
buffer += decoder.decode();
break;
}
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024 * 1024) throw new Error('Build stream too large.');
buffer += decoder.decode(chunk.value, { stream: true });
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') output = appendOutputTail(output, `${result.event.message}\n`);
if (result.result) return result.result;
if (result.error) {
if (result.error)
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
}
if (Buffer.byteLength(buffer) > MAX_REMOTE_BUILD_FRAME_BYTES) throw new Error('Build frame too large.');
}
if (buffer.trim()) {
const result = await this.handleRemoteMessage(buffer, onProgress);
if (result.result) return result.result;
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
} catch (error) {
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, 'Release builder closed without a result.'),
};
} catch {
const aborted = options?.signal?.aborted ?? false;
return {
ok: false,
exitCode: null,
output: appendOutputTail(
output,
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
aborted ? 'Build cancelled by operator.' : 'Release builder request failed.'
),
...(aborted ? { aborted: true } : {}),
};
} finally {
clearTimeout(timeout);
controller.abort();
await reader?.cancel().catch(() => undefined);
reader?.releaseLock();
}
}
private async handleRemoteMessage(
line: string,
onProgress?: BuildProgressObserver
): Promise<RemoteBuildMessage> {
let message: RemoteBuildMessage;
private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise<RemoteBuildMessage> {
if (!line.trim()) return {};
if (Buffer.byteLength(line) > MAX_REMOTE_BUILD_FRAME_BYTES)
return { error: 'Release builder frame is too large.' };
let message: unknown;
try {
message = JSON.parse(line) as RemoteBuildMessage;
message = JSON.parse(line);
} catch {
return { error: 'Release builder returned malformed progress data.' };
}
if (!validRemoteMessage(message)) return { error: 'Release builder returned invalid progress data.' };
if (message.error !== undefined) return { error: 'Release builder rejected the build request.' };
if (message.event && onProgress) await onProgress(message.event);
return message;
}
@@ -324,5 +434,6 @@ export class RemoteBuildRunner implements BuildRunner {
export const createReleaseBuildRunner = (
baseUrl: string | undefined,
localRunner: BuildRunner,
fetchImpl: typeof fetch = fetch
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
fetchImpl: typeof fetch = fetch,
tokenFile?: string
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl, { tokenFile }) : localRunner);
@@ -1023,7 +1023,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.releaseBuildRunner = createReleaseBuildRunner(
options.processConfig.releaseBuilderUrl,
options.buildRunner,
options.fetchImpl ?? fetch
options.fetchImpl ?? fetch,
options.processConfig.baseEnv?.RELEASE_BUILDER_TOKEN_FILE
);
this.workspaceManager = options.workspaceManager;
this.processConfig = options.processConfig;
+17 -2
View File
@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
RemoteBuildRunner,
@@ -34,12 +37,24 @@ describe('sanitizeReleaseBuildEnv', () => {
});
describe('RemoteBuildRunner', () => {
let directory: string;
let tokenFile: string;
beforeAll(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'builder-token-test-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterAll(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it('streams progress and returns the builder result without sending secrets', async () => {
const messages = [
{ event: { type: 'OUTPUT', stream: 'stdout', message: 'building' } },
{ result: { ok: true, exitCode: 0, output: 'building' } },
];
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
expect(init?.redirect).toBe('error');
const request = JSON.parse(String(init?.body)) as {
commands: Array<{ env: Record<string, string> }>;
};
@@ -49,7 +64,7 @@ describe('RemoteBuildRunner', () => {
});
}) as unknown as typeof fetch;
const progress: BuildProgressEvent[] = [];
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl).run(
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl, { tokenFile }).run(
[
{
command: 'pnpm',
@@ -0,0 +1,114 @@
import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RemoteBuildRunner } from '../src/orchestrator/buildRunner.js';
describe('release builder authenticated HTTP and bounded responses', () => {
let directory: string;
let tokenFile: string;
const servers: Server[] = [];
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-build-http-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
await fs.rm(directory, { recursive: true, force: true });
});
const provider = async (handler: (req: IncomingMessage, res: ServerResponse) => void) => {
const server = createServer(handler);
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Fixture listener unavailable.');
return `http://127.0.0.1:${address.port}`;
};
const commands = [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: '/srv/core/repository' }];
it('sends the file token only as a header and preserves successful stream results', async () => {
let received = '';
const endpoint = await provider((req, res) => {
expect(req.headers['x-release-builder-token']).toBe('t'.repeat(64));
req.on('data', (chunk: Buffer) => {
received += chunk.toString();
});
req.on('end', () => res.end(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: 'done' } })}\n`));
});
expect(await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).toMatchObject({
ok: true,
output: 'done',
});
expect(received).not.toContain('t'.repeat(64));
});
it('fails closed without a valid credential before sending a request', async () => {
let requests = 0;
const endpoint = await provider((_req, res) => {
requests += 1;
res.end();
});
const result = await new RemoteBuildRunner(endpoint).run(commands);
expect(result.ok).toBe(false);
expect(requests).toBe(0);
expect(result.output).not.toContain(directory);
});
it('rejects malformed and oversized progress without exposing upstream error payloads', async () => {
for (const body of [
'{"event":{"type":"OUTPUT","stream":"stdout","message":42}}\n',
'x'.repeat(512 * 1024 + 1),
'null\n',
'{"result":{"ok":true,"exitCode":2,"output":""}}\n',
]) {
const endpoint = await provider((_req, res) => res.end(body));
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
}
const endpoint = await provider((_req, res) => {
res.writeHead(500);
res.end('synthetic-sensitive-detail'.repeat(100_000));
});
const result = await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands);
expect(result.output).toBe('Release builder returned HTTP 500.');
const rejected = await provider((_req, res) =>
res.end(JSON.stringify({ error: 'synthetic-sensitive-detail' }) + '\n')
);
expect((await new RemoteBuildRunner(rejected, fetch, { tokenFile }).run(commands)).output).toBe(
'Release builder rejected the build request.'
);
});
it('does not forward the token to redirect destinations', async () => {
let requests = 0;
const destination = await provider((_req, res) => {
requests += 1;
res.end();
});
const endpoint = await provider((_req, res) => {
res.writeHead(307, { location: destination });
res.end();
});
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
expect(requests).toBe(0);
});
it('bounds header/body stalls and preserves explicit operator cancellation', async () => {
const silent = await provider(() => {});
expect((await new RemoteBuildRunner(silent, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const partial = await provider((_req, res) => {
res.writeHead(200);
res.write('{');
});
expect((await new RemoteBuildRunner(partial, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const controller = new AbortController();
const pending = new RemoteBuildRunner(partial, fetch, { tokenFile }).run(commands, undefined, {
signal: controller.signal,
});
controller.abort();
expect(await pending).toMatchObject({ ok: false, aborted: true, output: 'Build cancelled by operator.' });
});
});
+6 -1
View File
@@ -29,7 +29,12 @@ const main = async (): Promise<void> => {
baseEnv: config.baseEnv,
});
const migrationRunner = new PnpmBuildRunner();
const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner);
const releaseBuildRunner = createReleaseBuildRunner(
config.releaseBuilderUrl,
migrationRunner,
fetch,
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
);
const processManager = new Pm2ProcessManager();
const controller = new GatewayReleaseController(
repository,
@@ -160,7 +160,12 @@ export class GatewayReleaseController {
private readonly now: () => Date = () => new Date(),
private readonly fetchImpl: typeof fetch = fetch
) {
this.releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner, fetchImpl);
this.releaseBuildRunner = createReleaseBuildRunner(
config.releaseBuilderUrl,
migrationRunner,
fetchImpl,
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
);
this.artifactManager = new FrontendArtifactManager(config.frontendArtifactRoot ?? '/srv/frontend-artifacts');
}
@@ -509,7 +514,11 @@ export class GatewayReleaseController {
}
private async waitForReadiness(operationId: string): Promise<void> {
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
await this.appendLog(
operationId,
'readiness',
'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.'
);
const deadline = performance.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl =
@@ -524,10 +533,7 @@ export class GatewayReleaseController {
);
while (performance.now() < deadline) {
try {
const [api, frontend] = await Promise.all([
this.fetchImpl(apiUrl),
this.fetchImpl(frontendUrl),
]);
const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]);
const processes = await this.processManager.list();
const expected = processes.filter((process) => expectedNames.includes(process.name));
const safe = expected.filter(
@@ -447,6 +447,8 @@ describe('GatewayReleaseController', () => {
it('runs release builds remotely while keeping Gateway migration on the runtime runner', async () => {
const workspace = await createReleaseWorkspace();
const tokenFile = path.join(workspace, 'builder-token.fixture');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
const harness = createRepository();
const localCommandGroups: string[][] = [];
const remoteRequests: Array<{ commands: Array<{ args: string[] }> }> = [];
@@ -481,10 +483,15 @@ describe('GatewayReleaseController', () => {
running.delete(name);
},
},
{ ...config, releaseBuilderUrl: 'http://builder:15100' },
{
...config,
releaseBuilderUrl: 'http://builder:15100',
baseEnv: { ...config.baseEnv, RELEASE_BUILDER_TOKEN_FILE: tokenFile },
},
() => new Date('2026-08-01T00:00:00.000Z'),
async (input, init) => {
if (String(input) === 'http://builder:15100/v1/builds') {
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
remoteRequests.push(JSON.parse(String(init?.body)) as { commands: Array<{ args: string[] }> });
return new Response(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: '' } })}\n`, {
status: 200,
+37
View File
@@ -617,3 +617,40 @@ Kakao transport는 각 HTTP 요청의 header와 body 전체에 10초 deadline, d
logout과 game-session 교환에 동일한 인증 시작 budget을 강제로 적용하지 않는다.
임시 운영 stack과 공개 운영/개발 stack은 Redis와 prefix를 공유하지 않으며 새 코드의
검증 결과를 실제 공개 배포 근거로 사용하지 않는다.
### Builder 요청 인증과 자원 경계 (2026-10-09)
`RELEASE_BUILDER_URL`을 설정한 원격 build는 전용 `RELEASE_BUILDER_TOKEN_FILE`이
필요합니다. 파일의 무작위 token은 `x-release-builder-token` 헤더로만 전달하며
명령·progress event·`VITE_*`에 넣지 않습니다. 파일 누락/오류는 build 실패이며
로컬 runner로 우회하지 않습니다. HTTP redirect를 따라가지 않고 오류 response의
원문 body도 릴리스 로그에 복사하지 않습니다.
Docker builder와 runtime에 같은 파일을 read-only mount합니다. host 파일은 Git
제외·mode 0600으로 보존하고 DB/Redis/OAuth/bootstrap token과 공유하지 않습니다.
이 경계는 신뢰하는 Core repository와 dependency의 build script를 실행하기 위한
인증입니다. 악의적인 repository code를 OS 수준으로 격리하는 sandbox가 아니며
builder 파일 시스템에 있는 builder 전용 token은 신뢰하는 build code가 읽을 수
있습니다. 다른 운영 credential은 builder에 mount하거나 child env로 전달하지 않습니다.
Builder는 허용된 package의 frozen install·build·Prisma generate·frontend materialize만
실행하며 임의 task/flag, cwd/cache의 탈출 symlink와 Node preload 옵션을 거부합니다.
동시에 실행하는 job은 하나이며 실행·대기·body 읽기를 합쳐 3개까지만 받습니다.
초과 요청은 429이고, byte 기준 body 256 KiB/읽기 5초, job 전체 30분,
NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를 상한으로 둡니다.
취소·deadline·response 중단은 자식 process group을 TERM/KILL하며 실제 종료까지
대기 자리를 보존합니다. Core client도 30분 deadline과 같은 frame/stream 상한을
적용하고 progress/result의 실제 type과 길이를 검사합니다.
기존 무인증 builder에서 전환할 때는 다음 순서로 작업합니다.
1. 전용 token 파일을 준비하고 현재 이미지에 mount·환경 경로만 추가합니다.
2. 기존 builder를 유지한 채 새 Core controller self-upgrade와 Gateway UPDATE를
수행해 새 인증 client를 활성화합니다. 기존 builder는 추가 헤더를 허용합니다.
3. 인증을 강제하는 새 Docker builder와 bootstrap client를 함께 build·재생성합니다.
4. 인증 없는 POST가 401이고 정상 bootstrap/release가 성공하는지 확인합니다.
새 builder를 먼저 적용하면 구형 Core/구형 Docker bootstrap client의 요청이 401로
실패합니다. 해당 host의 wrapper와 정확한 project를 사용하고 기존 volume·active
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.