/**
* 開発用の偽制御盤(指示書AY 段階11-3。D23・D24)。
*
* D-Watch の `/api/ds-agent/` をポーリングし、list / export / send に固定の応答を返す。
* ★本物の常駐プログラム(`dwatch-log-agent`)の代わりにはならない★ — 非 fixtures の通し確認用。
*
* 実行例(トークンは画面で発行した値。★値をログに出さない★):
* $env:DS_AGENT_TOKEN='…'
* npx tsx scripts/ds-agent-fake.ts --server http://localhost:3100 --zip .\scratch\logs.zip
* npx tsx scripts/ds-agent-fake.ts --server http://localhost:3100 --zip .\scratch\logs.zip --fail-prepare too_large
*
* Args: --server <url> --zip <path> [--fail-prepare <code>]
* Env: DS_AGENT_TOKEN(必須。Bearer)
*/
import { createHash } from "node:crypto";
import { readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
const AGENT_VERSION = "fake-0.1.0";
const POLL_WAIT_SEC = 25;
/** チャンク POST のタイムアウト(秒。D20。設計書と同じ) */
const CHUNK_POST_TIMEOUT_SEC = 60;
const CHUNK_MAX_BYTES = 4 * 1024 * 1024;
const USER_AGENT = `dwatch-ds-agent-fake/${AGENT_VERSION}`;
const LOG_TYPES = [
{ key: "api_client", label: "API 通信(報告間隔の取得・通信の有効/無効)(仮)" },
{ key: "postcondition_security", label: "セキュリティ状態の報告(仮)" },
{ key: "postcondition_kick_action", label: "セコム状態の監視・清掃の起動(仮)" },
{ key: "send_start_sig", label: "清掃開始信号(仮)" },
] as const;
type Command =
| { command_id: string; kind: "list"; params: Record<string, unknown> }
| {
command_id: string;
kind: "export";
params: { file_ids?: string[]; max_bytes?: number; chunk_bytes?: number };
}
| { command_id: string; kind: "send"; params: { export_command_id?: string } };
type Args = {
server: string;
zipPath: string;
failPrepare: string | null;
};
function usage(message?: string): never {
if (message) console.error(`[ds-agent-fake] ${message}`);
console.error(
"Usage: npx tsx scripts/ds-agent-fake.ts --server <url> --zip <path> [--fail-prepare <code>]\n" +
" Env: DS_AGENT_TOKEN (required; never logged)"
);
process.exit(1);
}
function parseArgs(argv: string[]): Args {
let server: string | null = null;
let zipPath: string | null = null;
let failPrepare: string | null = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = argv[i + 1];
if (a === "--server" && next) {
server = next;
i++;
} else if (a === "--zip" && next) {
zipPath = next;
i++;
} else if (a === "--fail-prepare" && next) {
failPrepare = next;
i++;
} else if (a === "--help" || a === "-h") {
usage();
} else {
usage(`unknown argument: ${a}`);
}
}
if (!server) usage("--server is required");
if (!zipPath) usage("--zip is required");
return { server: server.replace(/\/$/, ""), zipPath: resolve(zipPath), failPrepare };
}
/** JST 壁時計の ISO(+09:00)。modified_at はオフセット必須 */
function toJstIso(date: Date): string {
const jst = new Date(date.getTime() + 9 * 60 * 60 * 1000);
const y = jst.getUTCFullYear();
const m = String(jst.getUTCMonth() + 1).padStart(2, "0");
const d = String(jst.getUTCDate()).padStart(2, "0");
const hh = String(jst.getUTCHours()).padStart(2, "0");
const mm = String(jst.getUTCMinutes()).padStart(2, "0");
const ss = String(jst.getUTCSeconds()).padStart(2, "0");
return `${y}-${m}-${d}T${hh}:${mm}:${ss}+09:00`;
}
function sha256Hex(bytes: Uint8Array | Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
/** 例外メッセージにトークンが混ざっていたら伏せる(値は絶対に出さない) */
function safeError(err: unknown, token: string): string {
const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
return token && text.includes(token) ? text.split(token).join("***") : text;
}
/**
* 固定の一覧(約10件。実機の種類・name の形。B-9・M6)。
* name は `{root の name}/{相対パス}` = `<種類>/<種類>.log` と日付つき。
*/
function fixedListResult(now: Date) {
const nowMs = now.getTime();
const day = 24 * 60 * 60 * 1000;
const items: Array<{
file_id: string;
name: string;
type: string;
size_bytes: number;
modified_at: string;
}> = [];
let n = 0;
const push = (type: string, name: string, modifiedMs: number, size: number) => {
n += 1;
items.push({
file_id: `fake-${n}`,
name,
type,
size_bytes: size,
modified_at: toJstIso(new Date(modifiedMs)),
});
};
// 各種類の最新
for (const t of LOG_TYPES) {
push(t.key, `${t.key}/${t.key}.log`, nowMs - items.length * 60_000, 4096 + items.length * 512);
}
// 日付つき(各種類1日分 + 追加2件で約10件)
const dates = [1, 2].map((d) => toJstIso(new Date(nowMs - d * day)).slice(0, 10));
push(
"api_client",
`api_client/api_client.log.${dates[0]}`,
nowMs - day,
8192
);
push(
"postcondition_security",
`postcondition_security/postcondition_security.log.${dates[0]}`,
nowMs - day - 60_000,
12288
);
push(
"postcondition_kick_action",
`postcondition_kick_action/postcondition_kick_action.log.${dates[0]}`,
nowMs - day - 120_000,
16384
);
push(
"send_start_sig",
`send_start_sig/send_start_sig.log.${dates[0]}`,
nowMs - day - 180_000,
20480
);
push(
"api_client",
`api_client/api_client.log.${dates[1]}`,
nowMs - 2 * day,
3072
);
push(
"postcondition_security",
`postcondition_security/postcondition_security.log.${dates[1]}`,
nowMs - 2 * day - 60_000,
6144
);
return {
types: LOG_TYPES.map((t) => ({ key: t.key, label: t.label })),
items,
truncated: false as const,
};
}
class FakeAgent {
private readonly base: string;
private readonly token: string;
private readonly zipBytes: Buffer;
private readonly zipSha256: string;
private readonly failPrepare: string | null;
/** export の command_id → 使った chunk_bytes(send で参照) */
private readonly preparedChunkBytes = new Map<string, number>();
private stopping = false;
constructor(opts: { server: string; token: string; zipPath: string; failPrepare: string | null }) {
this.base = opts.server;
this.token = opts.token;
this.failPrepare = opts.failPrepare;
const st = statSync(opts.zipPath);
if (!st.isFile()) throw new Error(`--zip is not a file: ${opts.zipPath}`);
this.zipBytes = readFileSync(opts.zipPath);
this.zipSha256 = sha256Hex(this.zipBytes);
console.log(
`[ds-agent-fake] zip loaded path=${opts.zipPath} bytes=${this.zipBytes.length} sha256=${this.zipSha256}`
);
if (opts.failPrepare) {
console.log(`[ds-agent-fake] --fail-prepare ${opts.failPrepare} (export will fail)`);
}
}
private headers(extra?: Record<string, string>): Record<string, string> {
return {
Authorization: `Bearer ${this.token}`,
"X-Agent-Version": AGENT_VERSION,
"User-Agent": USER_AGENT,
...extra,
};
}
private commandUrl(commandId: string, ...parts: Array<string | number>): string {
const enc = encodeURIComponent(commandId);
const rest = parts.map((p) => String(p)).join("/");
return `${this.base}/api/ds-agent/commands/${enc}/${rest}`;
}
private async request(
method: string,
url: string,
init?: { body?: BodyInit | null; headers?: Record<string, string>; timeoutMs?: number }
): Promise<Response> {
const signal =
init?.timeoutMs !== undefined ? AbortSignal.timeout(init.timeoutMs) : undefined;
return fetch(url, {
method,
headers: this.headers(init?.headers),
body: init?.body ?? null,
redirect: "manual",
signal,
});
}
private async postJson(commandId: string, action: "result" | "prepared" | "complete" | "fail", body: unknown) {
const res = await this.request("POST", this.commandUrl(commandId, action), {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
timeoutMs: 30_000,
});
return res.status;
}
async handle(cmd: Command): Promise<void> {
switch (cmd.kind) {
case "list":
await this.doList(cmd.command_id);
return;
case "export":
await this.doExport(cmd);
return;
case "send":
await this.doSend(cmd);
return;
default: {
const unknown = cmd as { command_id?: string; kind?: string };
if (unknown.command_id) {
await this.postJson(unknown.command_id, "fail", {
code: "unsupported",
message: `unknown kind: ${String(unknown.kind)}`,
});
}
}
}
}
private async doList(commandId: string): Promise<void> {
const status = await this.postJson(commandId, "result", fixedListResult(new Date()));
console.log(`[ds-agent-fake] list result status=${status} items≈10`);
}
private async doExport(cmd: Extract<Command, { kind: "export" }>): Promise<void> {
if (this.failPrepare) {
const status = await this.postJson(cmd.command_id, "fail", {
code: this.failPrepare,
message: `fake prepare failed: ${this.failPrepare}`,
});
console.log(`[ds-agent-fake] export fail code=${this.failPrepare} status=${status}`);
return;
}
const fileIds = Array.isArray(cmd.params.file_ids) ? cmd.params.file_ids : [];
const fileCount = Math.max(1, fileIds.length);
const chunkBytes =
typeof cmd.params.chunk_bytes === "number" && cmd.params.chunk_bytes > 0
? Math.min(cmd.params.chunk_bytes, CHUNK_MAX_BYTES)
: CHUNK_MAX_BYTES;
const status = await this.postJson(cmd.command_id, "prepared", {
total_bytes: this.zipBytes.length,
sha256: this.zipSha256,
file_count: fileCount,
});
if (status === 204) {
this.preparedChunkBytes.set(cmd.command_id, chunkBytes);
}
console.log(
`[ds-agent-fake] export prepared status=${status} bytes=${this.zipBytes.length} files=${fileCount}`
);
}
private async doSend(cmd: Extract<Command, { kind: "send" }>): Promise<void> {
const exportId = cmd.params.export_command_id;
if (typeof exportId !== "string" || exportId.length === 0) {
await this.postJson(cmd.command_id, "fail", {
code: "export_not_found",
message: "export_command_id missing",
});
console.log("[ds-agent-fake] send fail: missing export_command_id");
return;
}
const chunkBytes = this.preparedChunkBytes.get(exportId) ?? CHUNK_MAX_BYTES;
let seq = 0;
for (let offset = 0; offset < this.zipBytes.length; offset += chunkBytes, seq++) {
const chunk = Uint8Array.from(this.zipBytes.subarray(offset, offset + chunkBytes));
const chunkSha = sha256Hex(chunk);
let res: Response;
try {
res = await this.request("POST", this.commandUrl(exportId, "chunks", seq), {
body: chunk,
headers: {
"Content-Type": "application/octet-stream",
"X-Chunk-Sha256": chunkSha,
},
timeoutMs: CHUNK_POST_TIMEOUT_SEC * 1000,
});
} catch (err) {
console.error(`[ds-agent-fake] chunk ${seq} network error: ${safeError(err, this.token)}`);
await this.postJson(exportId, "fail", {
code: "io_error",
message: `chunk ${seq} upload failed`,
});
return;
}
if (res.status === 204) {
console.log(`[ds-agent-fake] chunk seq=${seq} bytes=${chunk.length} ok`);
continue;
}
if (res.status === 410 || res.status === 409) {
console.log(`[ds-agent-fake] chunk seq=${seq} stop status=${res.status}`);
return;
}
console.error(`[ds-agent-fake] chunk seq=${seq} rejected status=${res.status}`);
await this.postJson(exportId, "fail", {
code: "io_error",
message: `chunk ${seq} status ${res.status}`,
});
return;
}
const status = await this.postJson(exportId, "complete", {
total_bytes: this.zipBytes.length,
sha256: this.zipSha256,
});
this.preparedChunkBytes.delete(exportId);
console.log(`[ds-agent-fake] complete status=${status} chunks=${seq}`);
}
async run(): Promise<void> {
console.log(`[ds-agent-fake] polling ${this.base}/api/ds-agent/commands (version=${AGENT_VERSION})`);
let backoffSec = 1;
while (!this.stopping) {
const url = `${this.base}/api/ds-agent/commands?wait=${POLL_WAIT_SEC}`;
let res: Response;
try {
res = await this.request("GET", url, {
timeoutMs: (POLL_WAIT_SEC + 10) * 1000,
});
} catch (err) {
if (this.stopping) return;
console.warn(
`[ds-agent-fake] poll failed: ${safeError(err, this.token)} (retry in ${backoffSec}s)`
);
await sleep(backoffSec * 1000);
backoffSec = Math.min(backoffSec * 2, 30);
continue;
}
if (res.status === 200) {
backoffSec = 1;
let cmd: Command;
try {
cmd = (await res.json()) as Command;
} catch (err) {
console.error(`[ds-agent-fake] poll body not JSON: ${safeError(err, this.token)}`);
await sleep(backoffSec * 1000);
backoffSec = Math.min(backoffSec * 2, 30);
continue;
}
console.log(`[ds-agent-fake] command kind=${cmd.kind} id=${cmd.command_id}`);
// list はエクスポートと並行して返す(本物と同じ。D25 ③)
void this.handle(cmd).catch((err) => {
console.error(`[ds-agent-fake] handle error: ${safeError(err, this.token)}`);
});
continue;
}
if (res.status === 204) {
backoffSec = 1;
continue;
}
if (res.status === 401 || res.status === 403) {
console.error(
`[ds-agent-fake] poll rejected status=${res.status} (token wrong/revoked or IP). retry in 30s`
);
await sleep(30_000);
continue;
}
console.warn(`[ds-agent-fake] poll status=${res.status} (retry in ${backoffSec}s)`);
await sleep(backoffSec * 1000);
backoffSec = Math.min(backoffSec * 2, 30);
}
}
stop(): void {
this.stopping = true;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const token = process.env.DS_AGENT_TOKEN;
if (!token || token.trim() === "") {
usage("DS_AGENT_TOKEN env is required");
}
const agent = new FakeAgent({
server: args.server,
token,
zipPath: args.zipPath,
failPrepare: args.failPrepare,
});
const onSignal = () => {
console.log("[ds-agent-fake] stopping…");
agent.stop();
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
await agent.run();
}
main().catch((err) => {
// 起動前にトークンを読む前の失敗もあるので、値は出さない
const msg = err instanceof Error ? err.message : String(err);
console.error(`[ds-agent-fake] fatal: ${msg}`);
process.exit(1);
});